@lijian-ui/dsh-file-manager 0.1.20
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 +38 -0
- package/README.i18n.yaml +6 -0
- package/README.md +66 -0
- package/README.zh.md +66 -0
- package/cordis.patch.yml +13 -0
- package/lib/assets/mermaid.min.js +3587 -0
- package/lib/client.js +7621 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3207 -0
- package/lib/tsconfig.client.tsbuildinfo +1 -0
- package/lib/tsconfig.host.tsbuildinfo +1 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +104 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +75 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
- package/lib/types/client/chat/file-ref.d.ts +35 -0
- package/lib/types/client/chat/file-ref.d.ts.map +1 -0
- package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
- package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
- package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
- package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
- package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
- package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
- package/lib/types/client/components/FileIcon.d.ts +15 -0
- package/lib/types/client/components/FileIcon.d.ts.map +1 -0
- package/lib/types/client/components/ScmPanel.d.ts +23 -0
- package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
- package/lib/types/client/components/a11y.d.ts +12 -0
- package/lib/types/client/components/a11y.d.ts.map +1 -0
- package/lib/types/client/components/icons.d.ts +41 -0
- package/lib/types/client/components/icons.d.ts.map +1 -0
- package/lib/types/client/components/overlay.d.ts +45 -0
- package/lib/types/client/components/overlay.d.ts.map +1 -0
- package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
- package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
- package/lib/types/client/drag/file-drag.d.ts +47 -0
- package/lib/types/client/drag/file-drag.d.ts.map +1 -0
- package/lib/types/client/drag.d.ts +31 -0
- package/lib/types/client/drag.d.ts.map +1 -0
- package/lib/types/client/file-source.d.ts +29 -0
- package/lib/types/client/file-source.d.ts.map +1 -0
- package/lib/types/client/fileType.d.ts +31 -0
- package/lib/types/client/fileType.d.ts.map +1 -0
- package/lib/types/client/floating.d.ts +27 -0
- package/lib/types/client/floating.d.ts.map +1 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
- package/lib/types/client/hooks/useStore.d.ts +10 -0
- package/lib/types/client/hooks/useStore.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +57 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/layout.d.ts +104 -0
- package/lib/types/client/layout.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +125 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/maximize.d.ts +24 -0
- package/lib/types/client/maximize.d.ts.map +1 -0
- package/lib/types/client/mention.d.ts +44 -0
- package/lib/types/client/mention.d.ts.map +1 -0
- package/lib/types/client/mount.d.ts +22 -0
- package/lib/types/client/mount.d.ts.map +1 -0
- package/lib/types/client/persist.d.ts +55 -0
- package/lib/types/client/persist.d.ts.map +1 -0
- package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
- package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
- package/lib/types/client/picker/file-picker.d.ts +62 -0
- package/lib/types/client/picker/file-picker.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
- package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
- package/lib/types/client/preview/content.d.ts +43 -0
- package/lib/types/client/preview/content.d.ts.map +1 -0
- package/lib/types/client/preview/markdown.d.ts +60 -0
- package/lib/types/client/preview/markdown.d.ts.map +1 -0
- package/lib/types/client/preview/mermaid.d.ts +78 -0
- package/lib/types/client/preview/mermaid.d.ts.map +1 -0
- package/lib/types/client/preview/selection.d.ts +26 -0
- package/lib/types/client/preview/selection.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +75 -0
- package/lib/types/client/reference.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +206 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/store.d.ts +279 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/core/types.d.ts +132 -0
- package/lib/types/core/types.d.ts.map +1 -0
- package/lib/types/host/fs-service.d.ts +96 -0
- package/lib/types/host/fs-service.d.ts.map +1 -0
- package/lib/types/host/gate.d.ts +45 -0
- package/lib/types/host/gate.d.ts.map +1 -0
- package/lib/types/host/git-runner.d.ts +80 -0
- package/lib/types/host/git-runner.d.ts.map +1 -0
- package/lib/types/host/git-service.d.ts +130 -0
- package/lib/types/host/git-service.d.ts.map +1 -0
- package/lib/types/host/loopback.d.ts +25 -0
- package/lib/types/host/loopback.d.ts.map +1 -0
- package/lib/types/host/poll-guard.d.ts +55 -0
- package/lib/types/host/poll-guard.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +54 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/package.json +85 -0
- package/src/client/FileManagerSettingsCard.tsx +117 -0
- package/src/client/PluginSettingsCard.tsx +337 -0
- package/src/client/api.ts +141 -0
- package/src/client/chat/file-ref.ts +144 -0
- package/src/client/chat/mermaid-chat.tsx +102 -0
- package/src/client/chat/placeholder-hint.tsx +46 -0
- package/src/client/components/ExplorerPanel.tsx +534 -0
- package/src/client/components/FileIcon.tsx +46 -0
- package/src/client/components/ScmPanel.tsx +480 -0
- package/src/client/components/a11y.ts +20 -0
- package/src/client/components/icons.tsx +274 -0
- package/src/client/components/overlay.tsx +225 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/drag/DragFileInlay.tsx +89 -0
- package/src/client/drag/file-drag.ts +67 -0
- package/src/client/drag.ts +154 -0
- package/src/client/file-source.ts +58 -0
- package/src/client/fileType.ts +129 -0
- package/src/client/floating.ts +63 -0
- package/src/client/hooks/useResizableSplit.ts +91 -0
- package/src/client/hooks/useStore.ts +15 -0
- package/src/client/index.ts +389 -0
- package/src/client/layout.ts +546 -0
- package/src/client/locales.ts +247 -0
- package/src/client/maximize.ts +34 -0
- package/src/client/mount.tsx +74 -0
- package/src/client/persist.ts +194 -0
- package/src/client/picker/FilePickerModal.tsx +506 -0
- package/src/client/picker/file-picker.ts +86 -0
- package/src/client/preview/PreviewPanel.tsx +233 -0
- package/src/client/preview/PreviewTabs.tsx +159 -0
- package/src/client/preview/PreviewToolbar.tsx +160 -0
- package/src/client/preview/content.tsx +617 -0
- package/src/client/preview/markdown.ts +351 -0
- package/src/client/preview/mermaid.ts +274 -0
- package/src/client/preview/selection.ts +82 -0
- package/src/client/reference.ts +161 -0
- package/src/client/settings-card.module.css +316 -0
- package/src/client/settings-form.ts +451 -0
- package/src/client/store.ts +1290 -0
- package/src/client/styles/chip.module.css +38 -0
- package/src/client/styles/drag.module.css +30 -0
- package/src/client/styles/explorer.module.css +371 -0
- package/src/client/styles/picker.module.css +344 -0
- package/src/client/styles/preview.module.css +904 -0
- package/src/client/styles/scm.module.css +380 -0
- package/src/client/styles/tokens.module.css +480 -0
- package/src/core/types.ts +162 -0
- package/src/host/fs-service.ts +513 -0
- package/src/host/gate.ts +74 -0
- package/src/host/git-runner.ts +116 -0
- package/src/host/git-service.ts +395 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/poll-guard.ts +109 -0
- package/src/host/routes.ts +695 -0
- package/src/index.ts +97 -0
- package/src/mount-once.ts +48 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host filesystem service for the panel: directory listing, file read with a
|
|
3
|
+
* preview ceiling, text write with an mtime conflict check, filename search
|
|
4
|
+
* with directory pruning, and delete (untracked discard). Every operation
|
|
5
|
+
* resolves against a gated project root and refuses to escape it (path
|
|
6
|
+
* traversal guard). Text is decoded utf-8; images come back as data URLs
|
|
7
|
+
* (capped) so the browser renders them without extra round trips.
|
|
8
|
+
*
|
|
9
|
+
* Change delivery is intentionally NOT watch-based: the panel refreshes on
|
|
10
|
+
* user actions (list/read/write) and the SCM tab polls git status on its own
|
|
11
|
+
* schedule. A recursive `fs.watch` over the workspace root turned out to be
|
|
12
|
+
* a measurable CPU/IO cost on large trees (a 70k-file workspace pushed the
|
|
13
|
+
* host process to ~70% CPU during write bursts) for near-zero visible
|
|
14
|
+
* benefit — content edits never auto-reloaded the preview anyway (only a
|
|
15
|
+
* "updated" badge), so the always-on watcher was removed.
|
|
16
|
+
* @module dsh-filemgr/host/fs-service
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { open, readdir, readFile, realpath, rename as renameFile, stat, writeFile, rm, mkdir } from 'node:fs/promises'
|
|
20
|
+
import type { Dirent } from 'node:fs'
|
|
21
|
+
import { join, dirname, relative } from 'node:path'
|
|
22
|
+
import type { DirListing, FileRead, FsEntry, PanelError, SearchHit, SearchView } from '../core/types.ts'
|
|
23
|
+
import { isPathInside, type GateVerdict, type WorkspaceGate } from './gate.ts'
|
|
24
|
+
|
|
25
|
+
/** Preview text ceiling — mirrors FileManager's single-tab 80k-char cap. */
|
|
26
|
+
export const TEXT_CAP_CHARS = 80_000
|
|
27
|
+
/** Image read cap (data URL payload budget). */
|
|
28
|
+
const IMAGE_CAP_BYTES = 8 << 20
|
|
29
|
+
/** Filename-search caps (results and scanned entries). */
|
|
30
|
+
const SEARCH_HIT_CAP = 200
|
|
31
|
+
const SEARCH_SCAN_CAP = 20_000
|
|
32
|
+
/** Directories skipped by search (VS Code-like noise reduction). */
|
|
33
|
+
const SEARCH_SKIP_DIRS = new Set(['.git', 'node_modules'])
|
|
34
|
+
/** Directories never listed in the tree. */
|
|
35
|
+
const TREE_SKIP_DIRS = new Set(['.git'])
|
|
36
|
+
/**
|
|
37
|
+
* Listing cap: a giant flat dir (node_modules top level) must not choke the
|
|
38
|
+
* tree or the @ mention menu. The tail is dropped (no paging) — the explorer
|
|
39
|
+
* tree stays usable and the truncated flag lets the UI hint at the cutoff.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_ENTRIES = 1_000
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a relative path against the canonical root, realpath-checking the
|
|
45
|
+
* existing ancestors so a symlink cannot smuggle the operation outside the
|
|
46
|
+
* root. A path that does not yet exist (ENOENT) is verified through its
|
|
47
|
+
* nearest existing ancestor — a nonexistent tail cannot itself be a symlink.
|
|
48
|
+
* A path whose real path escapes the root is rejected with path-outside-root.
|
|
49
|
+
*/
|
|
50
|
+
async function resolveInsideRoot(root: string, rel: string): Promise<{ ok: true; abs: string } | { ok: false; error: PanelError }> {
|
|
51
|
+
if (rel.includes('\0')) return { ok: false, error: { code: 'path-outside-root', message: 'invalid path' } }
|
|
52
|
+
const abs = join(root, rel)
|
|
53
|
+
if (!isPathInside(root, abs)) {
|
|
54
|
+
return { ok: false, error: { code: 'path-outside-root', message: `path escapes root: ${rel}` } }
|
|
55
|
+
}
|
|
56
|
+
// Walk ancestors until we hit one that exists; validate its real path.
|
|
57
|
+
let probe = abs
|
|
58
|
+
for (let hop = 0; hop < 32; hop += 1) {
|
|
59
|
+
let real: string
|
|
60
|
+
try {
|
|
61
|
+
real = await realpath(probe)
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
64
|
+
// Any other realpath failure (EACCES/ELOOP/...) lets the operation run;
|
|
65
|
+
// the caller's own try/catch maps permission/IO problems to its normal
|
|
66
|
+
// not-found / write-failed codes.
|
|
67
|
+
if (code !== 'ENOENT') return { ok: true, abs }
|
|
68
|
+
// Ancestor does not exist yet; realpath the parent instead.
|
|
69
|
+
const parent = dirname(probe)
|
|
70
|
+
if (parent === probe) return { ok: true, abs }
|
|
71
|
+
probe = parent
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (!isPathInside(root, real)) {
|
|
75
|
+
return { ok: false, error: { code: 'path-outside-root', message: `path resolves outside root: ${rel}` } }
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, abs }
|
|
78
|
+
}
|
|
79
|
+
return { ok: false, error: { code: 'path-outside-root', message: `path cannot be resolved: ${rel}` } }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when the relative path is, or passes through, a .git component. */
|
|
83
|
+
function isGitPath(rel: string): boolean {
|
|
84
|
+
return rel.split('/').some((part) => part.toLowerCase() === '.git')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Case-insensitive alpha compare (dirs first, then files). */
|
|
88
|
+
function compareEntries(a: FsEntry, b: FsEntry): number {
|
|
89
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
|
90
|
+
const an = a.name.toLowerCase()
|
|
91
|
+
const bn = b.name.toLowerCase()
|
|
92
|
+
return an < bn ? -1 : an > bn ? 1 : 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The image probe: parse PNG/JPEG/GIF/WebP header dimensions (undefined on failure). */
|
|
96
|
+
export function probeImageSize(data: Buffer): { width: number; height: number } | undefined {
|
|
97
|
+
try {
|
|
98
|
+
if (data.length >= 24 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
|
|
99
|
+
return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) }
|
|
100
|
+
}
|
|
101
|
+
if (data.length >= 10 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
|
102
|
+
// Walk the marker segments to the first SOF marker (frame header) and
|
|
103
|
+
// read the dimensions there; the bytes right after SOI (`FF E0 ...`)
|
|
104
|
+
// are an APP segment, not the frame dimensions. Bounded to 16 segments
|
|
105
|
+
// so a malformed file cannot stall the probe.
|
|
106
|
+
let pos = 2 // skip the SOI marker itself (FF D8)
|
|
107
|
+
for (let segment = 0; segment < 16; segment += 1) {
|
|
108
|
+
if (pos + 2 > data.length) return undefined
|
|
109
|
+
if (data[pos] !== 0xff) return undefined
|
|
110
|
+
// Skip any 0xFF padding before the actual marker byte.
|
|
111
|
+
while (pos < data.length && data[pos] === 0xff) pos += 1
|
|
112
|
+
if (pos >= data.length) return undefined
|
|
113
|
+
const marker = data[pos]
|
|
114
|
+
pos += 1
|
|
115
|
+
// Standalone markers (TEM / RST0..RST7) carry no payload.
|
|
116
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0xd8) continue
|
|
117
|
+
// SOF0..SOF15 (excluding DHT/DAC/DNL/...): the frame header with dims.
|
|
118
|
+
const isSof =
|
|
119
|
+
marker === 0xc0 || marker === 0xc1 || marker === 0xc2 || marker === 0xc3 ||
|
|
120
|
+
marker === 0xc5 || marker === 0xc6 || marker === 0xc7 ||
|
|
121
|
+
marker === 0xc9 || marker === 0xca || marker === 0xcb ||
|
|
122
|
+
marker === 0xcd || marker === 0xce || marker === 0xcf
|
|
123
|
+
if (isSof) {
|
|
124
|
+
// pos points at the length field: length(2) precision(1) height(2) width(2).
|
|
125
|
+
if (pos + 7 > data.length) return undefined
|
|
126
|
+
return { height: data.readUInt16BE(pos + 3), width: data.readUInt16BE(pos + 5) }
|
|
127
|
+
}
|
|
128
|
+
// A sized segment: 2-byte length (including itself) + its payload.
|
|
129
|
+
if (pos + 2 > data.length) return undefined
|
|
130
|
+
const length = data.readUInt16BE(pos)
|
|
131
|
+
pos += length
|
|
132
|
+
if (pos < 0) return undefined
|
|
133
|
+
}
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
if (data.length >= 14 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) {
|
|
137
|
+
return { width: data.readUInt16LE(6), height: data.readUInt16LE(8) }
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
data.length >= 30 && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50
|
|
141
|
+
&& data[12] === 0x56 && data[13] === 0x50 && data[14] === 0x38 && data[15] === 0x58
|
|
142
|
+
) {
|
|
143
|
+
const size = (o: number): number => data[o] | (data[o + 1] << 8) | (data[o + 2] << 16)
|
|
144
|
+
return { width: size(24) + 1, height: size(27) + 1 }
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Mime lookup by file extension (undefined when the extension is unknown). */
|
|
153
|
+
function mimeByExtension(rel: string): string | undefined {
|
|
154
|
+
const ext = rel.split('.').pop()?.toLowerCase() ?? ''
|
|
155
|
+
const byExt: Record<string, string> = {
|
|
156
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
157
|
+
webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon', avif: 'image/avif', bmp: 'image/bmp',
|
|
158
|
+
pdf: 'application/pdf',
|
|
159
|
+
}
|
|
160
|
+
return byExt[ext]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Derive the mime type for a raw read from the extension, then the content. */
|
|
164
|
+
// pdf mappings (extension + %PDF magic) contributed by EricWang1358 (#239).
|
|
165
|
+
function imageMime(rel: string, data: Buffer): string {
|
|
166
|
+
const byExt = mimeByExtension(rel)
|
|
167
|
+
if (byExt !== undefined) return byExt
|
|
168
|
+
if (data.length >= 3 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e) return 'image/png'
|
|
169
|
+
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg'
|
|
170
|
+
if (data.length >= 4 && data[0] === 0x25 && data[1] === 0x50 && data[2] === 0x44 && data[3] === 0x46) return 'application/pdf'
|
|
171
|
+
return 'application/octet-stream'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read the first 4 magic bytes of a file (empty buffer when unreadable). */
|
|
175
|
+
async function readMagicBytes(abs: string): Promise<Buffer> {
|
|
176
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined
|
|
177
|
+
try {
|
|
178
|
+
handle = await open(abs, 'r')
|
|
179
|
+
const buf = Buffer.alloc(4)
|
|
180
|
+
const { bytesRead } = await handle.read(buf, 0, 4, 0)
|
|
181
|
+
return buf.subarray(0, bytesRead)
|
|
182
|
+
} catch {
|
|
183
|
+
return Buffer.alloc(0)
|
|
184
|
+
} finally {
|
|
185
|
+
if (handle !== undefined) await handle.close().catch(() => {})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Filesystem service: gated listing/read/write/search/delete. All relative
|
|
191
|
+
* paths are resolved against the gated root.
|
|
192
|
+
* @param gate - the workspace gate (host: registered workspace membership).
|
|
193
|
+
*/
|
|
194
|
+
export class FsService {
|
|
195
|
+
constructor(
|
|
196
|
+
private readonly gate: WorkspaceGate,
|
|
197
|
+
) {}
|
|
198
|
+
|
|
199
|
+
/** Verify a project root against the workspace gate (used by the SSE layer). */
|
|
200
|
+
verify(root: string): Promise<GateVerdict> {
|
|
201
|
+
return this.gate(root)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* List one directory (relative path; '' = root). Sorted dirs-first alpha.
|
|
206
|
+
* Entry metadata comes straight from `Dirent` (name + is-directory only) —
|
|
207
|
+
* no per-file stat — so listing a giant dir stays cheap. The result is
|
|
208
|
+
* capped at MAX_ENTRIES (truncated flag) to keep the tree and the '@'
|
|
209
|
+
* menu responsive on node_modules-style flat dirs.
|
|
210
|
+
*/
|
|
211
|
+
async list(root: string, rel: string): Promise<DirListing | PanelError> {
|
|
212
|
+
const gated = await this.gate(root)
|
|
213
|
+
if (!gated.ok) return gated.error
|
|
214
|
+
const resolved = await resolveInsideRoot(gated.canonical, rel)
|
|
215
|
+
if (!resolved.ok) return resolved.error
|
|
216
|
+
let dirents: Dirent[]
|
|
217
|
+
try {
|
|
218
|
+
dirents = await readdir(resolved.abs, { withFileTypes: true })
|
|
219
|
+
} catch {
|
|
220
|
+
return { code: 'not-found', message: `cannot list ${rel}` }
|
|
221
|
+
}
|
|
222
|
+
const entries: FsEntry[] = []
|
|
223
|
+
for (const entry of dirents) {
|
|
224
|
+
if (entry.isDirectory() && TREE_SKIP_DIRS.has(entry.name)) continue
|
|
225
|
+
entries.push({
|
|
226
|
+
name: entry.name,
|
|
227
|
+
path: rel === '' ? entry.name : `${rel}/${entry.name}`,
|
|
228
|
+
isDir: entry.isDirectory(),
|
|
229
|
+
// No stat: size/mtime are not rendered by the tree and the '@'
|
|
230
|
+
// menu no longer shows byte sizes. Kept as 0 for type stability.
|
|
231
|
+
size: 0,
|
|
232
|
+
mtime: 0,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
const truncated = entries.length > MAX_ENTRIES
|
|
236
|
+
if (truncated) entries.length = MAX_ENTRIES
|
|
237
|
+
entries.sort(compareEntries)
|
|
238
|
+
return { root: gated.canonical, entries, truncated }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Read one file for preview: text decoded utf-8 (capped), images as data URLs. */
|
|
242
|
+
async read(root: string, rel: string, asImage: boolean): Promise<FileRead | PanelError> {
|
|
243
|
+
const gated = await this.gate(root)
|
|
244
|
+
if (!gated.ok) return gated.error
|
|
245
|
+
const resolved = await resolveInsideRoot(gated.canonical, rel)
|
|
246
|
+
if (!resolved.ok) return resolved.error
|
|
247
|
+
// Stat before reading: directories, oversized images, and the text cap
|
|
248
|
+
// are all decidable from metadata, so a huge file never enters memory
|
|
249
|
+
// just to be truncated afterwards.
|
|
250
|
+
let info: Awaited<ReturnType<typeof stat>>
|
|
251
|
+
try {
|
|
252
|
+
info = await stat(resolved.abs)
|
|
253
|
+
} catch {
|
|
254
|
+
return { code: 'not-found', message: `cannot read ${rel}` }
|
|
255
|
+
}
|
|
256
|
+
if (info.isDirectory()) return { code: 'is-directory', message: `${rel} is a directory` }
|
|
257
|
+
if (asImage) {
|
|
258
|
+
if (info.size > IMAGE_CAP_BYTES) {
|
|
259
|
+
return { code: 'read-failed', message: 'image exceeds preview cap' }
|
|
260
|
+
}
|
|
261
|
+
let data: Buffer
|
|
262
|
+
try {
|
|
263
|
+
data = await readFile(resolved.abs)
|
|
264
|
+
} catch {
|
|
265
|
+
return { code: 'not-found', message: `cannot read ${rel}` }
|
|
266
|
+
}
|
|
267
|
+
const mime = imageMime(rel, data)
|
|
268
|
+
return {
|
|
269
|
+
content: `data:${mime};base64,${data.toString('base64')}`,
|
|
270
|
+
truncated: false,
|
|
271
|
+
size: data.length,
|
|
272
|
+
mtime: info.mtimeMs,
|
|
273
|
+
image: probeImageSize(data),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Bounded text read: 4 bytes per UTF-8 code point is the worst case, so
|
|
277
|
+
// this budget always covers the character cap without decoding the tail.
|
|
278
|
+
const budget = TEXT_CAP_CHARS * 4 + 3
|
|
279
|
+
let data: Buffer
|
|
280
|
+
try {
|
|
281
|
+
if (info.size <= budget) {
|
|
282
|
+
data = await readFile(resolved.abs)
|
|
283
|
+
} else {
|
|
284
|
+
const handle = await open(resolved.abs, 'r')
|
|
285
|
+
try {
|
|
286
|
+
const buffer = Buffer.alloc(budget)
|
|
287
|
+
const { bytesRead } = await handle.read(buffer, 0, budget, 0)
|
|
288
|
+
data = buffer.subarray(0, bytesRead)
|
|
289
|
+
} finally {
|
|
290
|
+
await handle.close()
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
return { code: 'not-found', message: `cannot read ${rel}` }
|
|
295
|
+
}
|
|
296
|
+
const text = data.toString('utf8')
|
|
297
|
+
const truncated = info.size > data.length || text.length > TEXT_CAP_CHARS
|
|
298
|
+
return {
|
|
299
|
+
content: truncated ? text.slice(0, TEXT_CAP_CHARS) : text,
|
|
300
|
+
truncated,
|
|
301
|
+
size: info.size,
|
|
302
|
+
mtime: info.mtimeMs,
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolve one file for raw streaming (the markdown image / pdf preview
|
|
308
|
+
* route): gated, traversal-guarded, and .git-refusing. Returns the absolute
|
|
309
|
+
* path with the derived mime, size and mtime — the HTTP layer streams the
|
|
310
|
+
* bytes itself (createReadStream + Range), so even large files never sit in
|
|
311
|
+
* host memory. Mime magic detection reads only the first few bytes. The
|
|
312
|
+
* mtime feeds the route's ETag/Last-Modified validators.
|
|
313
|
+
*/
|
|
314
|
+
async readRaw(root: string, rel: string): Promise<{ abs: string; mime: string; size: number; mtime: number } | PanelError> {
|
|
315
|
+
const gated = await this.gate(root)
|
|
316
|
+
if (!gated.ok) return gated.error
|
|
317
|
+
if (isGitPath(rel)) return { code: 'path-outside-root', message: 'refusing to read .git' }
|
|
318
|
+
const resolved = await resolveInsideRoot(gated.canonical, rel)
|
|
319
|
+
if (!resolved.ok) return resolved.error
|
|
320
|
+
let info: Awaited<ReturnType<typeof stat>>
|
|
321
|
+
try {
|
|
322
|
+
info = await stat(resolved.abs)
|
|
323
|
+
} catch {
|
|
324
|
+
return { code: 'not-found', message: `cannot read ${rel}` }
|
|
325
|
+
}
|
|
326
|
+
if (info.isDirectory()) return { code: 'is-directory', message: `${rel} is a directory` }
|
|
327
|
+
// Extension-known types skip the magic-bytes open/read entirely.
|
|
328
|
+
const mime = mimeByExtension(rel) ?? imageMime(rel, await readMagicBytes(resolved.abs))
|
|
329
|
+
return { abs: resolved.abs, mime, size: info.size, mtime: info.mtimeMs }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Write text content back, refusing when the file moved on disk (mtime conflict). */
|
|
333
|
+
async write(
|
|
334
|
+
root: string,
|
|
335
|
+
rel: string,
|
|
336
|
+
content: string,
|
|
337
|
+
baseMtime?: number,
|
|
338
|
+
): Promise<{ mtime: number } | PanelError> {
|
|
339
|
+
const gated = await this.gate(root)
|
|
340
|
+
if (!gated.ok) return gated.error
|
|
341
|
+
if (isGitPath(rel)) return { code: 'path-outside-root', message: 'refusing to touch .git' }
|
|
342
|
+
const resolved = await resolveInsideRoot(gated.canonical, rel)
|
|
343
|
+
if (!resolved.ok) return resolved.error
|
|
344
|
+
try {
|
|
345
|
+
let current: Awaited<ReturnType<typeof stat>>
|
|
346
|
+
try {
|
|
347
|
+
current = await stat(resolved.abs)
|
|
348
|
+
} catch {
|
|
349
|
+
current = { mtimeMs: 0 } as Awaited<ReturnType<typeof stat>>
|
|
350
|
+
}
|
|
351
|
+
if (baseMtime !== undefined && Number(current.mtimeMs) !== 0 && Math.abs(Number(current.mtimeMs) - baseMtime) > 1) {
|
|
352
|
+
return { code: 'write-conflict', message: 'file changed on disk since it was loaded' }
|
|
353
|
+
}
|
|
354
|
+
await mkdir(dirname(resolved.abs), { recursive: true })
|
|
355
|
+
await writeFile(resolved.abs, content, 'utf8')
|
|
356
|
+
const info = await stat(resolved.abs)
|
|
357
|
+
return { mtime: info.mtimeMs }
|
|
358
|
+
} catch {
|
|
359
|
+
return { code: 'write-failed', message: `cannot write ${rel}` }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Rename a path within the root. newName is a bare name (no separators,
|
|
365
|
+
* no '.'/'..') so the target always stays in the source's own directory;
|
|
366
|
+
* the joined target is re-checked against the canonical root anyway.
|
|
367
|
+
*/
|
|
368
|
+
async rename(root: string, rel: string, newName: string): Promise<{ ok: true } | PanelError> {
|
|
369
|
+
const gated = await this.gate(root)
|
|
370
|
+
if (!gated.ok) return gated.error
|
|
371
|
+
if (rel === '') return { code: 'path-outside-root', message: 'refusing to rename the root' }
|
|
372
|
+
if (isGitPath(rel)) return { code: 'path-outside-root', message: 'refusing to touch .git' }
|
|
373
|
+
const name = newName.trim()
|
|
374
|
+
if (name === '' || name === '.' || name === '..' || /[\\/]/.test(name)) {
|
|
375
|
+
return { code: 'path-outside-root', message: `invalid name: ${newName}` }
|
|
376
|
+
}
|
|
377
|
+
const resolved = await resolveInsideRoot(gated.canonical, rel)
|
|
378
|
+
if (!resolved.ok) return resolved.error
|
|
379
|
+
// Spellings like '.', './' or 'sub/..' land on the root itself (trailing
|
|
380
|
+
// separators included), so compare by relative(), not string equality.
|
|
381
|
+
if (relative(gated.canonical, resolved.abs) === '') return { code: 'path-outside-root', message: 'refusing to rename the root' }
|
|
382
|
+
const target = join(dirname(resolved.abs), name)
|
|
383
|
+
if (!isPathInside(gated.canonical, target)) {
|
|
384
|
+
return { code: 'path-outside-root', message: `path escapes root: ${rel}` }
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
await renameFile(resolved.abs, target)
|
|
388
|
+
return { ok: true }
|
|
389
|
+
} catch {
|
|
390
|
+
return { code: 'write-failed', message: `cannot rename ${rel}` }
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Create a directory at a relative path (its parent must already exist). */
|
|
395
|
+
async mkdir(root: string, rel: string): Promise<{ ok: true } | PanelError> {
|
|
396
|
+
const gated = await this.gate(root)
|
|
397
|
+
if (!gated.ok) return gated.error
|
|
398
|
+
if (rel === '') return { code: 'path-outside-root', message: 'refusing to create the root' }
|
|
399
|
+
if (isGitPath(rel)) return { code: 'path-outside-root', message: 'refusing to touch .git' }
|
|
400
|
+
const resolved = await resolveInsideRoot(gated.canonical, rel)
|
|
401
|
+
if (!resolved.ok) return resolved.error
|
|
402
|
+
try {
|
|
403
|
+
await mkdir(resolved.abs)
|
|
404
|
+
return { ok: true }
|
|
405
|
+
} catch {
|
|
406
|
+
return { code: 'write-failed', message: `cannot create directory ${rel}` }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Create an empty file at a relative path (wx: refuses to overwrite). */
|
|
411
|
+
async newFile(root: string, rel: string): Promise<{ ok: true } | PanelError> {
|
|
412
|
+
const gated = await this.gate(root)
|
|
413
|
+
if (!gated.ok) return gated.error
|
|
414
|
+
if (rel === '') return { code: 'path-outside-root', message: 'refusing to create the root' }
|
|
415
|
+
if (isGitPath(rel)) return { code: 'path-outside-root', message: 'refusing to touch .git' }
|
|
416
|
+
const resolved = await resolveInsideRoot(gated.canonical, rel)
|
|
417
|
+
if (!resolved.ok) return resolved.error
|
|
418
|
+
try {
|
|
419
|
+
await writeFile(resolved.abs, '', { flag: 'wx' })
|
|
420
|
+
return { ok: true }
|
|
421
|
+
} catch {
|
|
422
|
+
return { code: 'write-failed', message: `cannot create file ${rel}` }
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Resolve a relative path to its gated absolute path without touching it —
|
|
428
|
+
* the route layer uses it for reveal-in-file-manager / open-with-default.
|
|
429
|
+
*/
|
|
430
|
+
async resolveAbsolute(root: string, rel: string): Promise<{ ok: true; abs: string } | PanelError> {
|
|
431
|
+
const gated = await this.gate(root)
|
|
432
|
+
if (!gated.ok) return gated.error
|
|
433
|
+
if (isGitPath(rel)) return { code: 'path-outside-root', message: 'refusing to touch .git' }
|
|
434
|
+
const resolved = await resolveInsideRoot(gated.canonical, rel)
|
|
435
|
+
if (!resolved.ok) return resolved.error
|
|
436
|
+
return { ok: true, abs: resolved.abs }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Recursive filename search (case-insensitive substring), pruned at noise dirs. */
|
|
440
|
+
async search(root: string, query: string): Promise<SearchView | PanelError> {
|
|
441
|
+
const gated = await this.gate(root)
|
|
442
|
+
if (!gated.ok) return gated.error
|
|
443
|
+
const needle = query.trim().toLowerCase()
|
|
444
|
+
if (needle === '') return { query, hits: [], truncated: false }
|
|
445
|
+
const hits: SearchHit[] = []
|
|
446
|
+
let scanned = 0
|
|
447
|
+
let truncated = false
|
|
448
|
+
const walk = async (rel: string, depth: number): Promise<void> => {
|
|
449
|
+
if (truncated) return
|
|
450
|
+
const resolved = await resolveInsideRoot(gated.canonical, rel)
|
|
451
|
+
if (!resolved.ok) return
|
|
452
|
+
let dirents: Dirent[]
|
|
453
|
+
try {
|
|
454
|
+
dirents = await readdir(resolved.abs, { withFileTypes: true })
|
|
455
|
+
} catch {
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
for (const entry of dirents) {
|
|
459
|
+
if (scanned >= SEARCH_SCAN_CAP) {
|
|
460
|
+
truncated = true
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
scanned += 1
|
|
464
|
+
const path = rel === '' ? entry.name : `${rel}/${entry.name}`
|
|
465
|
+
if (entry.isDirectory()) {
|
|
466
|
+
if (SEARCH_SKIP_DIRS.has(entry.name)) continue
|
|
467
|
+
if (depth < 24 && !truncated) await walk(path, depth + 1)
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
if (entry.name.toLowerCase().includes(needle)) {
|
|
471
|
+
if (hits.length >= SEARCH_HIT_CAP) {
|
|
472
|
+
truncated = true
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
hits.push({ path, name: entry.name, isDir: false })
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
await walk('', 0)
|
|
481
|
+
} catch {
|
|
482
|
+
return { code: 'search-failed', message: 'search walk failed' }
|
|
483
|
+
}
|
|
484
|
+
// Rank: exact matches first, then prefix, then substring; shorter paths first.
|
|
485
|
+
const rank = (hit: SearchHit): number => {
|
|
486
|
+
const name = hit.name.toLowerCase()
|
|
487
|
+
if (name === needle) return 0
|
|
488
|
+
if (name.startsWith(needle)) return 1
|
|
489
|
+
return 2
|
|
490
|
+
}
|
|
491
|
+
hits.sort((a, b) => rank(a) - rank(b) || a.path.length - b.path.length || (a.path < b.path ? -1 : 1))
|
|
492
|
+
return { query, hits, truncated }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Delete a path (discard of untracked files). Recursive for directories. */
|
|
496
|
+
async delete(root: string, rel: string): Promise<{ ok: true } | PanelError> {
|
|
497
|
+
const gated = await this.gate(root)
|
|
498
|
+
if (!gated.ok) return gated.error
|
|
499
|
+
if (rel === '') return { code: 'path-outside-root', message: 'refusing to delete the root' }
|
|
500
|
+
if (isGitPath(rel)) return { code: 'path-outside-root', message: 'refusing to touch .git' }
|
|
501
|
+
const resolved = await resolveInsideRoot(gated.canonical, rel)
|
|
502
|
+
if (!resolved.ok) return resolved.error
|
|
503
|
+
// Spellings like '.', './' or 'sub/..' land on the root itself (trailing
|
|
504
|
+
// separators included), so compare by relative(), not string equality.
|
|
505
|
+
if (relative(gated.canonical, resolved.abs) === '') return { code: 'path-outside-root', message: 'refusing to delete the root' }
|
|
506
|
+
try {
|
|
507
|
+
await rm(resolved.abs, { recursive: true, force: true })
|
|
508
|
+
return { ok: true }
|
|
509
|
+
} catch {
|
|
510
|
+
return { code: 'write-failed', message: `cannot delete ${rel}` }
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
package/src/host/gate.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace gate for the /filemgr routes: canonicalize the requested
|
|
3
|
+
* project root and require it to be a registered workspace (or a directory
|
|
4
|
+
* inside one). This is the security boundary of the panel's fs/git routes -
|
|
5
|
+
* the browser may only read and mutate files under registered workspace
|
|
6
|
+
* roots, never arbitrary host directories.
|
|
7
|
+
* @module dsh-filemgr/host/gate
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { realpath } from 'node:fs/promises'
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import type {} from '@deepseek-ai/dsh-workspace'
|
|
13
|
+
import type { PanelError } from '../core/types.ts'
|
|
14
|
+
|
|
15
|
+
/** The gate verdict for one project root. */
|
|
16
|
+
export type GateVerdict = { ok: true; canonical: string } | { ok: false; error: PanelError }
|
|
17
|
+
|
|
18
|
+
/** The workspace-membership check the services run on every request. */
|
|
19
|
+
export type WorkspaceGate = (root: string) => Promise<GateVerdict>
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalize a path for prefix comparison: collapse Windows separators to `/`
|
|
23
|
+
* and drop any trailing slash. On win32 the whole path is also lower-cased so
|
|
24
|
+
* a case-insensitive FS cannot trip the membership check (the drive letter and
|
|
25
|
+
* every segment are compared case-insensitively). On any other platform the
|
|
26
|
+
* path separator and case are left untouched.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeForPrefix(value: string): string {
|
|
29
|
+
const normalized = value.replaceAll('\\', '/').replace(/\/+$/, '')
|
|
30
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The canonical prefix check: child must live inside (or equal) the root.
|
|
35
|
+
* Separator- and case-robust on Windows: `path.join` yields backslashes while
|
|
36
|
+
* git (`rev-parse --show-toplevel`) and the browser (`./x`) yield forward
|
|
37
|
+
* slashes, so both sides are normalized to forward slashes before comparing,
|
|
38
|
+
* and the comparison is case-insensitive on win32 (the FS is case-insensitive).
|
|
39
|
+
*/
|
|
40
|
+
export function isPathInside(root: string, child: string): boolean {
|
|
41
|
+
if (root === '' || child === '') return false
|
|
42
|
+
const normRoot = normalizeForPrefix(root)
|
|
43
|
+
const normChild = normalizeForPrefix(child)
|
|
44
|
+
if (normChild === normRoot) return true
|
|
45
|
+
return normChild.startsWith(`${normRoot}/`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Production gate: canonicalize the requested root and require it to be a
|
|
50
|
+
* registered workspace path (or a subdirectory of one). The host's workspace
|
|
51
|
+
* registry owns canonicalization, so an unowned path is rejected outright.
|
|
52
|
+
* @param ctx - context carrying the workspace service.
|
|
53
|
+
* @returns the gate.
|
|
54
|
+
*/
|
|
55
|
+
export function createWorkspaceGate(ctx: Context): WorkspaceGate {
|
|
56
|
+
return async (root) => {
|
|
57
|
+
if (typeof root !== 'string' || root === '') {
|
|
58
|
+
return { ok: false, error: { code: 'workspace-unknown', message: 'empty project root' } }
|
|
59
|
+
}
|
|
60
|
+
let canonical: string
|
|
61
|
+
try {
|
|
62
|
+
canonical = await realpath(root)
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, error: { code: 'workspace-unknown', message: 'path does not resolve on disk' } }
|
|
65
|
+
}
|
|
66
|
+
const workspaces = ctx.workspaceRegistry.list()
|
|
67
|
+
for (const workspace of workspaces) {
|
|
68
|
+
if (isPathInside(workspace.path, canonical)) {
|
|
69
|
+
return { ok: true, canonical }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { ok: false, error: { code: 'workspace-unknown', message: 'path is not inside a registered workspace' } }
|
|
73
|
+
}
|
|
74
|
+
}
|