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
package/src/git.ts ADDED
@@ -0,0 +1,446 @@
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 { readdir } from 'node:fs/promises'
13
+ import { join } from 'node:path'
14
+ import { spawn } from 'node:child_process'
15
+ import { resolve } from 'node:path'
16
+
17
+ /** A parsed `git status --porcelain=v1 -z` entry. */
18
+ export interface GitStatusEntry {
19
+ path: string
20
+ /** Two-letter index/worktree status (X Y), e.g. 'M ', ' M', 'A ', '??'. */
21
+ xy: string
22
+ }
23
+
24
+ /** The source-control panel snapshot. */
25
+ export interface GitStatusResult {
26
+ isRepo: boolean
27
+ branch?: string
28
+ entries: GitStatusEntry[]
29
+ /** True when the working tree had more rows than `GIT_STATUS_LIMIT`; the
30
+ * panel shows a truncation notice instead of freezing on a huge untracked
31
+ * set (issue #369). */
32
+ truncated?: boolean
33
+ /** Selected repository root, or the discovered roots when the cwd is a container. */
34
+ root?: string
35
+ repositories?: string[]
36
+ }
37
+
38
+ /** One linked checkout returned by `git worktree list --porcelain`. */
39
+ export interface GitWorktree {
40
+ /** Absolute checkout root. */
41
+ path: string
42
+ /** Branch name without `refs/heads/`, or `HEAD` when detached. */
43
+ branch: string
44
+ /** Whether this checkout contains the session cwd. */
45
+ current: boolean
46
+ /** Number of staged + unstaged status rows (a file changed on both sides counts once). */
47
+ changes: number
48
+ }
49
+
50
+ /** One `git log` row. */
51
+ export interface GitLogEntry {
52
+ /** Short hash (7+ chars, display). */
53
+ hash: string
54
+ /** Full 40-char hash (advanced operations: revert / cherry-pick). */
55
+ hashFull: string
56
+ subject: string
57
+ author: string
58
+ /** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */
59
+ date: string
60
+ /** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
61
+ refs: string
62
+ }
63
+
64
+ /** One git failure (stderr text as the message). */
65
+ export class GitCommandError extends Error {
66
+ constructor(
67
+ message: string,
68
+ readonly code = 'git-error',
69
+ readonly command: string,
70
+ ) {
71
+ super(message)
72
+ }
73
+ }
74
+
75
+ /** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
76
+ export function parsePorcelainZ(output: string): GitStatusEntry[] {
77
+ const tokens = output.split('\0')
78
+ const entries: GitStatusEntry[] = []
79
+ let index = 0
80
+ while (index < tokens.length) {
81
+ const token = tokens[index]!
82
+ index += 1
83
+ if (token === '') continue
84
+ const xy = token.slice(0, 2)
85
+ const rest = token.slice(3)
86
+ entries.push({ path: rest, xy })
87
+ // Rename/copy entries carry the ORIGIN path as the next NUL field; the
88
+ // new path (the file as it exists now) is the display path.
89
+ if ((xy[0] === 'R' || xy[0] === 'C') && tokens[index] !== undefined && tokens[index] !== '') {
90
+ index += 1
91
+ }
92
+ }
93
+ return entries
94
+ }
95
+
96
+ /** One raw porcelain worktree record. Prunable checkouts are retained by
97
+ * Git's administrative metadata after their directory disappears and must not
98
+ * become selectable command targets. Locked checkouts remain usable. */
99
+ export interface GitWorktreeRecord {
100
+ path: string
101
+ branch: string
102
+ locked: boolean
103
+ prunable: boolean
104
+ }
105
+
106
+ /** Parse `git worktree list --porcelain` records. Production requests use
107
+ * `-z` so even newlines and non-ASCII bytes in checkout paths stay lossless;
108
+ * newline framing remains accepted for small fixtures and older Git output. */
109
+ export function parseWorktreeList(output: string): GitWorktreeRecord[] {
110
+ const rows: GitWorktreeRecord[] = []
111
+ let path: string | undefined
112
+ let branch = 'HEAD'
113
+ let locked = false
114
+ let prunable = false
115
+ const flush = (): void => {
116
+ if (path !== undefined) rows.push({ path, branch, locked, prunable })
117
+ path = undefined
118
+ branch = 'HEAD'
119
+ locked = false
120
+ prunable = false
121
+ }
122
+ const sep = output.includes('\0') ? '\0' : '\n'
123
+ const framed = output.endsWith(sep) ? output : `${output}${sep}`
124
+ for (const line of framed.split(sep)) {
125
+ if (line === '') {
126
+ flush()
127
+ } else if (line.startsWith('worktree ')) {
128
+ path = line.slice('worktree '.length)
129
+ } else if (line.startsWith('branch refs/heads/')) {
130
+ branch = line.slice('branch refs/heads/'.length)
131
+ } else if (line === 'locked' || line.startsWith('locked ')) {
132
+ locked = true
133
+ } else if (line === 'prunable' || line.startsWith('prunable ')) {
134
+ prunable = true
135
+ }
136
+ }
137
+ return rows
138
+ }
139
+
140
+ /** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
141
+ export function parseLogLines(output: string): GitLogEntry[] {
142
+ const rows: GitLogEntry[] = []
143
+ for (const line of output.split('\n')) {
144
+ if (line === '') continue
145
+ const [hash, subject, author, date, hashFull, refs] = line.split('\x1f')
146
+ if (hash === undefined || subject === undefined) continue
147
+ rows.push({
148
+ hash,
149
+ subject,
150
+ author: author ?? '',
151
+ date: date ?? '',
152
+ hashFull: hashFull ?? hash,
153
+ refs: refs ?? '',
154
+ })
155
+ }
156
+ return rows
157
+ }
158
+
159
+ /** Run one git command; resolves with stdout, rejects with GitCommandError. */
160
+ function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string> {
161
+ const full = ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args]
162
+ return new Promise<string>((resolvePromise, reject) => {
163
+ const child = spawn('git', full, {
164
+ stdio: ['ignore', 'pipe', 'pipe'],
165
+ windowsHide: true,
166
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
167
+ })
168
+ let stdout = ''
169
+ let stderr = ''
170
+ const timer = setTimeout(() => {
171
+ child.kill('SIGKILL')
172
+ reject(new GitCommandError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'git-error', args.join(' ')))
173
+ }, timeoutMs)
174
+ child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
175
+ child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
176
+ child.on('error', (error) => {
177
+ clearTimeout(timer)
178
+ reject(new GitCommandError(`cannot run git: ${error.message}`, 'git-error', args.join(' ')))
179
+ })
180
+ child.on('close', (code) => {
181
+ clearTimeout(timer)
182
+ if (code === 0) {
183
+ resolvePromise(stdout)
184
+ } else {
185
+ reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, 'git-error', args.join(' ')))
186
+ }
187
+ })
188
+ })
189
+ }
190
+
191
+ /** Cap on child directories probed by the workspace-container fallback scan.
192
+ * A home-directory cwd can hold hundreds of visible folders (Library, iCloud
193
+ * mounts…); probing them all serially is what froze the panel in #369. */
194
+ const DISCOVERY_LIMIT = 200
195
+ /** Per-probe and direct-discovery budget. `rev-parse` is millisecond-scale on
196
+ * a healthy checkout; a probe that needs longer is a stalled mount and is
197
+ * better abandoned than waited on. */
198
+ const DISCOVERY_TIMEOUT_MS = 5_000
199
+ /** Discovery results are cheap to recompute but expensive to storm: the panel
200
+ * polls every 2s and each poll fans out into several git.* calls that all
201
+ * resolve the same roots. A short TTL keeps fan-out at one scan per cwd. */
202
+ const DISCOVERY_CACHE_TTL_MS = 60_000
203
+
204
+ const repoRootsCache = new Map<string, { roots: string[]; expires: number }>()
205
+ const repoRootsInFlight = new Map<string, Promise<string[]>>()
206
+
207
+ /** Whether the directory is inside a git work tree (exit-0 `git rev-parse`).
208
+ * Probe timeout is short: a cwd on a stalled mount must not hold the panel
209
+ * hostage for the full command budget (issue #369). */
210
+ export async function isGitRepo(cwd: string): Promise<boolean> {
211
+ try {
212
+ const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'], DISCOVERY_TIMEOUT_MS)
213
+ return out.trim() === 'true'
214
+ } catch {
215
+ return false
216
+ }
217
+ }
218
+
219
+ /** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
220
+ async function directRepoRoot(cwd: string): Promise<string> {
221
+ const out = await runGit(cwd, ['rev-parse', '--show-toplevel'], DISCOVERY_TIMEOUT_MS)
222
+ return out.trim()
223
+ }
224
+
225
+ /** Discover the current repository or direct child repositories. Results are
226
+ * cached per cwd and concurrent callers share one in-flight scan, so opening
227
+ * the panel (three parallel git.* requests) costs a single discovery pass. */
228
+ export function repoRoots(cwd: string): Promise<string[]> {
229
+ const cached = repoRootsCache.get(cwd)
230
+ if (cached !== undefined && cached.expires > Date.now()) return Promise.resolve(cached.roots)
231
+ const pending = repoRootsInFlight.get(cwd)
232
+ if (pending !== undefined) return pending
233
+ const promise = discoverRepoRoots(cwd).then(
234
+ (roots) => {
235
+ repoRootsCache.set(cwd, { roots, expires: Date.now() + DISCOVERY_CACHE_TTL_MS })
236
+ repoRootsInFlight.delete(cwd)
237
+ return roots
238
+ },
239
+ (error: unknown) => {
240
+ repoRootsInFlight.delete(cwd)
241
+ throw error
242
+ },
243
+ )
244
+ repoRootsInFlight.set(cwd, promise)
245
+ return promise
246
+ }
247
+
248
+ async function discoverRepoRoots(cwd: string): Promise<string[]> {
249
+ try {
250
+ return [await directRepoRoot(cwd)]
251
+ } catch {
252
+ const entries = await readdir(cwd, { withFileTypes: true }).catch(() => [])
253
+ const roots: string[] = []
254
+ for (const entry of entries
255
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules')
256
+ .sort((left, right) => left.name.localeCompare(right.name))
257
+ .slice(0, DISCOVERY_LIMIT)) {
258
+ try {
259
+ const root = await directRepoRoot(join(cwd, entry.name))
260
+ if (!roots.some(existing => pathIdentity(existing) === pathIdentity(root))) roots.push(root)
261
+ } catch {
262
+ // Ordinary child directory; keep discovering sibling repositories.
263
+ }
264
+ }
265
+ return roots
266
+ }
267
+ }
268
+
269
+ /** Resolve the selected repository, defaulting to the first discovered root. */
270
+ export async function repoRoot(cwd: string, selected?: string): Promise<string> {
271
+ const roots = await repoRoots(cwd)
272
+ if (roots.length === 0) throw new GitCommandError('not a git repository', 'not-repo', 'rev-parse')
273
+ // Git for Windows may return forward-slash roots while callers pass
274
+ // backslashes (or vice-versa); compare via the platform-aware identity.
275
+ if (selected !== undefined) {
276
+ const identity = pathIdentity(selected)
277
+ const match = roots.find(root => pathIdentity(root) === identity)
278
+ if (match !== undefined) return match
279
+ }
280
+ return roots[0]!
281
+ }
282
+
283
+ /** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
284
+ export async function currentBranch(cwd: string): Promise<string> {
285
+ const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
286
+ return out.trim()
287
+ }
288
+
289
+ /** Upper bound on status rows shipped to the client. Beyond this the result
290
+ * is truncated (with `truncated: true`) so a pathological untracked set —
291
+ * e.g. the working tree discovered under a home-directory cwd — cannot
292
+ * freeze the browser main thread on JSON parse or list render (#369). */
293
+ const GIT_STATUS_LIMIT = 2_000
294
+
295
+ /**
296
+ * Working-tree status (untracked included). `--untracked-files=all` lists
297
+ * the contents of new directories as individual entries, while preserving
298
+ * repository discovery and explicit repository selection for workspace roots.
299
+ */
300
+ export async function status(cwd: string, selected?: string): Promise<GitStatusResult> {
301
+ const repositories = await repoRoots(cwd)
302
+ if (repositories.length === 0) return { isRepo: false, entries: [], repositories: [] }
303
+ const root = await repoRoot(cwd, selected)
304
+ const [branch, raw] = await Promise.all([
305
+ currentBranch(root).catch(() => 'HEAD'),
306
+ runGit(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
307
+ ])
308
+ const parsed = parsePorcelainZ(raw)
309
+ const truncated = parsed.length > GIT_STATUS_LIMIT
310
+ return {
311
+ isRepo: true,
312
+ branch,
313
+ entries: truncated ? parsed.slice(0, GIT_STATUS_LIMIT) : parsed,
314
+ truncated,
315
+ root,
316
+ repositories,
317
+ }
318
+ }
319
+
320
+ /** Platform-aware identity used only for comparing absolute checkout roots. */
321
+ function pathIdentity(path: string): string {
322
+ const absolute = resolve(path).replace(/[\\/]+$/, '')
323
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute
324
+ }
325
+
326
+ /** Raw usable checkout records, shared by inventory and target validation.
327
+ * Prunable records point at missing paths and are deliberately excluded from
328
+ * both the selector and the command-target allowlist. */
329
+ async function listedWorktrees(cwd: string): Promise<GitWorktreeRecord[]> {
330
+ const raw = await runGit(cwd, ['worktree', 'list', '--porcelain', '-z'])
331
+ return parseWorktreeList(raw).filter(entry => !entry.prunable)
332
+ }
333
+
334
+ /** All linked checkouts of the repository containing `cwd`, enriched with a
335
+ * live change count. The current checkout is first so a single-worktree repo
336
+ * preserves the old UI ordering. */
337
+ export async function worktrees(cwd: string): Promise<GitWorktree[]> {
338
+ if (!await isGitRepo(cwd)) return []
339
+ const currentRoot = await repoRoot(cwd)
340
+ const listed = await listedWorktrees(cwd)
341
+ const rows = await Promise.all(listed.map(async (entry): Promise<GitWorktree> => ({
342
+ path: entry.path,
343
+ branch: entry.branch,
344
+ current: pathIdentity(entry.path) === pathIdentity(currentRoot),
345
+ // One stale/permission-raced linked checkout must not hide the valid
346
+ // current repository from the panel. Targeted operations still fail loud.
347
+ changes: await status(entry.path).then(result => result.entries.length, () => 0),
348
+ })))
349
+ return rows.sort((left, right) => Number(right.current) - Number(left.current))
350
+ }
351
+
352
+ /** Resolve an optional client-selected linked checkout. A caller may never use
353
+ * this seam to point Git operations at an unrelated repository: the target
354
+ * must occur in the authoritative session repository's worktree list. */
355
+ export async function resolveWorktree(cwd: string, requested?: string): Promise<string> {
356
+ if (requested === undefined || requested === '') return cwd
357
+ const identity = pathIdentity(requested)
358
+ const match = (await listedWorktrees(cwd)).find(entry => pathIdentity(entry.path) === identity)
359
+ if (match === undefined) {
360
+ throw new GitCommandError(`unknown linked worktree: ${requested}`, 'git-worktree', 'worktree list')
361
+ }
362
+ return match.path
363
+ }
364
+
365
+ /** Diff text of the worktree (unstaged) or the index (staged). */
366
+ export async function diff(cwd: string, path: string | undefined, staged: boolean, selected?: string): Promise<string> {
367
+ const root = await repoRoot(cwd, selected)
368
+ const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
369
+ if (staged) args.push('--cached')
370
+ if (path !== undefined) args.push('--', path)
371
+ return runGit(root, args)
372
+ }
373
+
374
+ /** Stage paths (all when path is undefined). */
375
+ export async function stage(cwd: string, path: string | undefined, selected?: string): Promise<void> {
376
+ await runGit(await repoRoot(cwd, selected), ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
377
+ }
378
+
379
+ /** Unstage paths (all when path is undefined). */
380
+ export async function unstage(cwd: string, path: string | undefined, selected?: string): Promise<void> {
381
+ await runGit(await repoRoot(cwd, selected), ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
382
+ }
383
+
384
+ /** Commit the staged changes with a message (global identity untouched). */
385
+ export async function commit(cwd: string, message: string, selected?: string): Promise<void> {
386
+ await runGit(await repoRoot(cwd, selected), ['commit', '-m', message])
387
+ }
388
+
389
+ /** Branch names (current first). */
390
+ export async function branches(cwd: string, selected?: string): Promise<{ current: string; names: string[] }> {
391
+ const root = await repoRoot(cwd, selected)
392
+ const [current, raw] = await Promise.all([
393
+ currentBranch(root).catch(() => 'HEAD'),
394
+ runGit(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
395
+ ])
396
+ const names = raw.split('\n').filter(line => line !== '')
397
+ return { current, names: names.includes(current) ? names : [current, ...names] }
398
+ }
399
+
400
+ /** Switch to an existing branch. */
401
+ export async function checkout(cwd: string, branch: string, selected?: string): Promise<void> {
402
+ await runGit(await repoRoot(cwd, selected), ['checkout', branch])
403
+ }
404
+
405
+ /** Recent commit history (newest first), lazily pageable via skip/count. */
406
+ export async function log(cwd: string, count = 30, skip = 0, selected?: string): Promise<GitLogEntry[]> {
407
+ const raw = await runGit(await repoRoot(cwd, selected), [
408
+ 'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
409
+ '--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
410
+ ])
411
+ return parseLogLines(raw)
412
+ }
413
+
414
+ /**
415
+ * Content of a file at a revision (`git show <rev>:<path>`), or null when the
416
+ * revision has no such path (a new/untracked file has no HEAD side).
417
+ */
418
+ export async function show(cwd: string, rev: string, path: string, selected?: string): Promise<string | null> {
419
+ try {
420
+ return await runGit(await repoRoot(cwd, selected), ['show', `${rev}:${path}`])
421
+ } catch {
422
+ return null
423
+ }
424
+ }
425
+
426
+ /** Full patch text of one commit (`git show` with the commit header suppressed).
427
+ * Merge commits show their diff against the first parent (`-m --first-parent`
428
+ * is a no-op for regular commits), so a history click always has content. */
429
+ export async function commitDiff(cwd: string, hash: string, selected?: string): Promise<string> {
430
+ return runGit(await repoRoot(cwd, selected), ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
431
+ }
432
+
433
+ /** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
434
+ export async function discard(cwd: string, path: string, selected?: string): Promise<void> {
435
+ await runGit(await repoRoot(cwd, selected), ['checkout', '--', path])
436
+ }
437
+
438
+ /** Revert one commit onto the current branch with an auto-generated message. */
439
+ export async function revert(cwd: string, hash: string, selected?: string): Promise<void> {
440
+ await runGit(await repoRoot(cwd, selected), ['revert', '--no-edit', hash])
441
+ }
442
+
443
+ /** Cherry-pick one commit onto the current branch. */
444
+ export async function cherryPick(cwd: string, hash: string, selected?: string): Promise<void> {
445
+ await runGit(await repoRoot(cwd, selected), ['cherry-pick', hash])
446
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Pure URL vocabulary of the /sidebar/html route (HTML previewer).
3
+ *
4
+ * Why path-encoded parameters instead of a query string: the previewed
5
+ * page resolves its relative assets (./style.css, img/x.png) against the
6
+ * document URL, and the WHATWG URL algorithm DROPS the query of a
7
+ * path-relative reference — `/sidebar/html?a=1&path=/a/b/` + `./style.css`
8
+ * would lose the session scope and the route would reject the asset.
9
+ * Encoding everything into the URL path keeps relative resolution inside
10
+ * the same route with every request self-contained:
11
+ *
12
+ * /sidebar/html/<sessionId>/<absolute-path segments, encodeURIComponent'd>
13
+ * /sidebar/html/S/Users/me/proj/index.html
14
+ * + ./style.css → /sidebar/html/S/Users/me/proj/style.css
15
+ * Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
16
+ * UNC (\\server\share\... or //server/share/...):
17
+ * → /sidebar/html/S//server/share/proj/a.html ('//' right after the
18
+ * sessionId marks the UNC prefix; the WHATWG URL keeps '//' intact so
19
+ * relative assets still resolve inside the same route)
20
+ *
21
+ * The decoder rebuilds the marker as a forward-slash `//server/share/...`
22
+ * path. That form is intentionally platform-neutral: `node:path` resolves it
23
+ * to `\\server\share\...` on win32 and `/server/share/...` on POSIX, so the
24
+ * host's existing requireAbsolute + isWithin fence needs no platform signal
25
+ * (a leading `//` is a legal POSIX absolute path, so no data is lost on
26
+ * either platform).
27
+ *
28
+ * This module is intentionally dependency-free (no node imports, no wire
29
+ * helpers) so the client bundle can import `encodeHtmlUrl` without tripping
30
+ * the build-time purity gate; the host converts decode failures into
31
+ * SidebarError responses at the route boundary.
32
+ */
33
+
34
+ /** One decoded route reference. */
35
+ export interface HtmlRouteRef {
36
+ sessionId: string
37
+ /** Absolute file path (leading slash; Windows drives keep their colon). */
38
+ path: string
39
+ }
40
+
41
+ /** Decode outcome: the reference, or a client-error description. */
42
+ export type HtmlDecodeResult =
43
+ | { ok: true; ref: HtmlRouteRef }
44
+ | { ok: false; status: 400 | 404; message: string }
45
+
46
+ /** The route prefix both encoders/decoders agree on. */
47
+ export const HTML_ROUTE_PREFIX = '/sidebar/html/'
48
+
49
+ /** Build the route URL for one absolute file path (client + tests). */
50
+ export function encodeHtmlUrl(sessionId: string, path: string): string {
51
+ const unc = /^[\\/]{2}[^\\/]/.test(path)
52
+ const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
53
+ return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}`
54
+ }
55
+
56
+ /**
57
+ * Decode a route pathname into the session + absolute file path. Rejects
58
+ * a wrong prefix (404), an empty path, malformed percent encoding, and a
59
+ * missing sessionId or file path (400). The caller still must bound the
60
+ * decoded path with the workspace real-path guard — a decoded `..`
61
+ * segment resolves outside the cwd and is refused there.
62
+ */
63
+ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
64
+ if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
65
+ return { ok: false, status: 404, message: 'not an html route' }
66
+ }
67
+ const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
68
+ if (rest === '') {
69
+ return { ok: false, status: 400, message: 'invalid html route path' }
70
+ }
71
+ let segments: string[]
72
+ try {
73
+ segments = rest.split('/').map(segment => decodeURIComponent(segment))
74
+ } catch {
75
+ return { ok: false, status: 400, message: 'malformed URL encoding' }
76
+ }
77
+ const [sessionId, ...pathSegments] = segments
78
+ if (sessionId === undefined || sessionId === '') {
79
+ return { ok: false, status: 400, message: 'sessionId and file path are required' }
80
+ }
81
+ // An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits
82
+ // '<sid>//server/share/...' for UNC paths); the encoder filters empty
83
+ // segments everywhere else, so an empty segment can only be the marker or
84
+ // a malformed URL — both handled here.
85
+ const unc = pathSegments[0] === ''
86
+ const tail = unc ? pathSegments.slice(1) : pathSegments
87
+ if (tail.length === 0 || tail.some(segment => segment === '')) {
88
+ return { ok: false, status: 400, message: 'sessionId and file path are required' }
89
+ }
90
+ let path: string
91
+ if (unc) {
92
+ // Rebuild the platform-neutral forward-slash form `//server/share/...`;
93
+ // requireAbsolute() resolves it to the platform's own UNC/POSIX spelling.
94
+ path = `//${tail.join('/')}`
95
+ } else if (/^[A-Za-z]:$/.test(tail[0] ?? '')) {
96
+ // A Windows drive segment ('D:') is the FIRST path segment of an encoded
97
+ // drive path. Rejoining it with a leading slash would yield '/D:/work/...'
98
+ // which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
99
+ // the html route's workspace fence would then reject every drive path.
100
+ // Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
101
+ path = tail.join('/')
102
+ } else {
103
+ path = `/${tail.join('/')}`
104
+ }
105
+ return { ok: true, ref: { sessionId, path } }
106
+ }