dsh-gitbar-lite 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.
- package/LICENSE +21 -0
- package/README.en.md +274 -0
- package/README.md +246 -2
- package/cordis.patch.yml +19 -0
- package/docs/CONTRACT.md +394 -0
- package/icon.svg +15 -0
- package/lib/client.js +2154 -0
- package/lib/host/diff.js +216 -0
- package/lib/host/git.js +211 -0
- package/lib/host/parse.js +267 -0
- package/lib/host/repo.js +729 -0
- package/lib/host/routes.js +448 -0
- package/lib/host/store.js +360 -0
- package/lib/host/workspace.js +84 -0
- package/lib/index.js +160 -0
- package/lib/shared.js +125 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +88 -4
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable state: workspace→repository bindings, UI preferences, and the plugin's
|
|
3
|
+
* own trash.
|
|
4
|
+
*
|
|
5
|
+
* Where it lives
|
|
6
|
+
* --------------
|
|
7
|
+
* `<DSH_HOME>/dsh-gitbar-lite/`, falling back to `~/.dsh` exactly the way
|
|
8
|
+
* `@deepseek-ai/dsh-home-paths` does. Do not read `$DSH_HOME` alone: the Host
|
|
9
|
+
* process usually does *not* have it set (the variable is injected into the
|
|
10
|
+
* shell the Harness spawns), so a state file written under a bare
|
|
11
|
+
* `process.env.DSH_HOME` would silently land somewhere else or nowhere. This is
|
|
12
|
+
* the same trap `dsh-tailscale-remote` documents in its own state-file code.
|
|
13
|
+
*
|
|
14
|
+
* Why not the settings service or a storage domain
|
|
15
|
+
* -----------------------------------------------
|
|
16
|
+
* A binding is keyed by an arbitrary absolute workspace path, and a storage
|
|
17
|
+
* domain's per-record keys are restricted to `^[a-zA-Z0-9_-]+$` because they
|
|
18
|
+
* become path segments — `C:\Users\...\<folder with spaces>` cannot be one
|
|
19
|
+
* without hashing it, at which point the "native" option is doing less for us
|
|
20
|
+
* than the twelve lines below. A settings `Config` would put a growing map of
|
|
21
|
+
* machine paths into the user-visible settings form, which is worse. So this is
|
|
22
|
+
* a plain JSON file the plugin owns, written through a temp file and a rename
|
|
23
|
+
* so a crash mid-write cannot truncate it.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-gitbar-lite/host/store
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
29
|
+
import { realpathSync } from 'node:fs'
|
|
30
|
+
import { homedir } from 'node:os'
|
|
31
|
+
import { dirname, join, resolve } from 'node:path'
|
|
32
|
+
|
|
33
|
+
import { STATE_DIR_NAME, STATE_FILES } from '../shared.js'
|
|
34
|
+
|
|
35
|
+
/** Schema version of every file this module writes. */
|
|
36
|
+
const STATE_VERSION = 1
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the DSH home directory, matching `resolveDshHome` from
|
|
40
|
+
* `@deepseek-ai/dsh-home-paths`: explicit config wins, then a non-blank
|
|
41
|
+
* `$DSH_HOME`, then `~/.dsh`.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} configured
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function resolveDshHome(configured) {
|
|
47
|
+
if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
|
|
48
|
+
const fromEnv = process.env.DSH_HOME
|
|
49
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv.trim()
|
|
50
|
+
return join(homedir(), '.dsh')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Canonicalize a path so two spellings of one directory share a record: resolve
|
|
55
|
+
* relatives, drop a trailing separator, and (best effort) follow junctions and
|
|
56
|
+
* symlinks to their real target. Falls back to the lexical form when the path
|
|
57
|
+
* does not exist yet.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} input
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function canonicalize(input) {
|
|
63
|
+
let absolute
|
|
64
|
+
try {
|
|
65
|
+
absolute = resolve(input)
|
|
66
|
+
} catch {
|
|
67
|
+
return String(input)
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return realpathSync.native(absolute)
|
|
71
|
+
} catch {
|
|
72
|
+
return absolute
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The binding key for a workspace path: canonical, separator-normalized, and
|
|
78
|
+
* case-folded on Windows, where `C:\Foo` and `c:\foo` are one directory.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} workspacePath
|
|
81
|
+
*/
|
|
82
|
+
export function workspaceKey(workspacePath) {
|
|
83
|
+
const canonical = canonicalize(workspacePath).replace(/[\\/]+$/, '')
|
|
84
|
+
return process.platform === 'win32' ? canonical.toLowerCase() : canonical
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A filesystem-safe, roughly sortable id for a trash entry. */
|
|
88
|
+
export function trashId(now = Date.now(), random = Math.random) {
|
|
89
|
+
const suffix = Math.floor(random() * 0xffffffff)
|
|
90
|
+
.toString(36)
|
|
91
|
+
.padStart(7, '0')
|
|
92
|
+
return `${now.toString(36)}-${suffix}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the store bound to one plugin instance.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} ctx host context (for `logger` only)
|
|
99
|
+
* @param {object} config resolved plugin config
|
|
100
|
+
*/
|
|
101
|
+
export function createStore(ctx, config) {
|
|
102
|
+
const warn = (message) => {
|
|
103
|
+
try {
|
|
104
|
+
ctx.logger?.warn?.(message)
|
|
105
|
+
} catch {
|
|
106
|
+
// Logging must never be the thing that breaks a request.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const baseDir = () =>
|
|
111
|
+
typeof config.stateDir === 'string' && config.stateDir.trim() !== ''
|
|
112
|
+
? config.stateDir.trim()
|
|
113
|
+
: join(resolveDshHome(process.env.DSH_HOME), STATE_DIR_NAME)
|
|
114
|
+
|
|
115
|
+
const bindingsPath = () => join(baseDir(), STATE_FILES.BINDINGS)
|
|
116
|
+
const prefsPath = () => join(baseDir(), STATE_FILES.PREFS)
|
|
117
|
+
const trashIndexPath = () => join(baseDir(), STATE_FILES.TRASH_INDEX)
|
|
118
|
+
const trashDir = () => join(baseDir(), 'trash')
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Serialize writes per file.
|
|
122
|
+
*
|
|
123
|
+
* Two tabs (or the panel and its trash tab) can issue overlapping mutations,
|
|
124
|
+
* and a read-modify-write that interleaves loses one of them. Chaining per
|
|
125
|
+
* path is enough: every writer goes through `mutateJson`.
|
|
126
|
+
*/
|
|
127
|
+
const queues = new Map()
|
|
128
|
+
function serialize(path, work) {
|
|
129
|
+
const previous = queues.get(path) ?? Promise.resolve()
|
|
130
|
+
const next = previous.then(work, work)
|
|
131
|
+
queues.set(
|
|
132
|
+
path,
|
|
133
|
+
next.then(
|
|
134
|
+
() => undefined,
|
|
135
|
+
() => undefined,
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
return next
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Read JSON, returning `fallback` for anything missing or malformed. */
|
|
142
|
+
async function readJson(path, fallback) {
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'))
|
|
145
|
+
if (parsed === null || typeof parsed !== 'object') return fallback
|
|
146
|
+
return parsed
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error?.code !== 'ENOENT') warn(`dsh-gitbar-lite: ignoring unreadable ${path}: ${String(error?.message ?? error)}`)
|
|
149
|
+
return fallback
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Write JSON through a temp file plus rename, so a crash cannot truncate it. */
|
|
154
|
+
async function writeJson(path, value) {
|
|
155
|
+
const temporary = `${path}.tmp`
|
|
156
|
+
await mkdir(dirname(path), { recursive: true })
|
|
157
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
158
|
+
await rename(temporary, path)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Read-modify-write under the per-path queue. */
|
|
162
|
+
function mutateJson(path, fallback, mutate) {
|
|
163
|
+
return serialize(path, async () => {
|
|
164
|
+
const current = await readJson(path, fallback)
|
|
165
|
+
const next = await mutate(current)
|
|
166
|
+
await writeJson(path, next)
|
|
167
|
+
return next
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
baseDir,
|
|
173
|
+
trashDir,
|
|
174
|
+
bindingsPath,
|
|
175
|
+
prefsPath,
|
|
176
|
+
trashIndexPath,
|
|
177
|
+
readJson,
|
|
178
|
+
writeJson,
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------- bindings
|
|
181
|
+
|
|
182
|
+
/** Every binding, keyed by `workspaceKey`. */
|
|
183
|
+
async readBindings() {
|
|
184
|
+
const document = await readJson(bindingsPath(), { version: STATE_VERSION, bindings: {} })
|
|
185
|
+
const bindings = document?.bindings
|
|
186
|
+
return bindings !== null && typeof bindings === 'object' ? bindings : {}
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
/** The repository bound to one workspace path, or `undefined`. */
|
|
190
|
+
async readBinding(workspacePath) {
|
|
191
|
+
const bindings = await this.readBindings()
|
|
192
|
+
const record = bindings[workspaceKey(workspacePath)]
|
|
193
|
+
return typeof record?.repoRoot === 'string' && record.repoRoot !== '' ? record.repoRoot : undefined
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Bind (or, with `undefined`, unbind) one workspace path.
|
|
198
|
+
*
|
|
199
|
+
* @returns {Promise<string | undefined>} the repository now bound
|
|
200
|
+
*/
|
|
201
|
+
async writeBinding(workspacePath, repoRoot) {
|
|
202
|
+
const key = workspaceKey(workspacePath)
|
|
203
|
+
const canonicalWorkspace = canonicalize(workspacePath)
|
|
204
|
+
let result
|
|
205
|
+
await mutateJson(bindingsPath(), { version: STATE_VERSION, bindings: {} }, (document) => {
|
|
206
|
+
const bindings = document?.bindings !== null && typeof document?.bindings === 'object' ? { ...document.bindings } : {}
|
|
207
|
+
if (repoRoot === undefined) {
|
|
208
|
+
delete bindings[key]
|
|
209
|
+
result = undefined
|
|
210
|
+
} else {
|
|
211
|
+
bindings[key] = {
|
|
212
|
+
repoRoot: canonicalize(repoRoot),
|
|
213
|
+
workspacePath: canonicalWorkspace,
|
|
214
|
+
updatedAt: new Date().toISOString(),
|
|
215
|
+
}
|
|
216
|
+
result = bindings[key].repoRoot
|
|
217
|
+
}
|
|
218
|
+
return { version: STATE_VERSION, bindings }
|
|
219
|
+
})
|
|
220
|
+
return result
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
// ------------------------------------------------------------------- prefs
|
|
224
|
+
|
|
225
|
+
/** Preferences, with defaults applied. */
|
|
226
|
+
async readPrefs() {
|
|
227
|
+
const document = await readJson(prefsPath(), { version: STATE_VERSION })
|
|
228
|
+
return {
|
|
229
|
+
/**
|
|
230
|
+
* Whether the discard confirmation starts with "delete for good" ticked.
|
|
231
|
+
* Default false: the safe answer is the one that keeps a copy.
|
|
232
|
+
*/
|
|
233
|
+
trashHardDelete: document?.trashHardDelete === true,
|
|
234
|
+
/** Whether the trash lists entries from every repository, not just the bound one. */
|
|
235
|
+
trashShowAllRepos: document?.trashShowAllRepos === true,
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
/** Merge a preferences patch. */
|
|
240
|
+
async writePrefs(patch) {
|
|
241
|
+
let result
|
|
242
|
+
await mutateJson(prefsPath(), { version: STATE_VERSION }, (document) => {
|
|
243
|
+
result = {
|
|
244
|
+
version: STATE_VERSION,
|
|
245
|
+
trashHardDelete: (patch?.trashHardDelete ?? document?.trashHardDelete) === true,
|
|
246
|
+
trashShowAllRepos: (patch?.trashShowAllRepos ?? document?.trashShowAllRepos) === true,
|
|
247
|
+
}
|
|
248
|
+
return result
|
|
249
|
+
})
|
|
250
|
+
return result
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
// ------------------------------------------------------------------- trash
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The trash index: metadata for every archived discard, newest first.
|
|
257
|
+
*
|
|
258
|
+
* Entries whose blob is missing are still listed — the metadata (which
|
|
259
|
+
* repository, which base commit, which files) is worth keeping even if
|
|
260
|
+
* someone deleted the `.diff` by hand.
|
|
261
|
+
*/
|
|
262
|
+
async readTrash() {
|
|
263
|
+
const document = await readJson(trashIndexPath(), { version: STATE_VERSION, entries: [] })
|
|
264
|
+
const entries = Array.isArray(document?.entries) ? document.entries : []
|
|
265
|
+
return entries.filter((entry) => entry !== null && typeof entry?.id === 'string')
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Append one entry, trimming the index to `config.trashLimit` and deleting
|
|
270
|
+
* the blobs of whatever falls off the end.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} entry
|
|
273
|
+
* @param {string} diffText
|
|
274
|
+
*/
|
|
275
|
+
async appendTrash(entry, diffText) {
|
|
276
|
+
const blobName = `${entry.id}.diff`
|
|
277
|
+
await mkdir(trashDir(), { recursive: true })
|
|
278
|
+
await writeFile(join(trashDir(), blobName), diffText, 'utf8')
|
|
279
|
+
|
|
280
|
+
let dropped = []
|
|
281
|
+
await mutateJson(trashIndexPath(), { version: STATE_VERSION, entries: [] }, (document) => {
|
|
282
|
+
const existing = Array.isArray(document?.entries) ? document.entries : []
|
|
283
|
+
const limit = Number.isFinite(config.trashLimit) && config.trashLimit > 0 ? config.trashLimit : 200
|
|
284
|
+
const combined = [{ ...entry, blob: blobName }, ...existing]
|
|
285
|
+
dropped = combined.slice(limit)
|
|
286
|
+
return { version: STATE_VERSION, entries: combined.slice(0, limit) }
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
for (const stale of dropped) {
|
|
290
|
+
if (typeof stale?.blob === 'string') await this.removeTrashBlob(stale.blob)
|
|
291
|
+
}
|
|
292
|
+
return entry
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
/** One entry's metadata, or `undefined`. */
|
|
296
|
+
async readTrashEntry(id) {
|
|
297
|
+
const entries = await this.readTrash()
|
|
298
|
+
return entries.find((entry) => entry.id === id)
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
/** One entry's archived diff text, or `undefined` when the blob is gone. */
|
|
302
|
+
async readTrashDiff(entry) {
|
|
303
|
+
const blob = typeof entry?.blob === 'string' && entry.blob !== '' ? entry.blob : `${entry?.id}.diff`
|
|
304
|
+
try {
|
|
305
|
+
return await readFile(join(trashDir(), blob), 'utf8')
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (error?.code !== 'ENOENT') warn(`dsh-gitbar-lite: reading trash blob failed: ${String(error?.message ?? error)}`)
|
|
308
|
+
return undefined
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
/** Delete one blob, tolerating its absence. */
|
|
313
|
+
async removeTrashBlob(blob) {
|
|
314
|
+
const name = String(blob).replace(/[\\/]/g, '')
|
|
315
|
+
if (name === '' || name.includes('..')) return
|
|
316
|
+
try {
|
|
317
|
+
await rm(join(trashDir(), name), { force: true })
|
|
318
|
+
} catch (error) {
|
|
319
|
+
warn(`dsh-gitbar-lite: removing trash blob failed: ${String(error?.message ?? error)}`)
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Drop one entry and its blob.
|
|
325
|
+
*
|
|
326
|
+
* @returns {Promise<boolean>} whether an entry was actually removed
|
|
327
|
+
*/
|
|
328
|
+
async removeTrash(id) {
|
|
329
|
+
const entry = await this.readTrashEntry(id)
|
|
330
|
+
if (entry === undefined) return false
|
|
331
|
+
await mutateJson(trashIndexPath(), { version: STATE_VERSION, entries: [] }, (document) => {
|
|
332
|
+
const existing = Array.isArray(document?.entries) ? document.entries : []
|
|
333
|
+
return { version: STATE_VERSION, entries: existing.filter((candidate) => candidate.id !== id) }
|
|
334
|
+
})
|
|
335
|
+
if (typeof entry.blob === 'string') await this.removeTrashBlob(entry.blob)
|
|
336
|
+
return true
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Empty the trash.
|
|
341
|
+
*
|
|
342
|
+
* The blobs are swept by directory listing rather than by index, so a blob
|
|
343
|
+
* orphaned by an interrupted write goes too.
|
|
344
|
+
*
|
|
345
|
+
* @returns {Promise<number>} how many entries were removed
|
|
346
|
+
*/
|
|
347
|
+
async clearTrash() {
|
|
348
|
+
const entries = await this.readTrash()
|
|
349
|
+
await writeJson(trashIndexPath(), { version: STATE_VERSION, entries: [] })
|
|
350
|
+
try {
|
|
351
|
+
for (const name of await readdir(trashDir())) {
|
|
352
|
+
if (name.endsWith('.diff')) await this.removeTrashBlob(name)
|
|
353
|
+
}
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error?.code !== 'ENOENT') warn(`dsh-gitbar-lite: sweeping trash failed: ${String(error?.message ?? error)}`)
|
|
356
|
+
}
|
|
357
|
+
return entries.length
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which directory is this session's workspace?
|
|
3
|
+
*
|
|
4
|
+
* The answer is taken from the same place the shipped
|
|
5
|
+
* `@deepseek-ai/dsh-api-workspace-files` takes it: the session header's `cwd`,
|
|
6
|
+
* falling back to the sandbox policy's `workspaceRoot`. That is the authoritative
|
|
7
|
+
* answer — a workspace record's `sessionIds` is *not*, because the registry only
|
|
8
|
+
* indexes sessions that existed when the Host booted, so a session created a
|
|
9
|
+
* minute ago is filtered out of its own workspace's list.
|
|
10
|
+
*
|
|
11
|
+
* Every value here is optional-chained: a plugin must not turn a missing
|
|
12
|
+
* optional service into a thrown error that blanks a panel.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-gitbar-lite/host/workspace
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ERROR_CODES } from '../shared.js'
|
|
18
|
+
import { RepoError } from './repo.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The header of one session, live when possible and persisted otherwise.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} ctx host context
|
|
24
|
+
* @param {string} sessionId
|
|
25
|
+
* @returns {Promise<object | undefined>}
|
|
26
|
+
*/
|
|
27
|
+
async function headerOf(ctx, sessionId) {
|
|
28
|
+
const live = ctx.sessions?.get?.(sessionId)?.header
|
|
29
|
+
if (live !== undefined && live !== null) return live
|
|
30
|
+
const persistence = ctx.get?.('sessionPersistence', false)
|
|
31
|
+
if (persistence === undefined || typeof persistence.stat !== 'function') return undefined
|
|
32
|
+
try {
|
|
33
|
+
return (await persistence.stat(sessionId))?.header
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The workspace directory a session works in.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} ctx host context
|
|
43
|
+
* @param {string} sessionId
|
|
44
|
+
* @returns {Promise<{ sessionId: string, cwd: string, workspaceTitle: string | null } | undefined>}
|
|
45
|
+
*/
|
|
46
|
+
export async function workspaceOf(ctx, sessionId) {
|
|
47
|
+
const header = await headerOf(ctx, sessionId)
|
|
48
|
+
if (header === undefined) return undefined
|
|
49
|
+
|
|
50
|
+
const fromPolicy = ctx.get?.('sandboxPolicy', false)?.workspaceRoot
|
|
51
|
+
const cwd = typeof header.cwd === 'string' && header.cwd !== '' ? header.cwd : typeof fromPolicy === 'string' ? fromPolicy : ''
|
|
52
|
+
if (cwd === '') return undefined
|
|
53
|
+
|
|
54
|
+
let workspaceTitle = null
|
|
55
|
+
const registry = ctx.get?.('workspaceRegistry', false)
|
|
56
|
+
if (registry !== undefined && typeof registry.resolveByPath === 'function') {
|
|
57
|
+
try {
|
|
58
|
+
workspaceTitle = (await registry.resolveByPath(cwd))?.title ?? null
|
|
59
|
+
} catch {
|
|
60
|
+
// `resolveByPath` realpaths and therefore rejects for a missing directory;
|
|
61
|
+
// the cwd we already have is still the right answer.
|
|
62
|
+
workspaceTitle = null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { sessionId, cwd, workspaceTitle }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `workspaceOf`, or a `RepoError` carrying the user-facing reason.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} ctx host context
|
|
73
|
+
* @param {string} sessionId
|
|
74
|
+
*/
|
|
75
|
+
export async function requireWorkspace(ctx, sessionId) {
|
|
76
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
77
|
+
throw new RepoError(ERROR_CODES.BAD_REQUEST, '缺少 sessionId。')
|
|
78
|
+
}
|
|
79
|
+
const workspace = await workspaceOf(ctx, sessionId)
|
|
80
|
+
if (workspace === undefined) {
|
|
81
|
+
throw new RepoError(ERROR_CODES.NO_WORKSPACE, '拿不到这个会话的工作区目录,无法定位仓库。')
|
|
82
|
+
}
|
|
83
|
+
return workspace
|
|
84
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-gitbar-lite — host half.
|
|
3
|
+
*
|
|
4
|
+
* A GitHub Desktop-shaped Git panel for the built-in right sidebar. The host
|
|
5
|
+
* half owns everything that touches the disk or the repository:
|
|
6
|
+
*
|
|
7
|
+
* - `createGit` runs git through `ctx.subprocess` (resolved executable,
|
|
8
|
+
* containment, a bounded output buffer, a scrubbed env);
|
|
9
|
+
* - `createRepo` the operations themselves — status, stage, commit,
|
|
10
|
+
* push, fetch, per-file diff, discard;
|
|
11
|
+
* - `createStore` the workspace→repository bindings, the UI preferences,
|
|
12
|
+
* and the plugin's own trash under `<DSH_HOME>`;
|
|
13
|
+
* - `createHandlers` the RPC actions the browser half calls;
|
|
14
|
+
* - `createRoute` one exact Fetch route on `connection.fetch`.
|
|
15
|
+
*
|
|
16
|
+
* The browser half lives in `lib/client.js` and speaks to this one only over
|
|
17
|
+
* that route, so it inherits the harness Host/Origin fence and the browser
|
|
18
|
+
* cookie check for free.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-gitbar-lite
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import z from '@deepseek-ai/schemastery'
|
|
24
|
+
|
|
25
|
+
import { createGit } from './host/git.js'
|
|
26
|
+
import { createRepo } from './host/repo.js'
|
|
27
|
+
import { createHandlers, createRoute } from './host/routes.js'
|
|
28
|
+
import { createStore } from './host/store.js'
|
|
29
|
+
import { PLUGIN_ID } from './shared.js'
|
|
30
|
+
|
|
31
|
+
/** Stable Loader identity; also the settings namespace and the client bundle id. */
|
|
32
|
+
export const name = PLUGIN_ID
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Only what the plugin cannot work without.
|
|
36
|
+
*
|
|
37
|
+
* `connection` is deliberately NOT here: declaring it would park the whole
|
|
38
|
+
* plugin when the Web bundle is absent, and the plugin is still perfectly able
|
|
39
|
+
* to run headless. It is acquired opportunistically instead (see `apply`).
|
|
40
|
+
*/
|
|
41
|
+
export const inject = ['subprocess', 'sessions']
|
|
42
|
+
|
|
43
|
+
/** Guard for a schemastery build without `volatile()`. */
|
|
44
|
+
const vol = (schema) => (typeof schema?.volatile === 'function' ? schema.volatile() : schema)
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Plugin configuration.
|
|
48
|
+
*
|
|
49
|
+
* Every field is volatile, which is what lets the user change them from the
|
|
50
|
+
* settings page without a restart — and is why `cordis.patch.yml` deliberately
|
|
51
|
+
* carries no `config` block: values written into the profile's own patch layer
|
|
52
|
+
* must not be shadowed by this package on the next upgrade.
|
|
53
|
+
*/
|
|
54
|
+
export const Config = vol(
|
|
55
|
+
z.object({
|
|
56
|
+
/** Explicit git executable; empty means "whatever is on PATH". */
|
|
57
|
+
gitPath: z.string().default(''),
|
|
58
|
+
/** Override the plugin's state directory; empty means `<DSH_HOME>/dsh-gitbar-lite`. */
|
|
59
|
+
stateDir: z.string().default(''),
|
|
60
|
+
/** Per-command timeout. A push to a slow remote may need more. */
|
|
61
|
+
timeoutMs: z.number().default(30000),
|
|
62
|
+
/** Ceiling on one archived diff, in bytes. */
|
|
63
|
+
maxDiffBytes: z.number().default(512 * 1024),
|
|
64
|
+
/** Files larger than this are listed but their content is not archived or diffed. */
|
|
65
|
+
maxFileBytes: z.number().default(2 * 1024 * 1024),
|
|
66
|
+
/** How many trash entries to keep before the oldest are dropped. */
|
|
67
|
+
trashLimit: z.number().default(200),
|
|
68
|
+
/** Directory depth the repository picker scans below the workspace root. */
|
|
69
|
+
scanDepth: z.number().default(3),
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
/** Defaults, read from the schema rather than restated. */
|
|
74
|
+
const DEFAULTS = new Config({}).get()
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the live config.
|
|
78
|
+
*
|
|
79
|
+
* A volatile schemastery root is a frozenset whose values are read through
|
|
80
|
+
* `get()`; a plain one is the object itself. Both shapes have to work, because
|
|
81
|
+
* whether the schema is volatile is not something this module controls.
|
|
82
|
+
*
|
|
83
|
+
* @param {unknown} rawConfig
|
|
84
|
+
* @returns {typeof DEFAULTS}
|
|
85
|
+
*/
|
|
86
|
+
export function readConfig(rawConfig) {
|
|
87
|
+
const source = typeof rawConfig?.get === 'function' ? rawConfig.get() : rawConfig
|
|
88
|
+
const record = source !== null && typeof source === 'object' ? source : {}
|
|
89
|
+
const resolved = { ...DEFAULTS }
|
|
90
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
91
|
+
const value = record[key]
|
|
92
|
+
if (value !== undefined && value !== null) resolved[key] = value
|
|
93
|
+
}
|
|
94
|
+
return resolved
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Install the host half.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} ctx host context
|
|
101
|
+
* @param {unknown} rawConfig the Loader's config for this row
|
|
102
|
+
*/
|
|
103
|
+
export function apply(ctx, rawConfig) {
|
|
104
|
+
const config = readConfig(rawConfig)
|
|
105
|
+
const git = createGit(ctx, config)
|
|
106
|
+
const store = createStore(ctx, config)
|
|
107
|
+
const repo = createRepo(git, config)
|
|
108
|
+
const handlers = createHandlers({ ctx, git, repo, store })
|
|
109
|
+
const route = createRoute({ handlers, logger: ctx.logger })
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Register the route on the preferred carrier, exactly once.
|
|
113
|
+
*
|
|
114
|
+
* The guard is load-bearing, not defensive: `connection` may already be
|
|
115
|
+
* present when this plugin loads, in which case `ctx.inject` fires its
|
|
116
|
+
* callback immediately *as well as* the direct attempt below running — and
|
|
117
|
+
* `connection.fetch.register` rejects a second registration of the same path.
|
|
118
|
+
* Without the flag the plugin would either throw at load or, worse, race
|
|
119
|
+
* itself depending on service order.
|
|
120
|
+
*
|
|
121
|
+
* `connection.fetch` is the only acceptable carrier. An exact `webServer`
|
|
122
|
+
* route on the same path resolves *before* the harness's `/api` prefix route,
|
|
123
|
+
* which is where the Host/Origin fence and the browser-cookie check live — so
|
|
124
|
+
* using one would turn this into an unauthenticated git endpoint reachable by
|
|
125
|
+
* any page that can guess the path.
|
|
126
|
+
*/
|
|
127
|
+
let registered = false
|
|
128
|
+
const install = (scope) => {
|
|
129
|
+
if (registered) return
|
|
130
|
+
const connection = scope.get('connection', false)
|
|
131
|
+
if (connection?.fetch?.register === undefined) return
|
|
132
|
+
// Own the lifetime explicitly rather than relying on the service proxy's
|
|
133
|
+
// call-site binding: the disposer is the registration's, so unloading the
|
|
134
|
+
// plugin always unregisters the route. Assign `registered` only after the
|
|
135
|
+
// effect has actually run, so a throw here leaves the plugin retryable.
|
|
136
|
+
scope.effect(() => connection.fetch.register(route), 'dsh-gitbar-lite: rpc route')
|
|
137
|
+
registered = true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
install(ctx)
|
|
141
|
+
// Services load in no guaranteed order, so wait for `connection` the public
|
|
142
|
+
// way. It fires when the service arrives and stays silent if it never does.
|
|
143
|
+
ctx.inject(['connection'], (scope) => install(scope))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Internals exposed for the test suite.
|
|
148
|
+
*
|
|
149
|
+
* The suite drives real registrations against a fake context rather than
|
|
150
|
+
* re-implementing the plugin, so it needs the same factories `apply` uses.
|
|
151
|
+
*/
|
|
152
|
+
export const internals = {
|
|
153
|
+
readConfig,
|
|
154
|
+
DEFAULTS,
|
|
155
|
+
createGit,
|
|
156
|
+
createRepo,
|
|
157
|
+
createStore,
|
|
158
|
+
createHandlers,
|
|
159
|
+
createRoute,
|
|
160
|
+
}
|