dsh-coding-sidebar 1.0.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.
Files changed (277) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +524 -0
  3. package/cordis.patch.yml +49 -0
  4. package/lib/client-editor.js +48229 -0
  5. package/lib/client-mermaid.js +200745 -0
  6. package/lib/client-registry.js +21867 -0
  7. package/lib/client-terminal.js +9361 -0
  8. package/lib/client.js +21867 -0
  9. package/lib/index.js +4556 -0
  10. package/lib/invariant.js +22 -0
  11. package/lib/types/agent-opens.d.ts +65 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +39 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +13 -0
  19. package/lib/types/client/FileTree.d.ts +38 -0
  20. package/lib/types/client/FreeWindow.d.ts +29 -0
  21. package/lib/types/client/GitView.d.ts +10 -0
  22. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  23. package/lib/types/client/OrphanedTab.d.ts +11 -0
  24. package/lib/types/client/PdfView.d.ts +6 -0
  25. package/lib/types/client/RenderBoundary.d.ts +32 -0
  26. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  27. package/lib/types/client/SideCardSection.d.ts +74 -0
  28. package/lib/types/client/SideChatView.d.ts +16 -0
  29. package/lib/types/client/Sidebar.d.ts +6 -0
  30. package/lib/types/client/SubagentView.d.ts +16 -0
  31. package/lib/types/client/TabBar.d.ts +54 -0
  32. package/lib/types/client/TerminalView.d.ts +24 -0
  33. package/lib/types/client/TextEditor.d.ts +10 -0
  34. package/lib/types/client/TreePanel.d.ts +24 -0
  35. package/lib/types/client/UploadOverlay.d.ts +20 -0
  36. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  37. package/lib/types/client/api.d.ts +275 -0
  38. package/lib/types/client/binary-download.d.ts +12 -0
  39. package/lib/types/client/breakpoints.d.ts +17 -0
  40. package/lib/types/client/browser.d.ts +60 -0
  41. package/lib/types/client/builtins/index.d.ts +18 -0
  42. package/lib/types/client/builtins/tabs.d.ts +11 -0
  43. package/lib/types/client/builtins/viewers.d.ts +3 -0
  44. package/lib/types/client/chunk-loader.d.ts +131 -0
  45. package/lib/types/client/chunks/editor.d.ts +9 -0
  46. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  47. package/lib/types/client/chunks/terminal.d.ts +9 -0
  48. package/lib/types/client/cm-themes.d.ts +24 -0
  49. package/lib/types/client/conversation-draft.d.ts +14 -0
  50. package/lib/types/client/desktop-env.d.ts +35 -0
  51. package/lib/types/client/editor-load.d.ts +66 -0
  52. package/lib/types/client/frame-batcher.d.ts +24 -0
  53. package/lib/types/client/icons.d.ts +59 -0
  54. package/lib/types/client/image-types.d.ts +3 -0
  55. package/lib/types/client/ime-guard.d.ts +36 -0
  56. package/lib/types/client/index.d.ts +25 -0
  57. package/lib/types/client/intercept.d.ts +46 -0
  58. package/lib/types/client/lang.d.ts +14 -0
  59. package/lib/types/client/layout-push.d.ts +15 -0
  60. package/lib/types/client/lazy-chunk.d.ts +25 -0
  61. package/lib/types/client/link-intercept.d.ts +42 -0
  62. package/lib/types/client/locales-ar.d.ts +18 -0
  63. package/lib/types/client/locales-de.d.ts +4 -0
  64. package/lib/types/client/locales-fr.d.ts +10 -0
  65. package/lib/types/client/locales-hi.d.ts +17 -0
  66. package/lib/types/client/locales-id.d.ts +15 -0
  67. package/lib/types/client/locales-it.d.ts +8 -0
  68. package/lib/types/client/locales-ja.d.ts +17 -0
  69. package/lib/types/client/locales-ko.d.ts +9 -0
  70. package/lib/types/client/locales-nl.d.ts +16 -0
  71. package/lib/types/client/locales-pl.d.ts +19 -0
  72. package/lib/types/client/locales-pt.d.ts +1 -0
  73. package/lib/types/client/locales-ru.d.ts +13 -0
  74. package/lib/types/client/locales-sv.d.ts +1 -0
  75. package/lib/types/client/locales-th.d.ts +17 -0
  76. package/lib/types/client/locales-tr.d.ts +17 -0
  77. package/lib/types/client/locales-vi.d.ts +17 -0
  78. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  79. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  80. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  81. package/lib/types/client/locales.d.ts +452 -0
  82. package/lib/types/client/markdown-html.d.ts +96 -0
  83. package/lib/types/client/markdown-images.d.ts +45 -0
  84. package/lib/types/client/markdown-labels.d.ts +38 -0
  85. package/lib/types/client/md-toc.d.ts +4 -0
  86. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  87. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  88. package/lib/types/client/mermaid.d.ts +12 -0
  89. package/lib/types/client/open-when-sized.d.ts +20 -0
  90. package/lib/types/client/open-with-settings.d.ts +4 -0
  91. package/lib/types/client/open-with.d.ts +87 -0
  92. package/lib/types/client/openpath-intercept.d.ts +85 -0
  93. package/lib/types/client/paths.d.ts +43 -0
  94. package/lib/types/client/pdf-types.d.ts +2 -0
  95. package/lib/types/client/pinned.d.ts +86 -0
  96. package/lib/types/client/plugin-settings.d.ts +8 -0
  97. package/lib/types/client/plugins-shared.d.ts +27 -0
  98. package/lib/types/client/plugins-tabs.d.ts +3 -0
  99. package/lib/types/client/plugins-viewers.d.ts +3 -0
  100. package/lib/types/client/prefs.d.ts +41 -0
  101. package/lib/types/client/produced-files.d.ts +29 -0
  102. package/lib/types/client/selection-payload.d.ts +27 -0
  103. package/lib/types/client/service.d.ts +439 -0
  104. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  105. package/lib/types/client/shell-presets.d.ts +48 -0
  106. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  107. package/lib/types/client/split-pane.d.ts +36 -0
  108. package/lib/types/client/state.d.ts +432 -0
  109. package/lib/types/client/subagent-detect.d.ts +54 -0
  110. package/lib/types/client/subagent-jobs.d.ts +63 -0
  111. package/lib/types/client/tab-content-memo.d.ts +40 -0
  112. package/lib/types/client/terminal-font.d.ts +78 -0
  113. package/lib/types/client/terminal-links.d.ts +119 -0
  114. package/lib/types/client/theme.d.ts +45 -0
  115. package/lib/types/client/titlebar-strip.d.ts +23 -0
  116. package/lib/types/client/upload.d.ts +69 -0
  117. package/lib/types/client/wco.d.ts +47 -0
  118. package/lib/types/config.d.ts +64 -0
  119. package/lib/types/context-types.d.ts +605 -0
  120. package/lib/types/fs-operations.d.ts +28 -0
  121. package/lib/types/fs-search.d.ts +24 -0
  122. package/lib/types/fs-tree.d.ts +51 -0
  123. package/lib/types/git.d.ts +124 -0
  124. package/lib/types/html-route.d.ts +59 -0
  125. package/lib/types/index.d.ts +47 -0
  126. package/lib/types/invariant.d.ts +15 -0
  127. package/lib/types/jobs-routes.d.ts +45 -0
  128. package/lib/types/open-external.d.ts +24 -0
  129. package/lib/types/path-security.d.ts +20 -0
  130. package/lib/types/prefs-shared.d.ts +236 -0
  131. package/lib/types/pty-deps.d.ts +78 -0
  132. package/lib/types/pty-manager.d.ts +168 -0
  133. package/lib/types/sidechat-core.d.ts +172 -0
  134. package/lib/types/sidechat-routes.d.ts +30 -0
  135. package/lib/types/subagent-activity.d.ts +44 -0
  136. package/lib/types/subagent-live-route.d.ts +44 -0
  137. package/lib/types/tools.d.ts +34 -0
  138. package/lib/types/trust-fence.d.ts +24 -0
  139. package/lib/types/wire.d.ts +38 -0
  140. package/package.json +172 -0
  141. package/scripts/install.ps1 +306 -0
  142. package/scripts/install.sh +287 -0
  143. package/src/agent-opens.ts +265 -0
  144. package/src/agent-pty.ts +527 -0
  145. package/src/browser-probe.ts +26 -0
  146. package/src/bundle-route.ts +129 -0
  147. package/src/client/BrowserView.tsx +289 -0
  148. package/src/client/DiffTab.tsx +114 -0
  149. package/src/client/DiffView.tsx +305 -0
  150. package/src/client/EditorHost.tsx +558 -0
  151. package/src/client/FileTree.tsx +669 -0
  152. package/src/client/FreeWindow.tsx +337 -0
  153. package/src/client/GitView.tsx +717 -0
  154. package/src/client/MarkdownHtml.tsx +296 -0
  155. package/src/client/OrphanedTab.tsx +27 -0
  156. package/src/client/PdfView.tsx +110 -0
  157. package/src/client/RenderBoundary.tsx +49 -0
  158. package/src/client/SandboxStatusBar.tsx +60 -0
  159. package/src/client/SideCardSection.module.css +960 -0
  160. package/src/client/SideCardSection.tsx +1163 -0
  161. package/src/client/SideChatView.module.css +437 -0
  162. package/src/client/SideChatView.tsx +645 -0
  163. package/src/client/Sidebar.tsx +1370 -0
  164. package/src/client/SubagentView.module.css +547 -0
  165. package/src/client/SubagentView.tsx +883 -0
  166. package/src/client/TabBar.tsx +365 -0
  167. package/src/client/TerminalView.tsx +424 -0
  168. package/src/client/TextEditor.tsx +468 -0
  169. package/src/client/TreePanel.tsx +275 -0
  170. package/src/client/UploadOverlay.tsx +62 -0
  171. package/src/client/add-plugin-modal.tsx +199 -0
  172. package/src/client/api.ts +357 -0
  173. package/src/client/binary-download.tsx +23 -0
  174. package/src/client/breakpoints.ts +61 -0
  175. package/src/client/browser.ts +167 -0
  176. package/src/client/builtins/index.ts +37 -0
  177. package/src/client/builtins/tabs.tsx +340 -0
  178. package/src/client/builtins/viewers.tsx +125 -0
  179. package/src/client/chunk-loader.ts +358 -0
  180. package/src/client/chunks/editor.tsx +9 -0
  181. package/src/client/chunks/mermaid.tsx +10 -0
  182. package/src/client/chunks/terminal.tsx +9 -0
  183. package/src/client/cm-themes.ts +129 -0
  184. package/src/client/conversation-draft.ts +29 -0
  185. package/src/client/css-modules.d.ts +5 -0
  186. package/src/client/desktop-env.ts +72 -0
  187. package/src/client/editor-load.ts +92 -0
  188. package/src/client/frame-batcher.ts +56 -0
  189. package/src/client/icons.tsx +152 -0
  190. package/src/client/image-types.ts +8 -0
  191. package/src/client/ime-guard.ts +47 -0
  192. package/src/client/index.tsx +427 -0
  193. package/src/client/intercept.tsx +188 -0
  194. package/src/client/lang.ts +225 -0
  195. package/src/client/layout-push.ts +24 -0
  196. package/src/client/layout.css +90 -0
  197. package/src/client/lazy-chunk.tsx +89 -0
  198. package/src/client/link-intercept.ts +73 -0
  199. package/src/client/locales-ar.ts +368 -0
  200. package/src/client/locales-de.ts +353 -0
  201. package/src/client/locales-fr.ts +360 -0
  202. package/src/client/locales-hi.ts +367 -0
  203. package/src/client/locales-id.ts +365 -0
  204. package/src/client/locales-it.ts +358 -0
  205. package/src/client/locales-ja.ts +367 -0
  206. package/src/client/locales-ko.ts +359 -0
  207. package/src/client/locales-nl.ts +365 -0
  208. package/src/client/locales-pl.ts +369 -0
  209. package/src/client/locales-pt.ts +350 -0
  210. package/src/client/locales-ru.ts +365 -0
  211. package/src/client/locales-sv.ts +350 -0
  212. package/src/client/locales-th.ts +367 -0
  213. package/src/client/locales-tr.ts +367 -0
  214. package/src/client/locales-vi.ts +367 -0
  215. package/src/client/locales-zh-HK.ts +382 -0
  216. package/src/client/locales-zh-MO.ts +382 -0
  217. package/src/client/locales-zh-TW.ts +382 -0
  218. package/src/client/locales.ts +894 -0
  219. package/src/client/markdown-html.ts +331 -0
  220. package/src/client/markdown-images.ts +137 -0
  221. package/src/client/markdown-labels.tsx +52 -0
  222. package/src/client/md-toc.tsx +127 -0
  223. package/src/client/mermaid-blocks.ts +110 -0
  224. package/src/client/mermaid-sanitize.ts +93 -0
  225. package/src/client/mermaid.tsx +406 -0
  226. package/src/client/open-when-sized.ts +43 -0
  227. package/src/client/open-with-settings.tsx +135 -0
  228. package/src/client/open-with.ts +221 -0
  229. package/src/client/openpath-intercept.ts +143 -0
  230. package/src/client/paths.ts +63 -0
  231. package/src/client/pdf-types.ts +4 -0
  232. package/src/client/pinned.ts +183 -0
  233. package/src/client/plugin-settings.ts +43 -0
  234. package/src/client/plugins-shared.ts +29 -0
  235. package/src/client/plugins-tabs.ts +96 -0
  236. package/src/client/plugins-viewers.ts +30 -0
  237. package/src/client/prefs.ts +210 -0
  238. package/src/client/produced-files.ts +120 -0
  239. package/src/client/selection-payload.ts +86 -0
  240. package/src/client/service.ts +859 -0
  241. package/src/client/settings-nav-icon.ts +45 -0
  242. package/src/client/shell-presets.ts +83 -0
  243. package/src/client/sidebar.module.css +2788 -0
  244. package/src/client/sidechat-transcript.ts +321 -0
  245. package/src/client/split-pane.tsx +331 -0
  246. package/src/client/state.ts +1497 -0
  247. package/src/client/subagent-detect.ts +137 -0
  248. package/src/client/subagent-jobs.ts +161 -0
  249. package/src/client/tab-content-memo.ts +55 -0
  250. package/src/client/terminal-font.ts +224 -0
  251. package/src/client/terminal-links.ts +194 -0
  252. package/src/client/theme.ts +110 -0
  253. package/src/client/titlebar-strip.ts +37 -0
  254. package/src/client/upload.ts +187 -0
  255. package/src/client/wco.ts +120 -0
  256. package/src/config.ts +154 -0
  257. package/src/context-types.ts +611 -0
  258. package/src/fs-operations.ts +95 -0
  259. package/src/fs-search.ts +113 -0
  260. package/src/fs-tree.ts +158 -0
  261. package/src/git.ts +446 -0
  262. package/src/html-route.ts +106 -0
  263. package/src/index.ts +1291 -0
  264. package/src/invariant.ts +32 -0
  265. package/src/jobs-routes.ts +257 -0
  266. package/src/open-external.ts +90 -0
  267. package/src/path-security.ts +75 -0
  268. package/src/prefs-shared.ts +280 -0
  269. package/src/pty-deps.ts +240 -0
  270. package/src/pty-manager.ts +376 -0
  271. package/src/sidechat-core.ts +468 -0
  272. package/src/sidechat-routes.ts +344 -0
  273. package/src/subagent-activity.ts +91 -0
  274. package/src/subagent-live-route.ts +93 -0
  275. package/src/tools.ts +482 -0
  276. package/src/trust-fence.ts +84 -0
  277. package/src/wire.ts +100 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Workspace-safe file mutations for the sidebar (the upload route today).
