@yiln-dsh/dsh-plugin-file-explorer 0.4.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/README.md +103 -0
- package/client.js +1625 -0
- package/cordis.patch.yml +11 -0
- package/index.js +519 -0
- package/package.json +44 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-plugin-file-explorer bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# Adds the host row that serves the browser bundle's /_dsh/file-explorer API.
|
|
4
|
+
# The client half is declared by dsh.client and loaded with the web profile.
|
|
5
|
+
|
|
6
|
+
- insert:
|
|
7
|
+
- id: dsh-file-explorer
|
|
8
|
+
name: '@yiln-dsh/dsh-plugin-file-explorer'
|
|
9
|
+
inject:
|
|
10
|
+
- webServer
|
|
11
|
+
config: {}
|
package/index.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-file-explorer — Host half (static DSH bundle).
|
|
3
|
+
*
|
|
4
|
+
* Registers exact HTTP routes under /_dsh/file-explorer for the browser
|
|
5
|
+
* bundle: list (with parent path), read (text preview), download (data URL),
|
|
6
|
+
* and delete.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
function parentOf(path) {
|
|
10
|
+
if (!path) return null
|
|
11
|
+
const cleaned = path.replace(/[/\\]+$/, '')
|
|
12
|
+
if (cleaned === '' || /^[A-Za-z]:$/.test(cleaned)) return null
|
|
13
|
+
const slash = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\'))
|
|
14
|
+
if (slash < 0) return null
|
|
15
|
+
if (slash === 0) return cleaned[0] === '/' ? '/' : null
|
|
16
|
+
const parent = cleaned.slice(0, slash)
|
|
17
|
+
return parent === '' ? null : parent
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function bytesToBase64(bytes) {
|
|
21
|
+
let binary = ''
|
|
22
|
+
const chunk = 0x8000
|
|
23
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
24
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk))
|
|
25
|
+
}
|
|
26
|
+
return btoa(binary)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function guessMime(name) {
|
|
30
|
+
const ext = (name.split('.').pop() || '').toLowerCase()
|
|
31
|
+
const map = {
|
|
32
|
+
json: 'application/json', txt: 'text/plain', md: 'text/markdown',
|
|
33
|
+
js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
|
|
34
|
+
ts: 'text/typescript', tsx: 'text/typescript', jsx: 'text/javascript',
|
|
35
|
+
html: 'text/html', htm: 'text/html', css: 'text/css',
|
|
36
|
+
csv: 'text/csv', yml: 'text/yaml', yaml: 'text/yaml',
|
|
37
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
|
38
|
+
pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip',
|
|
39
|
+
}
|
|
40
|
+
return map[ext] || 'application/octet-stream'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sendJson(res, status, value) {
|
|
44
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
45
|
+
res.end(JSON.stringify(value))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readJson(req) {
|
|
49
|
+
let body = ''
|
|
50
|
+
for await (const chunk of req) body += chunk
|
|
51
|
+
if (body.length === 0) return {}
|
|
52
|
+
return JSON.parse(body)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Strip git's "fatal: "/"error: " prefixes and collapse whitespace into one line. */
|
|
56
|
+
function cleanGitError(stderr, fallback) {
|
|
57
|
+
const text = typeof stderr === 'string' ? stderr.trim() : ''
|
|
58
|
+
if (text === '') return fallback
|
|
59
|
+
const firstLine = text.split('\n')[0].replace(/^(fatal|error|warning):\s*/i, '').trim()
|
|
60
|
+
return firstLine === '' ? fallback : firstLine
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A commit-ish accepted from the browser: hex abbreviations or the WORKING sentinel. */
|
|
64
|
+
function isSafeCommitish(value) {
|
|
65
|
+
return typeof value === 'string' && /^([0-9a-fA-F]{6,40}|WORKING)$/.test(value)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A path argument for `git ... -- <path>`: never empty, never option-like, no control characters. */
|
|
69
|
+
function isSafeGitPath(value) {
|
|
70
|
+
return (
|
|
71
|
+
typeof value === 'string' &&
|
|
72
|
+
value.length > 0 &&
|
|
73
|
+
value.length <= 1024 &&
|
|
74
|
+
!value.startsWith('-') &&
|
|
75
|
+
!/[\0\r\n]/.test(value)
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const name = 'file-explorer'
|
|
80
|
+
|
|
81
|
+
/** Hard dependency: the active web server (which the auth webserver also provides). */
|
|
82
|
+
export const inject = ['webServer']
|
|
83
|
+
|
|
84
|
+
export function apply(ctx) {
|
|
85
|
+
const webServer = ctx.get('webServer')
|
|
86
|
+
if (webServer === undefined) return
|
|
87
|
+
|
|
88
|
+
const disposers = []
|
|
89
|
+
const addRoute = (path, handler) => {
|
|
90
|
+
disposers.push(webServer.register({ kind: 'exact', path, handler }))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
addRoute('/_dsh/file-explorer/list', async (req, res) => {
|
|
94
|
+
if (req.method === 'POST') req = await readJson(req)
|
|
95
|
+
const fs = ctx.get('fs')
|
|
96
|
+
if (fs === undefined) {
|
|
97
|
+
sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let requestedPath
|
|
102
|
+
if (typeof req.path === 'string' && req.path.trim() !== '') {
|
|
103
|
+
requestedPath = req.path
|
|
104
|
+
} else {
|
|
105
|
+
const policy = ctx.get('sandboxPolicy')
|
|
106
|
+
if (policy && typeof policy.workspaceRoot === 'string') requestedPath = policy.workspaceRoot
|
|
107
|
+
}
|
|
108
|
+
if (typeof requestedPath !== 'string') {
|
|
109
|
+
sendJson(res, 200, { ok: false, error: 'no directory to list' })
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const target = await fs.resolve(requestedPath)
|
|
115
|
+
const entries = await fs.listDir(target)
|
|
116
|
+
sendJson(res, 200, {
|
|
117
|
+
ok: true,
|
|
118
|
+
path: target.displayPath,
|
|
119
|
+
parent: parentOf(target.displayPath),
|
|
120
|
+
entries: entries.map((entry) => ({
|
|
121
|
+
name: entry.name,
|
|
122
|
+
type: entry.type,
|
|
123
|
+
size: typeof entry.size === 'number' ? entry.size : null,
|
|
124
|
+
path: entry.target.displayPath,
|
|
125
|
+
})),
|
|
126
|
+
})
|
|
127
|
+
} catch (error) {
|
|
128
|
+
sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
addRoute('/_dsh/file-explorer/read', async (req, res) => {
|
|
133
|
+
if (req.method === 'POST') req = await readJson(req)
|
|
134
|
+
const fs = ctx.get('fs')
|
|
135
|
+
if (fs === undefined) {
|
|
136
|
+
sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (typeof req.path !== 'string') {
|
|
140
|
+
sendJson(res, 200, { ok: false, error: 'missing file path' })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const target = await fs.resolve(req.path)
|
|
146
|
+
const info = await fs.stat(target)
|
|
147
|
+
const size = info && typeof info.size === 'number' ? info.size : null
|
|
148
|
+
const limit = 256 * 1024
|
|
149
|
+
if (size !== null && size > limit) {
|
|
150
|
+
sendJson(res, 200, { ok: true, name: target.displayPath.split('/').pop(), size, tooLarge: true })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const bytes = await fs.readBytes(target, undefined, limit)
|
|
154
|
+
const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
|
|
155
|
+
let binary = false
|
|
156
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
157
|
+
if (bytes[i] === 0) { binary = true; break }
|
|
158
|
+
}
|
|
159
|
+
if (!binary && text.replace(/\uFFFD/g, '').length * 10 < text.length * 9) binary = true
|
|
160
|
+
sendJson(res, 200, {
|
|
161
|
+
ok: true,
|
|
162
|
+
name: target.displayPath.split('/').pop(),
|
|
163
|
+
size,
|
|
164
|
+
text: binary ? null : text,
|
|
165
|
+
binary,
|
|
166
|
+
})
|
|
167
|
+
} catch (error) {
|
|
168
|
+
sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
addRoute('/_dsh/file-explorer/download', async (req, res) => {
|
|
173
|
+
if (req.method === 'POST') req = await readJson(req)
|
|
174
|
+
const fs = ctx.get('fs')
|
|
175
|
+
if (fs === undefined) {
|
|
176
|
+
sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
if (typeof req.path !== 'string') {
|
|
180
|
+
sendJson(res, 200, { ok: false, error: 'missing file path' })
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const target = await fs.resolve(req.path)
|
|
186
|
+
const info = await fs.stat(target)
|
|
187
|
+
const size = info && typeof info.size === 'number' ? info.size : null
|
|
188
|
+
const limit = 64 * 1024 * 1024
|
|
189
|
+
if (size !== null && size > limit) {
|
|
190
|
+
sendJson(res, 200, { ok: false, error: 'file too large for explorer download' })
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
const bytes = await fs.readBytes(target, undefined, limit)
|
|
194
|
+
const name = target.displayPath.split('/').pop()
|
|
195
|
+
sendJson(res, 200, { ok: true, name, size, dataUrl: `data:${guessMime(name)};base64,${bytesToBase64(bytes)}` })
|
|
196
|
+
} catch (error) {
|
|
197
|
+
sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
addRoute('/_dsh/file-explorer/delete', async (req, res) => {
|
|
202
|
+
if (req.method === 'POST') req = await readJson(req)
|
|
203
|
+
if (typeof req.path !== 'string') {
|
|
204
|
+
sendJson(res, 200, { ok: false, error: 'missing file path' })
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
const subprocess = ctx.get('subprocess')
|
|
208
|
+
const fs = ctx.get('fs')
|
|
209
|
+
if (subprocess === undefined) {
|
|
210
|
+
sendJson(res, 200, { ok: false, error: 'subprocess service unavailable' })
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
if (fs === undefined) {
|
|
214
|
+
sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const target = await fs.resolve(req.path)
|
|
220
|
+
const info = await fs.stat(target)
|
|
221
|
+
if (!info) {
|
|
222
|
+
sendJson(res, 200, { ok: false, error: 'file does not exist' })
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
// Directories are removed recursively; the UI gates this behind a
|
|
226
|
+
// second-click confirm before the request is ever sent.
|
|
227
|
+
const args = info.type === 'directory' ? ['rm', '-rf', '--'] : ['rm', '-f', '--']
|
|
228
|
+
const path = fs.processPath(target)
|
|
229
|
+
const handle = subprocess.spawn({
|
|
230
|
+
argv: [...args, path],
|
|
231
|
+
cwd: '/',
|
|
232
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } },
|
|
233
|
+
graceMs: 30000,
|
|
234
|
+
})
|
|
235
|
+
const outcome = await handle.done
|
|
236
|
+
if (outcome.exitCode !== 0) {
|
|
237
|
+
sendJson(res, 200, { ok: false, error: `remove failed with exit code ${outcome.exitCode}` })
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
sendJson(res, 200, { ok: true })
|
|
241
|
+
} catch (error) {
|
|
242
|
+
sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// --- git graph API -------------------------------------------------------
|
|
247
|
+
// Read-only git plumbing behind the browser's graph view. Every route
|
|
248
|
+
// resolves the repository root from the requested directory first, then
|
|
249
|
+
// runs git with machine-readable separators; nothing but JSON crosses the
|
|
250
|
+
// boundary.
|
|
251
|
+
let gitPathPromise = null
|
|
252
|
+
const resolveGit = () => {
|
|
253
|
+
const subprocess = ctx.get('subprocess')
|
|
254
|
+
if (subprocess === undefined) return Promise.reject(new Error('subprocess service unavailable'))
|
|
255
|
+
if (gitPathPromise === null) {
|
|
256
|
+
gitPathPromise = subprocess
|
|
257
|
+
.resolveExecutable('git')
|
|
258
|
+
.catch((error) => {
|
|
259
|
+
gitPathPromise = null
|
|
260
|
+
throw error
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
return gitPathPromise
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const runGit = async (git, cwd, args, maxBytes) => {
|
|
267
|
+
const subprocess = ctx.get('subprocess')
|
|
268
|
+
if (subprocess === undefined) throw new Error('subprocess service unavailable')
|
|
269
|
+
const handle = subprocess.spawn({
|
|
270
|
+
argv: [git, ...args],
|
|
271
|
+
cwd,
|
|
272
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes }, stderr: { maxBytes: 8192 } },
|
|
273
|
+
graceMs: 15000,
|
|
274
|
+
})
|
|
275
|
+
const outcome = await handle.done
|
|
276
|
+
const collect = (reader) => {
|
|
277
|
+
if (!reader) return { text: '', truncated: false }
|
|
278
|
+
const read = reader.readFrom(0)
|
|
279
|
+
return { text: read.text, truncated: read.lossy === true }
|
|
280
|
+
}
|
|
281
|
+
const out = collect(handle.collected.stdout)
|
|
282
|
+
const err = collect(handle.collected.stderr)
|
|
283
|
+
return { code: outcome.exitCode, stdout: out.text, stderr: err.text, truncated: out.truncated }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Resolve the requested (or workspace-root) directory to a process path.
|
|
287
|
+
const resolveDir = async (requestedPath) => {
|
|
288
|
+
const fs = ctx.get('fs')
|
|
289
|
+
if (fs === undefined) throw new Error('filesystem service unavailable')
|
|
290
|
+
let dir = typeof requestedPath === 'string' && requestedPath.trim() !== '' ? requestedPath : undefined
|
|
291
|
+
if (dir === undefined) {
|
|
292
|
+
const policy = ctx.get('sandboxPolicy')
|
|
293
|
+
if (policy && typeof policy.workspaceRoot === 'string') dir = policy.workspaceRoot
|
|
294
|
+
}
|
|
295
|
+
if (typeof dir !== 'string') throw new Error('no directory given')
|
|
296
|
+
const target = await fs.resolve(dir)
|
|
297
|
+
return fs.processPath(target)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Resolve the containing repository: { git, root } or throws a user-facing error.
|
|
301
|
+
const resolveRepo = async (requestedPath) => {
|
|
302
|
+
const dir = await resolveDir(requestedPath)
|
|
303
|
+
const git = await resolveGit()
|
|
304
|
+
const top = await runGit(git, dir, ['rev-parse', '--show-toplevel'], 4096)
|
|
305
|
+
if (top.code !== 0) throw new Error(cleanGitError(top.stderr, 'not a git repository'))
|
|
306
|
+
return { git, root: top.stdout.trim() }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const parseRefList = (stdout) => {
|
|
310
|
+
const heads = new Set()
|
|
311
|
+
const remotes = new Set()
|
|
312
|
+
const tags = new Set()
|
|
313
|
+
for (const line of stdout.split('\n')) {
|
|
314
|
+
const ref = line.trim()
|
|
315
|
+
if (ref.startsWith('refs/heads/')) heads.add(ref.slice('refs/heads/'.length))
|
|
316
|
+
else if (ref.startsWith('refs/remotes/')) remotes.add(ref.slice('refs/remotes/'.length))
|
|
317
|
+
else if (ref.startsWith('refs/tags/')) tags.add(ref.slice('refs/tags/'.length))
|
|
318
|
+
}
|
|
319
|
+
return { heads, remotes, tags }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Classify one %D decorator token ("HEAD -> main", "origin/main", "tag: v1", ...) into a pill.
|
|
323
|
+
const classifyRef = (token, refs) => {
|
|
324
|
+
if (token === 'HEAD') return { kind: 'detached', name: 'HEAD' }
|
|
325
|
+
if (token.startsWith('HEAD -> ')) return { kind: 'head', name: token.slice('HEAD -> '.length) }
|
|
326
|
+
if (token.startsWith('tag: ')) return { kind: 'tag', name: token.slice('tag: '.length) }
|
|
327
|
+
if (refs.heads.has(token)) return { kind: 'branch', name: token }
|
|
328
|
+
if (refs.remotes.has(token)) return { kind: 'remote', name: token }
|
|
329
|
+
return { kind: 'branch', name: token }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
addRoute('/_dsh/file-explorer/git-log', async (req, res) => {
|
|
333
|
+
if (req.method === 'POST') req = await readJson(req)
|
|
334
|
+
try {
|
|
335
|
+
const { git, root } = await resolveRepo(req.path)
|
|
336
|
+
const headRes = await runGit(git, root, ['rev-parse', '--abbrev-ref', 'HEAD'], 4096)
|
|
337
|
+
const head = headRes.code === 0 ? headRes.stdout.trim() : null
|
|
338
|
+
const refsRes = await runGit(git, root, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes', 'refs/tags'], 256 * 1024)
|
|
339
|
+
const refs = parseRefList(refsRes.stdout)
|
|
340
|
+
const limitRaw = Number(req.limit)
|
|
341
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 2000) : 300
|
|
342
|
+
const args = ['log', '--all', '--date-order', `--max-count=${limit}`, '--no-color', '--pretty=%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%D%x1f%s%x1e']
|
|
343
|
+
if (req.all === false) args.splice(1, 1)
|
|
344
|
+
const logRes = await runGit(git, root, args, 8 * 1024 * 1024)
|
|
345
|
+
if (logRes.code !== 0) {
|
|
346
|
+
sendJson(res, 200, { ok: false, error: cleanGitError(logRes.stderr, 'git log failed') })
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
const commits = []
|
|
350
|
+
for (const record of logRes.stdout.split('\x1e')) {
|
|
351
|
+
const line = record.startsWith('\n') ? record.slice(1) : record
|
|
352
|
+
if (line.trim() === '') continue
|
|
353
|
+
const f = line.split('\x1f')
|
|
354
|
+
const decorated = f[5] || ''
|
|
355
|
+
commits.push({
|
|
356
|
+
hash: f[0] || '',
|
|
357
|
+
parents: f[1] ? f[1].split(' ').filter(Boolean) : [],
|
|
358
|
+
author: f[2] || '',
|
|
359
|
+
email: f[3] || '',
|
|
360
|
+
date: Number(f[4]) || 0,
|
|
361
|
+
refs: decorated.split(',').map((part) => part.trim()).filter(Boolean).map((token) => classifyRef(token, refs)),
|
|
362
|
+
subject: f[6] || '',
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
sendJson(res, 200, { ok: true, root, head, commits, truncated: logRes.truncated })
|
|
366
|
+
} catch (error) {
|
|
367
|
+
sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
addRoute('/_dsh/file-explorer/git-commit', async (req, res) => {
|
|
372
|
+
if (req.method === 'POST') req = await readJson(req)
|
|
373
|
+
if (!isSafeCommitish(req.hash) || req.hash === 'WORKING') {
|
|
374
|
+
sendJson(res, 200, { ok: false, error: 'invalid commit hash' })
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const { git, root } = await resolveRepo(req.path)
|
|
379
|
+
const show = await runGit(git, root, ['show', '-s', '--no-color', '--format=%H%x1f%an%x1f%at%x1f%B', req.hash], 256 * 1024)
|
|
380
|
+
if (show.code !== 0) {
|
|
381
|
+
sendJson(res, 200, { ok: false, error: cleanGitError(show.stderr, 'unknown commit') })
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
const f = show.stdout.split('\x1f')
|
|
385
|
+
// -m --first-parent diffs merge commits against their first parent,
|
|
386
|
+
// matching the graph view's linear history; --root covers the initial commit.
|
|
387
|
+
const tree = await runGit(git, root, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', '-m', '--first-parent', '--no-color', '-z', req.hash], 1024 * 1024)
|
|
388
|
+
const files = []
|
|
389
|
+
if (tree.code === 0) {
|
|
390
|
+
const tokens = tree.stdout.split('\0')
|
|
391
|
+
for (let i = 0; i < tokens.length;) {
|
|
392
|
+
const status = tokens[i]
|
|
393
|
+
if (status === '') break
|
|
394
|
+
i += 1
|
|
395
|
+
const letter = status[0]
|
|
396
|
+
if ((letter === 'R' || letter === 'C') && i + 1 < tokens.length) {
|
|
397
|
+
files.push({ status: letter, oldPath: tokens[i], path: tokens[i + 1] })
|
|
398
|
+
i += 2
|
|
399
|
+
} else if (i < tokens.length) {
|
|
400
|
+
files.push({ status: letter, path: tokens[i] })
|
|
401
|
+
i += 1
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
sendJson(res, 200, {
|
|
406
|
+
ok: true,
|
|
407
|
+
hash: (f[0] || req.hash).trim(),
|
|
408
|
+
author: (f[1] || '').trim(),
|
|
409
|
+
date: Number(f[2]) || 0,
|
|
410
|
+
message: (f[3] || '').replace(/\n+$/, ''),
|
|
411
|
+
files,
|
|
412
|
+
})
|
|
413
|
+
} catch (error) {
|
|
414
|
+
sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
addRoute('/_dsh/file-explorer/git-diff', async (req, res) => {
|
|
419
|
+
if (req.method === 'POST') req = await readJson(req)
|
|
420
|
+
if (!isSafeCommitish(req.hash)) {
|
|
421
|
+
sendJson(res, 200, { ok: false, error: 'invalid commit hash' })
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
if (!isSafeGitPath(req.file)) {
|
|
425
|
+
sendJson(res, 200, { ok: false, error: 'invalid file path' })
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
const { git, root } = await resolveRepo(req.path)
|
|
430
|
+
const args = req.hash === 'WORKING'
|
|
431
|
+
? ['diff', 'HEAD', '--no-color', '--no-ext-diff', '--', req.file]
|
|
432
|
+
: ['show', '--no-color', '--no-ext-diff', '--format=', '-m', '--first-parent', req.hash, '--', req.file]
|
|
433
|
+
const diff = await runGit(git, root, args, 512 * 1024)
|
|
434
|
+
if (diff.code !== 0) {
|
|
435
|
+
sendJson(res, 200, { ok: false, error: cleanGitError(diff.stderr, 'git diff failed') })
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
sendJson(res, 200, { ok: true, patch: diff.stdout, truncated: diff.truncated })
|
|
439
|
+
} catch (error) {
|
|
440
|
+
sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
441
|
+
}
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
addRoute('/_dsh/file-explorer/git-status', async (req, res) => {
|
|
445
|
+
if (req.method === 'POST') req = await readJson(req)
|
|
446
|
+
try {
|
|
447
|
+
const { git, root } = await resolveRepo(req.path)
|
|
448
|
+
const status = await runGit(git, root, ['status', '--porcelain=v1', '-b'], 1024 * 1024)
|
|
449
|
+
if (status.code !== 0) {
|
|
450
|
+
sendJson(res, 200, { ok: false, error: cleanGitError(status.stderr, 'git status failed') })
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
const lines = status.stdout.split('\n')
|
|
454
|
+
let branch = null
|
|
455
|
+
let unborn = false
|
|
456
|
+
let upstream = null
|
|
457
|
+
let ahead = 0
|
|
458
|
+
let behind = 0
|
|
459
|
+
const header = lines.length > 0 ? lines[0] : ''
|
|
460
|
+
if (header.startsWith('## ')) {
|
|
461
|
+
const info = header.slice(3)
|
|
462
|
+
if (/^HEAD \(no branch\)$/.test(info)) {
|
|
463
|
+
branch = null
|
|
464
|
+
} else if (info.startsWith('No commits yet on ')) {
|
|
465
|
+
branch = info.slice('No commits yet on '.length)
|
|
466
|
+
unborn = true
|
|
467
|
+
} else {
|
|
468
|
+
const dots = info.indexOf('...')
|
|
469
|
+
if (dots === -1) {
|
|
470
|
+
branch = info
|
|
471
|
+
} else {
|
|
472
|
+
branch = info.slice(0, dots)
|
|
473
|
+
const rest = info.slice(dots + 3)
|
|
474
|
+
const bracket = rest.indexOf(' [')
|
|
475
|
+
upstream = bracket === -1 ? rest : rest.slice(0, bracket)
|
|
476
|
+
const flags = bracket === -1 ? '' : rest.slice(bracket + 2).replace(/\]$/, '')
|
|
477
|
+
for (const flag of flags.split(',')) {
|
|
478
|
+
const part = flag.trim().split(' ')
|
|
479
|
+
if (part[0] === 'ahead') ahead = Number(part[1]) || 0
|
|
480
|
+
if (part[0] === 'behind') behind = Number(part[1]) || 0
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const entries = []
|
|
486
|
+
for (const line of lines.slice(1)) {
|
|
487
|
+
if (line.length < 4) continue
|
|
488
|
+
const x = line[0]
|
|
489
|
+
const y = line[1]
|
|
490
|
+
const rawPath = line.slice(3)
|
|
491
|
+
const arrow = x === 'R' || x === 'C' ? rawPath.indexOf(' -> ') : -1
|
|
492
|
+
if (arrow !== -1) {
|
|
493
|
+
entries.push({ status: x, oldPath: rawPath.slice(0, arrow), path: rawPath.slice(arrow + 4) })
|
|
494
|
+
} else {
|
|
495
|
+
entries.push({ status: x === ' ' ? y : x, path: rawPath })
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
sendJson(res, 200, { ok: true, root, branch, unborn, upstream, ahead, behind, entries })
|
|
499
|
+
} catch (error) {
|
|
500
|
+
sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
ctx.effect(() => () => {
|
|
505
|
+
while (disposers.length > 0) {
|
|
506
|
+
const dispose = disposers.pop()
|
|
507
|
+
try {
|
|
508
|
+
dispose()
|
|
509
|
+
} catch (_e) {
|
|
510
|
+
// Route teardown is best-effort while the web server is shutting down.
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Attach inject on the default-exported apply: the loader unwraps default exports and drops named exports. */
|
|
517
|
+
apply.inject = ['webServer']
|
|
518
|
+
|
|
519
|
+
export default apply
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yiln-dsh/dsh-plugin-file-explorer",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "DSH bundle plugin that replaces the Web GUI details column with a workspace file explorer.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.js",
|
|
12
|
+
"./client": "./client.js",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dsh": {
|
|
16
|
+
"bundle": {
|
|
17
|
+
"patch": "./cordis.patch.yml"
|
|
18
|
+
},
|
|
19
|
+
"client": {
|
|
20
|
+
"inject": [
|
|
21
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
22
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
23
|
+
"@deepseek-ai/dsh-client-ui-renderer"
|
|
24
|
+
],
|
|
25
|
+
"platform": "web"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"index.js",
|
|
30
|
+
"client.js",
|
|
31
|
+
"cordis.patch.yml",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@deepseek-ai/cordis": ">=4.0.0",
|
|
36
|
+
"@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.0",
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-layout": ">=0.1.0-rc.0",
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-renderer": ">=0.1.0-rc.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT"
|
|
44
|
+
}
|