@yiln-dsh/dsh-plugin-file-explorer 0.5.1 → 0.6.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 +6 -4
- package/client.js +15 -19
- package/index.js +37 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ A DSH `dsh.bundle` that contributes a workspace file explorer page to the
|
|
|
36
36
|
|
|
37
37
|
## Install
|
|
38
38
|
|
|
39
|
-
The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.
|
|
39
|
+
The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.6.0`.
|
|
40
40
|
|
|
41
41
|
The right-panel package must be installed in the same `web` profile:
|
|
42
42
|
|
|
@@ -53,7 +53,7 @@ pnpm pack
|
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
|
-
dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.
|
|
56
|
+
dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.6.0.tgz
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
### npm package
|
|
@@ -76,8 +76,10 @@ apply the new profile composition.
|
|
|
76
76
|
- The Host falls back to `sandboxPolicy.workspaceRoot` when no path is passed,
|
|
77
77
|
and returns `{ ok, path, parent, entries }` JSON — never live objects.
|
|
78
78
|
- `read` previews up to 1 MiB of UTF-8 text or 16 MiB of recognized images;
|
|
79
|
-
image previews return a data URL for inline rendering. `download`
|
|
80
|
-
|
|
79
|
+
image previews return a data URL for inline rendering. `download` is a GET
|
|
80
|
+
route (`/_dsh/file-explorer/download?path=...`) that streams the file
|
|
81
|
+
natively (`Content-Disposition: attachment`) — no base64, no JSON body, no
|
|
82
|
+
size ceiling. The browser triggers it with a transient `<a download>` link.
|
|
81
83
|
- `delete` removes regular files (`rm -f`) and directories recursively
|
|
82
84
|
(`rm -rf`); both are called only after a second-click confirm in the UI.
|
|
83
85
|
- Git routes are read-only. They resolve the repository root with
|
package/client.js
CHANGED
|
@@ -1515,25 +1515,21 @@ window.__ModuleLoader__.load({
|
|
|
1515
1515
|
|
|
1516
1516
|
const downloadFile = (entry) => {
|
|
1517
1517
|
setPendingDelete(null)
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
setError(err && typeof err.message === 'string' ? err.message : String(err))
|
|
1534
|
-
}
|
|
1535
|
-
})
|
|
1536
|
-
.catch((err) => setError(err && typeof err.message === 'string' ? err.message : String(err)))
|
|
1518
|
+
// Native streaming download: navigate to the Host route, which
|
|
1519
|
+
// streams the file with `Content-Disposition: attachment`. The
|
|
1520
|
+
// browser handles the download natively — no JSON data URL, no
|
|
1521
|
+
// base64, no full-file buffering in page memory.
|
|
1522
|
+
try {
|
|
1523
|
+
const query = new URLSearchParams({ path: entry.path })
|
|
1524
|
+
const link = document.createElement('a')
|
|
1525
|
+
link.href = `/_dsh/file-explorer/download?${query.toString()}`
|
|
1526
|
+
link.download = entry.name
|
|
1527
|
+
document.body.appendChild(link)
|
|
1528
|
+
link.click()
|
|
1529
|
+
link.remove()
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
setError(err && typeof err.message === 'string' ? err.message : String(err))
|
|
1532
|
+
}
|
|
1537
1533
|
}
|
|
1538
1534
|
|
|
1539
1535
|
const requestDelete = (entry) => {
|
package/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* dsh-plugin-file-explorer — Host half (static DSH bundle).
|
|
3
5
|
*
|
|
4
6
|
* Registers exact HTTP routes under /_dsh/file-explorer for the browser
|
|
5
|
-
* bundle: list (with parent path), read (text/image preview), download
|
|
6
|
-
* and delete.
|
|
7
|
+
* bundle: list (with parent path), read (text/image preview), download
|
|
8
|
+
* (native streaming), and delete.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
function parentOf(path) {
|
|
@@ -190,29 +192,52 @@ export function apply(ctx) {
|
|
|
190
192
|
})
|
|
191
193
|
|
|
192
194
|
addRoute('/_dsh/file-explorer/download', async (req, res) => {
|
|
193
|
-
|
|
195
|
+
const url = new URL(req.url || '/', 'http://dsh.local')
|
|
196
|
+
const requestedPath = url.searchParams.get('path')
|
|
194
197
|
const fs = ctx.get('fs')
|
|
195
198
|
if (fs === undefined) {
|
|
196
199
|
sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
|
|
197
200
|
return
|
|
198
201
|
}
|
|
199
|
-
if (typeof
|
|
202
|
+
if (typeof requestedPath !== 'string' || requestedPath.trim() === '') {
|
|
200
203
|
sendJson(res, 200, { ok: false, error: 'missing file path' })
|
|
201
204
|
return
|
|
202
205
|
}
|
|
203
206
|
|
|
204
207
|
try {
|
|
205
|
-
const target = await fs.resolve(
|
|
208
|
+
const target = await fs.resolve(requestedPath)
|
|
206
209
|
const info = await fs.stat(target)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (size !== null && size > limit) {
|
|
210
|
-
sendJson(res, 200, { ok: false, error: 'file too large for explorer download' })
|
|
210
|
+
if (info === undefined || info.type !== 'file') {
|
|
211
|
+
sendJson(res, 200, { ok: false, error: 'file does not exist' })
|
|
211
212
|
return
|
|
212
213
|
}
|
|
213
|
-
const
|
|
214
|
-
const name = target.displayPath.split('/').pop()
|
|
215
|
-
|
|
214
|
+
const size = typeof info.size === 'number' && Number.isFinite(info.size) && info.size >= 0 ? info.size : 0
|
|
215
|
+
const name = target.displayPath.split('/').pop() || 'download'
|
|
216
|
+
const safeName = String(name).replace(/[\r\n"\\]/gu, '_').replace(/[^\x20-\x7e]/gu, '_') || 'download'
|
|
217
|
+
const encodedName = encodeURIComponent(String(name)).replace(/['()]/gu, (value) => `%${value.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
218
|
+
|
|
219
|
+
res.writeHead(200, {
|
|
220
|
+
'content-type': guessMime(name),
|
|
221
|
+
'content-disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodedName}`,
|
|
222
|
+
'content-length': size,
|
|
223
|
+
'x-content-type-options': 'nosniff',
|
|
224
|
+
'cache-control': 'no-store',
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Native streaming download: pipe the resolved host path straight to the
|
|
228
|
+
// HTTP response. No full-file buffering, no base64, no 64 MiB ceiling.
|
|
229
|
+
const stream = createReadStream(fs.processPath(target))
|
|
230
|
+
stream.on('error', (error) => {
|
|
231
|
+
if (res.headersSent) res.destroy(error)
|
|
232
|
+
else {
|
|
233
|
+
try {
|
|
234
|
+
sendJson(res, 500, { ok: false, error: `failed to stream file: ${error && typeof error.message === 'string' ? error.message : String(error)}` })
|
|
235
|
+
} catch {
|
|
236
|
+
res.destroy(error)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
stream.pipe(res)
|
|
216
241
|
} catch (error) {
|
|
217
242
|
sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
218
243
|
}
|