@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.
Files changed (169) hide show
  1. package/LICENSE +38 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +66 -0
  4. package/README.zh.md +66 -0
  5. package/cordis.patch.yml +13 -0
  6. package/lib/assets/mermaid.min.js +3587 -0
  7. package/lib/client.js +7621 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +3207 -0
  10. package/lib/tsconfig.client.tsbuildinfo +1 -0
  11. package/lib/tsconfig.host.tsbuildinfo +1 -0
  12. package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
  13. package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
  14. package/lib/types/client/PluginSettingsCard.d.ts +104 -0
  15. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  16. package/lib/types/client/api.d.ts +75 -0
  17. package/lib/types/client/api.d.ts.map +1 -0
  18. package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
  19. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
  20. package/lib/types/client/chat/file-ref.d.ts +35 -0
  21. package/lib/types/client/chat/file-ref.d.ts.map +1 -0
  22. package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
  23. package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
  24. package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
  25. package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
  26. package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
  27. package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
  28. package/lib/types/client/components/FileIcon.d.ts +15 -0
  29. package/lib/types/client/components/FileIcon.d.ts.map +1 -0
  30. package/lib/types/client/components/ScmPanel.d.ts +23 -0
  31. package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
  32. package/lib/types/client/components/a11y.d.ts +12 -0
  33. package/lib/types/client/components/a11y.d.ts.map +1 -0
  34. package/lib/types/client/components/icons.d.ts +41 -0
  35. package/lib/types/client/components/icons.d.ts.map +1 -0
  36. package/lib/types/client/components/overlay.d.ts +45 -0
  37. package/lib/types/client/components/overlay.d.ts.map +1 -0
  38. package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
  39. package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
  40. package/lib/types/client/drag/file-drag.d.ts +47 -0
  41. package/lib/types/client/drag/file-drag.d.ts.map +1 -0
  42. package/lib/types/client/drag.d.ts +31 -0
  43. package/lib/types/client/drag.d.ts.map +1 -0
  44. package/lib/types/client/file-source.d.ts +29 -0
  45. package/lib/types/client/file-source.d.ts.map +1 -0
  46. package/lib/types/client/fileType.d.ts +31 -0
  47. package/lib/types/client/fileType.d.ts.map +1 -0
  48. package/lib/types/client/floating.d.ts +27 -0
  49. package/lib/types/client/floating.d.ts.map +1 -0
  50. package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
  51. package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
  52. package/lib/types/client/hooks/useStore.d.ts +10 -0
  53. package/lib/types/client/hooks/useStore.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +57 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/layout.d.ts +104 -0
  57. package/lib/types/client/layout.d.ts.map +1 -0
  58. package/lib/types/client/locales.d.ts +125 -0
  59. package/lib/types/client/locales.d.ts.map +1 -0
  60. package/lib/types/client/maximize.d.ts +24 -0
  61. package/lib/types/client/maximize.d.ts.map +1 -0
  62. package/lib/types/client/mention.d.ts +44 -0
  63. package/lib/types/client/mention.d.ts.map +1 -0
  64. package/lib/types/client/mount.d.ts +22 -0
  65. package/lib/types/client/mount.d.ts.map +1 -0
  66. package/lib/types/client/persist.d.ts +55 -0
  67. package/lib/types/client/persist.d.ts.map +1 -0
  68. package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
  69. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
  70. package/lib/types/client/picker/file-picker.d.ts +62 -0
  71. package/lib/types/client/picker/file-picker.d.ts.map +1 -0
  72. package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
  73. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
  74. package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
  75. package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
  76. package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
  77. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
  78. package/lib/types/client/preview/content.d.ts +43 -0
  79. package/lib/types/client/preview/content.d.ts.map +1 -0
  80. package/lib/types/client/preview/markdown.d.ts +60 -0
  81. package/lib/types/client/preview/markdown.d.ts.map +1 -0
  82. package/lib/types/client/preview/mermaid.d.ts +78 -0
  83. package/lib/types/client/preview/mermaid.d.ts.map +1 -0
  84. package/lib/types/client/preview/selection.d.ts +26 -0
  85. package/lib/types/client/preview/selection.d.ts.map +1 -0
  86. package/lib/types/client/reference.d.ts +75 -0
  87. package/lib/types/client/reference.d.ts.map +1 -0
  88. package/lib/types/client/settings-form.d.ts +206 -0
  89. package/lib/types/client/settings-form.d.ts.map +1 -0
  90. package/lib/types/client/store.d.ts +279 -0
  91. package/lib/types/client/store.d.ts.map +1 -0
  92. package/lib/types/core/types.d.ts +132 -0
  93. package/lib/types/core/types.d.ts.map +1 -0
  94. package/lib/types/host/fs-service.d.ts +96 -0
  95. package/lib/types/host/fs-service.d.ts.map +1 -0
  96. package/lib/types/host/gate.d.ts +45 -0
  97. package/lib/types/host/gate.d.ts.map +1 -0
  98. package/lib/types/host/git-runner.d.ts +80 -0
  99. package/lib/types/host/git-runner.d.ts.map +1 -0
  100. package/lib/types/host/git-service.d.ts +130 -0
  101. package/lib/types/host/git-service.d.ts.map +1 -0
  102. package/lib/types/host/loopback.d.ts +25 -0
  103. package/lib/types/host/loopback.d.ts.map +1 -0
  104. package/lib/types/host/poll-guard.d.ts +55 -0
  105. package/lib/types/host/poll-guard.d.ts.map +1 -0
  106. package/lib/types/host/routes.d.ts +54 -0
  107. package/lib/types/host/routes.d.ts.map +1 -0
  108. package/lib/types/index.d.ts +35 -0
  109. package/lib/types/index.d.ts.map +1 -0
  110. package/lib/types/mount-once.d.ts +25 -0
  111. package/lib/types/mount-once.d.ts.map +1 -0
  112. package/package.json +85 -0
  113. package/src/client/FileManagerSettingsCard.tsx +117 -0
  114. package/src/client/PluginSettingsCard.tsx +337 -0
  115. package/src/client/api.ts +141 -0
  116. package/src/client/chat/file-ref.ts +144 -0
  117. package/src/client/chat/mermaid-chat.tsx +102 -0
  118. package/src/client/chat/placeholder-hint.tsx +46 -0
  119. package/src/client/components/ExplorerPanel.tsx +534 -0
  120. package/src/client/components/FileIcon.tsx +46 -0
  121. package/src/client/components/ScmPanel.tsx +480 -0
  122. package/src/client/components/a11y.ts +20 -0
  123. package/src/client/components/icons.tsx +274 -0
  124. package/src/client/components/overlay.tsx +225 -0
  125. package/src/client/css-modules.d.ts +5 -0
  126. package/src/client/drag/DragFileInlay.tsx +89 -0
  127. package/src/client/drag/file-drag.ts +67 -0
  128. package/src/client/drag.ts +154 -0
  129. package/src/client/file-source.ts +58 -0
  130. package/src/client/fileType.ts +129 -0
  131. package/src/client/floating.ts +63 -0
  132. package/src/client/hooks/useResizableSplit.ts +91 -0
  133. package/src/client/hooks/useStore.ts +15 -0
  134. package/src/client/index.ts +389 -0
  135. package/src/client/layout.ts +546 -0
  136. package/src/client/locales.ts +247 -0
  137. package/src/client/maximize.ts +34 -0
  138. package/src/client/mount.tsx +74 -0
  139. package/src/client/persist.ts +194 -0
  140. package/src/client/picker/FilePickerModal.tsx +506 -0
  141. package/src/client/picker/file-picker.ts +86 -0
  142. package/src/client/preview/PreviewPanel.tsx +233 -0
  143. package/src/client/preview/PreviewTabs.tsx +159 -0
  144. package/src/client/preview/PreviewToolbar.tsx +160 -0
  145. package/src/client/preview/content.tsx +617 -0
  146. package/src/client/preview/markdown.ts +351 -0
  147. package/src/client/preview/mermaid.ts +274 -0
  148. package/src/client/preview/selection.ts +82 -0
  149. package/src/client/reference.ts +161 -0
  150. package/src/client/settings-card.module.css +316 -0
  151. package/src/client/settings-form.ts +451 -0
  152. package/src/client/store.ts +1290 -0
  153. package/src/client/styles/chip.module.css +38 -0
  154. package/src/client/styles/drag.module.css +30 -0
  155. package/src/client/styles/explorer.module.css +371 -0
  156. package/src/client/styles/picker.module.css +344 -0
  157. package/src/client/styles/preview.module.css +904 -0
  158. package/src/client/styles/scm.module.css +380 -0
  159. package/src/client/styles/tokens.module.css +480 -0
  160. package/src/core/types.ts +162 -0
  161. package/src/host/fs-service.ts +513 -0
  162. package/src/host/gate.ts +74 -0
  163. package/src/host/git-runner.ts +116 -0
  164. package/src/host/git-service.ts +395 -0
  165. package/src/host/loopback.ts +63 -0
  166. package/src/host/poll-guard.ts +109 -0
  167. package/src/host/routes.ts +695 -0
  168. package/src/index.ts +97 -0
  169. 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
+ }
@@ -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
+ }