@keith9681/dsh-sidebar 0.15.2

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 (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -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 +22 -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 +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. package/src/wire.ts +100 -0
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Workspace-safe file mutations for the sidebar (the upload route today).
3
+ *
4
+ * Every write is lexically confined to the session workspace: the upload
5
+ * directory is resolved absolute and must sit inside the session cwd, the
6
+ * relative path is sanitized (absolute paths, '.', '..' and empty segments
7
+ * are refused), and the final target must stay inside both. Containment is
8
+ * lexical (no symlink resolution) — a symlinked directory inside the cwd can
9
+ * redirect writes outside, matching the trust model of the other /sidebar/*
10
+ * routes. Bytes stream from the request body to a uniquely named temp sibling
11
+ * and are renamed into place, so a failed, aborted, or oversized upload never
12
+ * leaves a partial file at the target path.
13
+ */
14
+ import { randomUUID } from 'node:crypto'
15
+ import { once } from 'node:events'
16
+ import { createWriteStream } from 'node:fs'
17
+ import { mkdir, rename, rm, stat } from 'node:fs/promises'
18
+ import { basename, dirname, join } from 'node:path'
19
+ import type { SidebarHttpRequest } from './context-types.ts'
20
+ import { isWithin, requireAbsolute } from './fs-tree.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
+ if (!isWithin(cwd, base)) {
52
+ throw new SidebarError('forbidden', 'upload directory escapes the session workspace', 403)
53
+ }
54
+ if (relativePath === '' || relativePath.startsWith('/') || relativePath.startsWith('\\')) {
55
+ throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
56
+ }
57
+ const segments = relativePath.split(/[\\/]/)
58
+ if (segments.some(part => part === '' || part === '.' || part === '..')) {
59
+ throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
60
+ }
61
+ const target = join(base, ...segments)
62
+ if (!isWithin(cwd, target) || !isWithin(base, target)) {
63
+ throw new SidebarError('forbidden', 'target escapes the session workspace', 403)
64
+ }
65
+ const tmp = join(dirname(target), `.${basename(target)}.dsh-upload-${randomUUID()}.tmp`)
66
+ await mkdir(dirname(target), { recursive: true })
67
+ const stream = createWriteStream(tmp, { flags: 'wx' })
68
+ // Resolves once the stream fully closes; created up front so a stream that
69
+ // already closed (successful end, later failure) cannot leave the wait hanging.
70
+ const closed = new Promise<void>((resolve) => { stream.once('close', () => resolve()) })
71
+ let size = 0
72
+ let streamError: unknown
73
+ // A permanent 'error' listener keeps a failing disk from crashing the host:
74
+ // every await below surfaces the failure through the promise chain instead.
75
+ stream.on('error', (error) => { streamError = error })
76
+ try {
77
+ for await (const chunk of chunks) {
78
+ const buffer = Buffer.from(chunk)
79
+ size += buffer.length
80
+ if (size > limit) throw new SidebarError('too-large', `upload exceeds the ${limit} byte limit`, 413)
81
+ if (!stream.write(buffer)) await once(stream, 'drain')
82
+ if (streamError !== undefined) throw streamError
83
+ }
84
+ await new Promise<void>((resolve, reject) => {
85
+ stream.end((error?: Error | null) => (error === undefined || error === null ? resolve() : reject(error)))
86
+ })
87
+ if (streamError !== undefined) throw streamError
88
+ await rename(tmp, target)
89
+ const info = await stat(target)
90
+ return { path: target, size: info.size }
91
+ } catch (error) {
92
+ // Wait for the stream to fully close before unlinking (Windows locks open
93
+ // files), then remove our own uniquely named temp file.
94
+ stream.destroy()
95
+ await closed.catch(() => {})
96
+ await rm(tmp, { force: true }).catch(() => {})
97
+ throw error
98
+ }
99
+ }
@@ -0,0 +1,86 @@
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 `.git` directories are
7
+ * skipped outright (VCS internals are never useful results) and symlink
8
+ * 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
+ * a node_modules forest — must not stall the host). Exceeding either stops
13
+ * early with `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
+ * Search `root` recursively for entries whose name contains `query`
38
+ * (case-insensitive).
39
+ * @param root - absolute search root.
40
+ * @param query - the name substring; empty matches nothing.
41
+ * @param opts - budget overrides (tests).
42
+ * @returns the matching paths RELATIVE to `root` ('/'-separated), sorted,
43
+ * plus whether a budget cut the walk short. An unreadable level is skipped
44
+ * (permission errors never fail the whole search).
45
+ */
46
+ export async function searchFiles(root: string, query: string, opts: FsSearchOptions = {}): Promise<FsSearchResult> {
47
+ const needle = query.trim().toLowerCase()
48
+ if (needle === '') return { matches: [], truncated: false }
49
+ const maxMatches = opts.maxMatches ?? DEFAULT_MAX_MATCHES
50
+ const maxVisited = opts.maxVisited ?? DEFAULT_MAX_VISITED
51
+
52
+ const matches: string[] = []
53
+ let visited = 0
54
+ let truncated = false
55
+
56
+ const walk = async (dir: string): Promise<void> => {
57
+ if (truncated) return
58
+ const level = await opendir(dir).catch(() => undefined)
59
+ if (level === undefined) return
60
+ for await (const dirent of level) {
61
+ visited += 1
62
+ if (visited > maxVisited) {
63
+ truncated = true
64
+ return
65
+ }
66
+ // .git is VCS-internal noise: never matched, never descended.
67
+ if (dirent.isDirectory() && dirent.name === '.git') continue
68
+ if (dirent.name.toLowerCase().includes(needle)) {
69
+ matches.push(join(relative(root, dir), dirent.name))
70
+ if (matches.length >= maxMatches) {
71
+ truncated = true
72
+ return
73
+ }
74
+ }
75
+ // Descend real directories only: a symlinked directory may point back
76
+ // up the tree (cycle).
77
+ if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
78
+ await walk(join(dir, dirent.name))
79
+ if (truncated) return
80
+ }
81
+ }
82
+ }
83
+ await walk(root)
84
+ // '/' separators on every platform: the client joins onto the cwd itself.
85
+ return { matches: matches.sort().map(path => path.split(sep).join('/')), truncated }
86
+ }
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
+ }
package/src/git.ts ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Git operations for the sidebar source-control panel. Everything goes
3
+ * through the system `git` binary spawned per request (no library, no state),
4
+ * with porcelain-parseable output formats (`-z` NUL framing, unit separators)
5
+ * so parsing never depends on locale or color config. All commands run with
6
+ * `-C <cwd>` on the session's working directory and `--no-pager` /
7
+ * `-c color.ui=false` so output stays machine-readable.
8
+ *
9
+ * Commits use the user's git global identity untouched (never sets
10
+ * user.name/user.email).
11
+ */
12
+ import { spawn } from 'node:child_process'
13
+
14
+ /** A parsed `git status --porcelain=v1 -z` entry. */
15
+ export interface GitStatusEntry {
16
+ path: string
17
+ /** Two-letter index/worktree status (X Y), e.g. 'M ', ' M', 'A ', '??'. */
18
+ xy: string
19
+ }
20
+
21
+ /** The source-control panel snapshot. */
22
+ export interface GitStatusResult {
23
+ isRepo: boolean
24
+ branch?: string
25
+ entries: GitStatusEntry[]
26
+ }
27
+
28
+ /** One `git log` row. */
29
+ export interface GitLogEntry {
30
+ /** Short hash (7+ chars, display). */
31
+ hash: string
32
+ /** Full 40-char hash (advanced operations: revert / cherry-pick). */
33
+ hashFull: string
34
+ subject: string
35
+ author: string
36
+ /** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */
37
+ date: string
38
+ /** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
39
+ refs: string
40
+ }
41
+
42
+ /** One git failure (stderr text as the message). */
43
+ export class GitCommandError extends Error {
44
+ constructor(
45
+ message: string,
46
+ readonly code = 'git-error',
47
+ readonly command: string,
48
+ ) {
49
+ super(message)
50
+ }
51
+ }
52
+
53
+ /** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
54
+ export function parsePorcelainZ(output: string): GitStatusEntry[] {
55
+ const tokens = output.split('\0')
56
+ const entries: GitStatusEntry[] = []
57
+ let index = 0
58
+ while (index < tokens.length) {
59
+ const token = tokens[index]!
60
+ index += 1
61
+ if (token === '') continue
62
+ const xy = token.slice(0, 2)
63
+ const rest = token.slice(3)
64
+ entries.push({ path: rest, xy })
65
+ // Rename/copy entries carry the ORIGIN path as the next NUL field; the
66
+ // new path (the file as it exists now) is the display path.
67
+ if ((xy[0] === 'R' || xy[0] === 'C') && tokens[index] !== undefined && tokens[index] !== '') {
68
+ index += 1
69
+ }
70
+ }
71
+ return entries
72
+ }
73
+
74
+ /** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
75
+ export function parseLogLines(output: string): GitLogEntry[] {
76
+ const rows: GitLogEntry[] = []
77
+ for (const line of output.split('\n')) {
78
+ if (line === '') continue
79
+ const [hash, subject, author, date, hashFull, refs] = line.split('\x1f')
80
+ if (hash === undefined || subject === undefined) continue
81
+ rows.push({
82
+ hash,
83
+ subject,
84
+ author: author ?? '',
85
+ date: date ?? '',
86
+ hashFull: hashFull ?? hash,
87
+ refs: refs ?? '',
88
+ })
89
+ }
90
+ return rows
91
+ }
92
+
93
+ /** Run one git command; resolves with stdout, rejects with GitCommandError. */
94
+ function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string> {
95
+ const full = ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args]
96
+ return new Promise<string>((resolvePromise, reject) => {
97
+ const child = spawn('git', full, {
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ windowsHide: true,
100
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
101
+ })
102
+ let stdout = ''
103
+ let stderr = ''
104
+ const timer = setTimeout(() => {
105
+ child.kill('SIGKILL')
106
+ reject(new GitCommandError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'git-error', args.join(' ')))
107
+ }, timeoutMs)
108
+ child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
109
+ child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
110
+ child.on('error', (error) => {
111
+ clearTimeout(timer)
112
+ reject(new GitCommandError(`cannot run git: ${error.message}`, 'git-error', args.join(' ')))
113
+ })
114
+ child.on('close', (code) => {
115
+ clearTimeout(timer)
116
+ if (code === 0) {
117
+ resolvePromise(stdout)
118
+ } else {
119
+ reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, 'git-error', args.join(' ')))
120
+ }
121
+ })
122
+ })
123
+ }
124
+
125
+ /** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
126
+ export async function isGitRepo(cwd: string): Promise<boolean> {
127
+ try {
128
+ const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'])
129
+ return out.trim() === 'true'
130
+ } catch {
131
+ return false
132
+ }
133
+ }
134
+
135
+ /** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
136
+ export async function repoRoot(cwd: string): Promise<string> {
137
+ const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
138
+ return out.trim()
139
+ }
140
+
141
+ /** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
142
+ export async function currentBranch(cwd: string): Promise<string> {
143
+ const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
144
+ return out.trim()
145
+ }
146
+
147
+ /**
148
+ * Working-tree status (untracked included). `--untracked-files=all` lists
149
+ * the CONTENTS of new directories as individual entries (`?? newdir/a.ts`
150
+ * rather than a collapsed `?? newdir/`), so every row in the source-control
151
+ * panel is a real file whose diff tab can load. With `=normal`, git folds a
152
+ * new folder into one trailing-slash entry that has no diff output and
153
+ * cannot be read as a file.
154
+ */
155
+ export async function status(cwd: string): Promise<GitStatusResult> {
156
+ const repo = await isGitRepo(cwd)
157
+ if (!repo) return { isRepo: false, entries: [] }
158
+ const [branch, raw] = await Promise.all([
159
+ currentBranch(cwd).catch(() => 'HEAD'),
160
+ runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
161
+ ])
162
+ return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
163
+ }
164
+
165
+ /** Diff text of the worktree (unstaged) or the index (staged). */
166
+ export async function diff(cwd: string, path: string | undefined, staged: boolean): Promise<string> {
167
+ const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
168
+ if (staged) args.push('--cached')
169
+ if (path !== undefined) args.push('--', path)
170
+ return runGit(cwd, args)
171
+ }
172
+
173
+ /** Stage paths (all when path is undefined). */
174
+ export async function stage(cwd: string, path: string | undefined): Promise<void> {
175
+ await runGit(cwd, ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
176
+ }
177
+
178
+ /** Unstage paths (all when path is undefined). */
179
+ export async function unstage(cwd: string, path: string | undefined): Promise<void> {
180
+ await runGit(cwd, ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
181
+ }
182
+
183
+ /** Commit the staged changes with a message (global identity untouched). */
184
+ export async function commit(cwd: string, message: string): Promise<void> {
185
+ await runGit(cwd, ['commit', '-m', message])
186
+ }
187
+
188
+ /** Branch names (current first). */
189
+ export async function branches(cwd: string): Promise<{ current: string; names: string[] }> {
190
+ const [current, raw] = await Promise.all([
191
+ currentBranch(cwd).catch(() => 'HEAD'),
192
+ runGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
193
+ ])
194
+ const names = raw.split('\n').filter(line => line !== '')
195
+ return { current, names: names.includes(current) ? names : [current, ...names] }
196
+ }
197
+
198
+ /** Switch to an existing branch. */
199
+ export async function checkout(cwd: string, branch: string): Promise<void> {
200
+ await runGit(cwd, ['checkout', branch])
201
+ }
202
+
203
+ /** Recent commit history (newest first), lazily pageable via skip/count. */
204
+ export async function log(cwd: string, count = 30, skip = 0): Promise<GitLogEntry[]> {
205
+ const raw = await runGit(cwd, [
206
+ 'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
207
+ '--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
208
+ ])
209
+ return parseLogLines(raw)
210
+ }
211
+
212
+ /**
213
+ * Content of a file at a revision (`git show <rev>:<path>`), or null when the
214
+ * revision has no such path (a new/untracked file has no HEAD side).
215
+ */
216
+ export async function show(cwd: string, rev: string, path: string): Promise<string | null> {
217
+ try {
218
+ return await runGit(cwd, ['show', `${rev}:${path}`])
219
+ } catch {
220
+ return null
221
+ }
222
+ }
223
+
224
+ /** Full patch text of one commit (`git show` with the commit header suppressed).
225
+ * Merge commits show their diff against the first parent (`-m --first-parent`
226
+ * is a no-op for regular commits), so a history click always has content. */
227
+ export async function commitDiff(cwd: string, hash: string): Promise<string> {
228
+ return runGit(cwd, ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
229
+ }
230
+
231
+ /** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
232
+ export async function discard(cwd: string, path: string): Promise<void> {
233
+ await runGit(cwd, ['checkout', '--', path])
234
+ }
235
+
236
+ /** Revert one commit onto the current branch with an auto-generated message. */
237
+ export async function revert(cwd: string, hash: string): Promise<void> {
238
+ await runGit(cwd, ['revert', '--no-edit', hash])
239
+ }
240
+
241
+ /** Cherry-pick one commit onto the current branch. */
242
+ export async function cherryPick(cwd: string, hash: string): Promise<void> {
243
+ await runGit(cwd, ['cherry-pick', hash])
244
+ }