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.
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +48229 -0
- package/lib/client-mermaid.js +200745 -0
- package/lib/client-registry.js +21867 -0
- package/lib/client-terminal.js +9361 -0
- package/lib/client.js +21867 -0
- package/lib/index.js +4556 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-opens.d.ts +65 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +39 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +13 -0
- package/lib/types/client/FileTree.d.ts +38 -0
- package/lib/types/client/FreeWindow.d.ts +29 -0
- package/lib/types/client/GitView.d.ts +10 -0
- package/lib/types/client/MarkdownHtml.d.ts +32 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +54 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +24 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +275 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +17 -0
- package/lib/types/client/browser.d.ts +60 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +131 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +59 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +25 -0
- package/lib/types/client/intercept.d.ts +46 -0
- package/lib/types/client/lang.d.ts +14 -0
- package/lib/types/client/layout-push.d.ts +15 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales-ar.d.ts +18 -0
- package/lib/types/client/locales-de.d.ts +4 -0
- package/lib/types/client/locales-fr.d.ts +10 -0
- package/lib/types/client/locales-hi.d.ts +17 -0
- package/lib/types/client/locales-id.d.ts +15 -0
- package/lib/types/client/locales-it.d.ts +8 -0
- package/lib/types/client/locales-ja.d.ts +17 -0
- package/lib/types/client/locales-ko.d.ts +9 -0
- package/lib/types/client/locales-nl.d.ts +16 -0
- package/lib/types/client/locales-pl.d.ts +19 -0
- package/lib/types/client/locales-pt.d.ts +1 -0
- package/lib/types/client/locales-ru.d.ts +13 -0
- package/lib/types/client/locales-sv.d.ts +1 -0
- package/lib/types/client/locales-th.d.ts +17 -0
- package/lib/types/client/locales-tr.d.ts +17 -0
- package/lib/types/client/locales-vi.d.ts +17 -0
- package/lib/types/client/locales-zh-HK.d.ts +32 -0
- package/lib/types/client/locales-zh-MO.d.ts +32 -0
- package/lib/types/client/locales-zh-TW.d.ts +32 -0
- package/lib/types/client/locales.d.ts +452 -0
- package/lib/types/client/markdown-html.d.ts +96 -0
- package/lib/types/client/markdown-images.d.ts +45 -0
- package/lib/types/client/markdown-labels.d.ts +38 -0
- package/lib/types/client/md-toc.d.ts +4 -0
- package/lib/types/client/mermaid-blocks.d.ts +47 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +85 -0
- package/lib/types/client/paths.d.ts +43 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/pinned.d.ts +86 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +29 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +439 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +36 -0
- package/lib/types/client/state.d.ts +432 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +40 -0
- package/lib/types/client/terminal-font.d.ts +78 -0
- package/lib/types/client/terminal-links.d.ts +119 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +605 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +124 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/path-security.d.ts +20 -0
- package/lib/types/prefs-shared.d.ts +236 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +34 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +172 -0
- package/scripts/install.ps1 +306 -0
- package/scripts/install.sh +287 -0
- package/src/agent-opens.ts +265 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +289 -0
- package/src/client/DiffTab.tsx +114 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +558 -0
- package/src/client/FileTree.tsx +669 -0
- package/src/client/FreeWindow.tsx +337 -0
- package/src/client/GitView.tsx +717 -0
- package/src/client/MarkdownHtml.tsx +296 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1163 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +645 -0
- package/src/client/Sidebar.tsx +1370 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +365 -0
- package/src/client/TerminalView.tsx +424 -0
- package/src/client/TextEditor.tsx +468 -0
- package/src/client/TreePanel.tsx +275 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +357 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +61 -0
- package/src/client/browser.ts +167 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +340 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +358 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +152 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +427 -0
- package/src/client/intercept.tsx +188 -0
- package/src/client/lang.ts +225 -0
- package/src/client/layout-push.ts +24 -0
- package/src/client/layout.css +90 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales-ar.ts +368 -0
- package/src/client/locales-de.ts +353 -0
- package/src/client/locales-fr.ts +360 -0
- package/src/client/locales-hi.ts +367 -0
- package/src/client/locales-id.ts +365 -0
- package/src/client/locales-it.ts +358 -0
- package/src/client/locales-ja.ts +367 -0
- package/src/client/locales-ko.ts +359 -0
- package/src/client/locales-nl.ts +365 -0
- package/src/client/locales-pl.ts +369 -0
- package/src/client/locales-pt.ts +350 -0
- package/src/client/locales-ru.ts +365 -0
- package/src/client/locales-sv.ts +350 -0
- package/src/client/locales-th.ts +367 -0
- package/src/client/locales-tr.ts +367 -0
- package/src/client/locales-vi.ts +367 -0
- package/src/client/locales-zh-HK.ts +382 -0
- package/src/client/locales-zh-MO.ts +382 -0
- package/src/client/locales-zh-TW.ts +382 -0
- package/src/client/locales.ts +894 -0
- package/src/client/markdown-html.ts +331 -0
- package/src/client/markdown-images.ts +137 -0
- package/src/client/markdown-labels.tsx +52 -0
- package/src/client/md-toc.tsx +127 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +406 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +143 -0
- package/src/client/paths.ts +63 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/pinned.ts +183 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +96 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +210 -0
- package/src/client/produced-files.ts +120 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +859 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2788 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +331 -0
- package/src/client/state.ts +1497 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +55 -0
- package/src/client/terminal-font.ts +224 -0
- package/src/client/terminal-links.ts +194 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +154 -0
- package/src/context-types.ts +611 -0
- package/src/fs-operations.ts +95 -0
- package/src/fs-search.ts +113 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +446 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1291 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/path-security.ts +75 -0
- package/src/prefs-shared.ts +280 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +482 -0
- package/src/trust-fence.ts +84 -0
- 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
|
+
}
|
package/src/fs-search.ts
ADDED
|
@@ -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
|
+
}
|