3
+ *
4
+ * Every write is confined to the real session workspace: the upload
5
+ * directory is resolved absolute and its target is checked through existing
6
+ * filesystem ancestors, the relative path is sanitized (absolute paths, '.',
7
+ * '..' and empty segments are refused), and the final target must stay inside
8
+ * the workspace after symlink resolution. Bytes stream from the request body
9
+ * to a uniquely named temp sibling
10
+ * and are renamed into place, so a failed, aborted, or oversized upload never
11
+ * leaves a partial file at the target path.
12
+ */
13
+ import { randomUUID } from 'node:crypto'
14
+ import { once } from 'node:events'
15
+ import { createWriteStream } from 'node:fs'
16
+ import { mkdir, rename, rm, stat } from 'node:fs/promises'
17
+ import { basename, dirname, join } from 'node:path'
18
+ import type { SidebarHttpRequest } from './context-types.ts'
19
+ import { requireAbsolute } from './fs-tree.ts'
20
+ import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
21
+ import { SidebarError } from './wire.ts'
22
+
23
+ /** Inputs of one upload: the session scope plus the request body stream. */
24
+ export interface WorkspaceUploadInput {
25
+ /** The session workspace root; target and directory must stay inside it. */
26
+ cwd: string
27
+ /** Absolute upload directory chosen by the client (inside `cwd`). */
28
+ dir: string
29
+ /** Relative path below `dir` (absolute paths, '.', '..' and empty segments refused). */
30
+ relativePath: string
31
+ /** The request body stream (raw bytes). */
32
+ chunks: AsyncIterable<string | Uint8Array>
33
+ /** Byte cap; an oversized upload is refused without touching the target. */
34
+ limit: number
35
+ }
36
+
37
+ /**
38
+ * Stream `chunks` into `dir/relativePath` atomically: a uniquely named temp
39
+ * sibling receives the bytes, then is renamed over the target. The parent
40
+ * directory is created on demand (recursive), so folder uploads work before
41
+ * any level exists. The unique temp name keeps concurrent uploads to the same
42
+ * target independent (each writes and renames its own file; the last rename
43
+ * wins) and never blocks later uploads after a crashed process.
44
+ *
45
+ * @throws SidebarError with a wire code for containment, shape, and size
46
+ * failures; the temp file is always removed on failure.
47
+ */
48
+ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise<{ path: string; size: number }> {
49
+ const { cwd, dir, relativePath, chunks, limit } = input
50
+ const base = requireAbsolute(dir)
51
+ await ensureWorkspacePath(cwd, base)
52
+ if (relativePath === '' || relativePath.startsWith('/') || relativePath.startsWith('\\')) {
53
+ throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
54
+ }
55
+ const segments = relativePath.split(/[\\/]/)
56
+ if (segments.some(part => part === '' || part === '.' || part === '..')) {
57
+ throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
58
+ }
59
+ const target = join(base, ...segments)
60
+ const safeTarget = await ensureWorkspaceWritePath(cwd, target)
61
+ const tmp = join(dirname(safeTarget), `.${basename(safeTarget)}.dsh-upload-${randomUUID()}.tmp`)
62
+ await mkdir(dirname(safeTarget), { recursive: true })
63
+ const stream = createWriteStream(tmp, { flags: 'wx' })
64
+ // Resolves once the stream fully closes; created up front so a stream that
65
+ // already closed (successful end, later failure) cannot leave the wait hanging.
66
+ const closed = new Promise<void>((resolve) => { stream.once('close', () => resolve()) })
67
+ let size = 0
68
+ let streamError: unknown
69
+ // A permanent 'error' listener keeps a failing disk from crashing the host:
70
+ // every await below surfaces the failure through the promise chain instead.
71
+ stream.on('error', (error) => { streamError = error })
72
+ try {
73
+ for await (const chunk of chunks) {
74
+ const buffer = Buffer.from(chunk)
75
+ size += buffer.length
76
+ if (size > limit) throw new SidebarError('too-large', `upload exceeds the ${limit} byte limit`, 413)
77
+ if (!stream.write(buffer)) await once(stream, 'drain')
78
+ if (streamError !== undefined) throw streamError
79
+ }
80
+ await new Promise<void>((resolve, reject) => {
81
+ stream.end((error?: Error | null) => (error === undefined || error === null ? resolve() : reject(error)))
82
+ })
83
+ if (streamError !== undefined) throw streamError
84
+ await rename(tmp, safeTarget)
85
+ const info = await stat(safeTarget)
86
+ return { path: target, size: info.size }
87
+ } catch (error) {
88
+ // Wait for the stream to fully close before unlinking (Windows locks open
89
+ // files), then remove our own uniquely named temp file.
90
+ stream.destroy()
91
+ await closed.catch(() => {})
92
+ await rm(tmp, { force: true }).catch(() => {})
93
+ throw error
94
+ }
95
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Recursive file-name search for the editor's merged-mode side panel.
3
+ * Streams the tree with opendir and matches the query as a case-insensitive
4
+ * substring of each entry's NAME (paths stay relative to the search root —
5
+ * the client resolves them against the session cwd). No .gitignore semantics
6
+ * (this is a name lookup, not a code search), but known noise directories
7
+ * (`.git`, `node_modules`, package-manager stores, build caches) are
8
+ * skipped outright and symlink directories are NOT descended (cycle safety).
9
+ *
10
+ * Two performance budgets bound the walk: `maxMatches` (the client renders
11
+ * the flat list) and `maxVisited` (a runaway tree — a home directory root
12
+ * — must not stall the host). Exceeding either stops early with
13
+ * `truncated: true`.
14
+ */
15
+ import { opendir } from 'node:fs/promises'
16
+ import { join, relative, sep } from 'node:path'
17
+
18
+ /** One search: the relative paths of the matching entries (dirs included so
19
+ * the client can hint where matches live) plus the truncation flag. */
20
+ export interface FsSearchResult {
21
+ matches: string[]
22
+ truncated: boolean
23
+ }
24
+
25
+ /** Search budgets (both injectable for tests). */
26
+ export interface FsSearchOptions {
27
+ /** Row cap of the result list (default 200). */
28
+ maxMatches?: number
29
+ /** Total entries visited before the walk gives up (default 100_000). */
30
+ maxVisited?: number
31
+ }
32
+
33
+ const DEFAULT_MAX_MATCHES = 200
34
+ const DEFAULT_MAX_VISITED = 100_000
35
+
36
+ /**
37
+ * Directory names that are never useful filename-search results and would
38
+ * burn the visit budget before the walk reaches project files. Compared
39
+ * case-insensitively so `Node_Modules` / `.GIT` stay skipped on every
40
+ * platform. The directory itself is neither matched nor descended.
41
+ */
42
+ const SEARCH_SKIP_DIRS = new Set([
43
+ '.git',
44
+ 'node_modules',
45
+ '.pnpm-store',
46
+ '.yarn',
47
+ '.turbo',
48
+ '.turbopack',
49
+ '.next',
50
+ '.nuxt',
51
+ '.output',
52
+ '.cache',
53
+ '.parcel-cache',
54
+ 'coverage',
55
+ 'dist',
56
+ 'build',
57
+ 'out',
58
+ '.umi',
59
+ '.umi-production',
60
+ '.dumi',
61
+ ])
62
+
63
+ /**
64
+ * Search `root` recursively for entries whose name contains `query`
65
+ * (case-insensitive).
66
+ * @param root - absolute search root.
67
+ * @param query - the name substring; empty matches nothing.
68
+ * @param opts - budget overrides (tests).
69
+ * @returns the matching paths RELATIVE to `root` ('/'-separated), sorted,
70
+ * plus whether a budget cut the walk short. An unreadable level is skipped
71
+ * (permission errors never fail the whole search).
72
+ */
73
+ export async function searchFiles(root: string, query: string, opts: FsSearchOptions = {}): Promise<FsSearchResult> {
74
+ const needle = query.trim().toLowerCase()
75
+ if (needle === '') return { matches: [], truncated: false }
76
+ const maxMatches = opts.maxMatches ?? DEFAULT_MAX_MATCHES
77
+ const maxVisited = opts.maxVisited ?? DEFAULT_MAX_VISITED
78
+
79
+ const matches: string[] = []
80
+ let visited = 0
81
+ let truncated = false
82
+
83
+ const walk = async (dir: string): Promise<void> => {
84
+ if (truncated) return
85
+ const level = await opendir(dir).catch(() => undefined)
86
+ if (level === undefined) return
87
+ for await (const dirent of level) {
88
+ visited += 1
89
+ if (visited > maxVisited) {
90
+ truncated = true
91
+ return
92
+ }
93
+ // Dependency / VCS / build-output forests: never matched, never descended.
94
+ if (dirent.isDirectory() && SEARCH_SKIP_DIRS.has(dirent.name.toLowerCase())) continue
95
+ if (dirent.name.toLowerCase().includes(needle)) {
96
+ matches.push(join(relative(root, dir), dirent.name))
97
+ if (matches.length >= maxMatches) {
98
+ truncated = true
99
+ return
100
+ }
101
+ }
102
+ // Descend real directories only: a symlinked directory may point back
103
+ // up the tree (cycle).
104
+ if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
105
+ await walk(join(dir, dirent.name))
106
+ if (truncated) return
107
+ }
108
+ }
109
+ }
110
+ await walk(root)
111
+ // '/' separators on every platform: the client joins onto the cwd itself.
112
+ return { matches: matches.sort().map(path => path.split(sep).join('/')), truncated }
113
+ }
package/src/fs-tree.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Single-level directory listing for the sidebar explorer. Streams the level
3
+ * with opendir, sorts directories first then names (case-insensitive), and
4
+ * marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
5
+ * stat'ed once to expose their target kind — a symlink to a directory
6
+ * expands like a directory — and dangling links are flagged broken. The
7
+ * probe runs only for entries that are actually symlinks, so levels without
8
+ * links stay as cheap as before.
9
+ */
10
+ import { opendir, stat } from 'node:fs/promises'
11
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
12
+ import { SidebarError } from './wire.ts'
13
+
14
+ /** One explorer row. */
15
+ export interface SidebarFsEntry {
16
+ name: string
17
+ path: string
18
+ isDir: boolean
19
+ hidden: boolean
20
+ /** Whether the row is a symlink; `isDir` then describes the link's target. */
21
+ isSymlink: boolean
22
+ /** For symlinks: the target is missing or unreadable (stat failed). */
23
+ broken: boolean
24
+ }
25
+
26
+ /** One listed level. */
27
+ export interface SidebarFsListing {
28
+ path: string
29
+ entries: SidebarFsEntry[]
30
+ truncated: boolean
31
+ }
32
+
33
+ /** Directory-first, case-insensitive name ordering (VSCode explorer order). */
34
+ export function compareEntries(a: SidebarFsEntry, b: SidebarFsEntry): number {
35
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
36
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
37
+ }
38
+
39
+ /**
40
+ * List one directory level.
41
+ * @param path - absolute directory path.
42
+ * @param maxEntries - row bound of one level (extra rows flag `truncated`).
43
+ * @returns the sorted listing.
44
+ * @throws {SidebarError} fs-error when the level is unreadable or not a directory.
45
+ */
46
+ export async function listDirectory(path: string, maxEntries = 1000): Promise<SidebarFsListing> {
47
+ let level
48
+ try {
49
+ level = await opendir(path)
50
+ } catch (error) {
51
+ throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
52
+ }
53
+ const rows: SidebarFsEntry[] = []
54
+ let overflow = 0
55
+ try {
56
+ for await (const dirent of level) {
57
+ if (rows.length >= maxEntries) {
58
+ overflow += 1
59
+ continue
60
+ }
61
+ // Platform join: on Windows the level path uses '\' — a hardcoded '/'
62
+ // would leak mixed separators into every row's path.
63
+ rows.push({
64
+ name: dirent.name,
65
+ path: join(path, dirent.name),
66
+ isDir: dirent.isDirectory(),
67
+ isSymlink: dirent.isSymbolicLink(),
68
+ broken: false,
69
+ hidden: dirent.name.startsWith('.'),
70
+ })
71
+ }
72
+ } catch (error) {
73
+ throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
74
+ }
75
+ // Probe symlink targets AFTER the readdir stream closes, with bounded
76
+ // concurrency: a symlink-heavy level (UNC/network targets) would otherwise
77
+ // serialize up to maxEntries stat calls and stall the explorer. Non-symlink
78
+ // rows are skipped by the probe, so levels without links stay as cheap as
79
+ // before.
80
+ await probeSymlinkTargets(rows)
81
+ rows.sort(compareEntries)
82
+ return { path, entries: rows, truncated: overflow > 0 }
83
+ }
84
+
85
+ /** How many symlink target stats run in flight during one level listing. */
86
+ const SYMLINK_PROBE_CONCURRENCY = 32
87
+
88
+ /** Probe each symlink row's target once (bounded concurrency, order-preserving). */
89
+ async function probeSymlinkTargets(rows: SidebarFsEntry[], concurrency = SYMLINK_PROBE_CONCURRENCY): Promise<void> {
90
+ let next = 0
91
+ const workers = Array.from({ length: Math.min(concurrency, rows.length) }, async () => {
92
+ for (;;) {
93
+ const index = next
94
+ next += 1
95
+ if (index >= rows.length) return
96
+ const row = rows[index]!
97
+ if (!row.isSymlink) continue
98
+ // stat follows the chain; any failure (missing target, ELOOP, permission)
99
+ // leaves the row as a broken file-shaped link the editor refuses to read.
100
+ const info = await stat(row.path).catch(() => undefined)
101
+ row.isDir = info !== undefined ? info.isDirectory() : row.isDir
102
+ row.broken = info === undefined
103
+ }
104
+ })
105
+ await Promise.all(workers)
106
+ }
107
+
108
+ /** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
109
+ export function rootLabel(path: string): string {
110
+ const base = basename(path)
111
+ return base !== '' ? base : path
112
+ }
113
+
114
+ /** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */
115
+ export function parentOf(path: string): string | undefined {
116
+ const parent = dirname(path)
117
+ return parent === path ? undefined : parent
118
+ }
119
+
120
+ /**
121
+ * Normalize a caller-supplied path to an absolute, resolved path or throw
122
+ * fs-error. `path.isAbsolute()` is the OS's own notion of absolute: POSIX
123
+ * roots (`/...`), Windows drive letters (`C:\...`) and — on win32 — UNC
124
+ * network shares (`\\server\share\...`); drive-relative forms (`C:foo`)
125
+ * stay rejected.
126
+ */
127
+ export function requireAbsolute(path: string): string {
128
+ if (!isAbsolute(path)) {
129
+ throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
130
+ }
131
+ return resolve(path)
132
+ }
133
+
134
+ /**
135
+ * Whether `target` lies under `base` (or equals it), tolerant of separator
136
+ * style and — on Windows, where the filesystem is case-insensitive — of
137
+ * letter case. The media route uses this instead of a raw `startsWith` so a
138
+ * case-mismatched or mixed-separator path can never be misclassified
139
+ * (e.g. `C:\Users\Me` vs `c:/users/me/file.png`).
140
+ * @param platform - filesystem semantics; injectable so both branches are
141
+ * unit-testable on any host.
142
+ */
143
+ export function isWithin(base: string, target: string, platform: NodeJS.Platform = process.platform): boolean {
144
+ const norm = (value: string): string => value.replace(/[\\/]+/g, '/').replace(/\/$/, '')
145
+ const b = norm(base)
146
+ const t = norm(target)
147
+ if (platform === 'win32') {
148
+ const lb = b.toLowerCase()
149
+ const lt = t.toLowerCase()
150
+ return lt === lb || lt.startsWith(`${lb}/`)
151
+ }
152
+ return t === b || t.startsWith(`${b}/`)
153
+ }
154
+
155
+ /** Message text of an unknown thrown value. */
156
+ export function messageOf(error: unknown): string {
157
+ return error instanceof Error ? error.message : String(error)
158
+ }