dsh-better-sidebar 0.10.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/README_EN.md +139 -0
  4. package/lib/client-docx.js +7649 -0
  5. package/lib/client-editor.js +32677 -0
  6. package/lib/client-pptx.js +63438 -0
  7. package/lib/client-registry.js +8021 -0
  8. package/lib/client-terminal.js +8060 -0
  9. package/lib/client-xlsx.js +359755 -0
  10. package/lib/client.js +8021 -0
  11. package/lib/index.js +2604 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-pty.d.ts +220 -0
  14. package/lib/types/browser-probe.d.ts +15 -0
  15. package/lib/types/bundle-route.d.ts +13 -0
  16. package/lib/types/client/BrowserView.d.ts +22 -0
  17. package/lib/types/client/DiffTab.d.ts +6 -0
  18. package/lib/types/client/DiffView.d.ts +51 -0
  19. package/lib/types/client/EditorHost.d.ts +10 -0
  20. package/lib/types/client/ExplorerView.d.ts +9 -0
  21. package/lib/types/client/GitView.d.ts +8 -0
  22. package/lib/types/client/OrphanedTab.d.ts +11 -0
  23. package/lib/types/client/PdfView.d.ts +6 -0
  24. package/lib/types/client/PptxView.d.ts +6 -0
  25. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  26. package/lib/types/client/SideCardSection.d.ts +27 -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 +38 -0
  30. package/lib/types/client/TerminalView.d.ts +8 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/api.d.ts +170 -0
  33. package/lib/types/client/binary-download.d.ts +12 -0
  34. package/lib/types/client/breakpoints.d.ts +12 -0
  35. package/lib/types/client/browser.d.ts +50 -0
  36. package/lib/types/client/builtins/index.d.ts +17 -0
  37. package/lib/types/client/builtins/tabs.d.ts +6 -0
  38. package/lib/types/client/builtins/viewers.d.ts +3 -0
  39. package/lib/types/client/chunk-loader.d.ts +74 -0
  40. package/lib/types/client/chunks/docx.d.ts +8 -0
  41. package/lib/types/client/chunks/editor.d.ts +9 -0
  42. package/lib/types/client/chunks/pptx.d.ts +8 -0
  43. package/lib/types/client/chunks/terminal.d.ts +9 -0
  44. package/lib/types/client/chunks/xlsx.d.ts +9 -0
  45. package/lib/types/client/cm-themes.d.ts +24 -0
  46. package/lib/types/client/conversation-draft.d.ts +14 -0
  47. package/lib/types/client/docx-view.d.ts +8 -0
  48. package/lib/types/client/editor-load.d.ts +66 -0
  49. package/lib/types/client/icons.d.ts +47 -0
  50. package/lib/types/client/image-types.d.ts +3 -0
  51. package/lib/types/client/ime-guard.d.ts +36 -0
  52. package/lib/types/client/index.d.ts +10 -0
  53. package/lib/types/client/intercept.d.ts +20 -0
  54. package/lib/types/client/lang.d.ts +12 -0
  55. package/lib/types/client/lazy-chunk.d.ts +25 -0
  56. package/lib/types/client/link-intercept.d.ts +35 -0
  57. package/lib/types/client/locales.d.ts +237 -0
  58. package/lib/types/client/office-shared.d.ts +33 -0
  59. package/lib/types/client/office-types.d.ts +36 -0
  60. package/lib/types/client/office-view.d.ts +12 -0
  61. package/lib/types/client/openpath-intercept.d.ts +41 -0
  62. package/lib/types/client/paths.d.ts +19 -0
  63. package/lib/types/client/pdf-types.d.ts +2 -0
  64. package/lib/types/client/prefs.d.ts +30 -0
  65. package/lib/types/client/produced-files.d.ts +27 -0
  66. package/lib/types/client/selection-payload.d.ts +27 -0
  67. package/lib/types/client/service.d.ts +214 -0
  68. package/lib/types/client/split-pane.d.ts +29 -0
  69. package/lib/types/client/state.d.ts +322 -0
  70. package/lib/types/client/subagent-activity.d.ts +35 -0
  71. package/lib/types/client/subagent-detect.d.ts +47 -0
  72. package/lib/types/client/subagent-jobs.d.ts +63 -0
  73. package/lib/types/client/theme.d.ts +28 -0
  74. package/lib/types/client/xlsx-to-univer.d.ts +33 -0
  75. package/lib/types/client/xlsx-view.d.ts +10 -0
  76. package/lib/types/config.d.ts +41 -0
  77. package/lib/types/context-types.d.ts +431 -0
  78. package/lib/types/fs-tree.d.ts +41 -0
  79. package/lib/types/git.d.ts +75 -0
  80. package/lib/types/html-route.d.ts +48 -0
  81. package/lib/types/index.d.ts +39 -0
  82. package/lib/types/invariant.d.ts +15 -0
  83. package/lib/types/jobs-routes.d.ts +45 -0
  84. package/lib/types/prefs-shared.d.ts +106 -0
  85. package/lib/types/pty-manager.d.ts +76 -0
  86. package/lib/types/tools.d.ts +28 -0
  87. package/lib/types/trust-fence.d.ts +24 -0
  88. package/lib/types/wire.d.ts +38 -0
  89. package/package.json +168 -0
  90. package/src/agent-pty.ts +519 -0
  91. package/src/browser-probe.ts +26 -0
  92. package/src/bundle-route.ts +130 -0
  93. package/src/client/BrowserView.tsx +253 -0
  94. package/src/client/DiffTab.tsx +110 -0
  95. package/src/client/DiffView.tsx +266 -0
  96. package/src/client/EditorHost.tsx +101 -0
  97. package/src/client/ExplorerView.tsx +283 -0
  98. package/src/client/GitView.tsx +540 -0
  99. package/src/client/OrphanedTab.tsx +27 -0
  100. package/src/client/PdfView.tsx +110 -0
  101. package/src/client/PptxView.tsx +131 -0
  102. package/src/client/SandboxStatusBar.tsx +60 -0
  103. package/src/client/SideCardSection.module.css +271 -0
  104. package/src/client/SideCardSection.tsx +426 -0
  105. package/src/client/Sidebar.tsx +861 -0
  106. package/src/client/SubagentView.module.css +547 -0
  107. package/src/client/SubagentView.tsx +866 -0
  108. package/src/client/TabBar.tsx +188 -0
  109. package/src/client/TerminalView.tsx +233 -0
  110. package/src/client/TextEditor.tsx +382 -0
  111. package/src/client/api.ts +215 -0
  112. package/src/client/binary-download.tsx +23 -0
  113. package/src/client/breakpoints.ts +51 -0
  114. package/src/client/browser.ts +119 -0
  115. package/src/client/builtins/index.ts +33 -0
  116. package/src/client/builtins/tabs.tsx +211 -0
  117. package/src/client/builtins/viewers.tsx +151 -0
  118. package/src/client/chunk-loader.ts +196 -0
  119. package/src/client/chunks/docx.tsx +8 -0
  120. package/src/client/chunks/editor.tsx +9 -0
  121. package/src/client/chunks/pptx.tsx +8 -0
  122. package/src/client/chunks/terminal.tsx +9 -0
  123. package/src/client/chunks/xlsx.tsx +9 -0
  124. package/src/client/cm-themes.ts +129 -0
  125. package/src/client/conversation-draft.ts +29 -0
  126. package/src/client/css-modules.d.ts +5 -0
  127. package/src/client/docx-view.tsx +113 -0
  128. package/src/client/editor-load.ts +92 -0
  129. package/src/client/icons.tsx +137 -0
  130. package/src/client/image-types.ts +8 -0
  131. package/src/client/ime-guard.ts +47 -0
  132. package/src/client/index.tsx +238 -0
  133. package/src/client/intercept.tsx +94 -0
  134. package/src/client/lang.ts +95 -0
  135. package/src/client/layout.css +54 -0
  136. package/src/client/lazy-chunk.tsx +89 -0
  137. package/src/client/link-intercept.ts +66 -0
  138. package/src/client/locales.ts +476 -0
  139. package/src/client/office-shared.tsx +40 -0
  140. package/src/client/office-types.ts +48 -0
  141. package/src/client/office-view.tsx +12 -0
  142. package/src/client/openpath-intercept.ts +62 -0
  143. package/src/client/paths.ts +28 -0
  144. package/src/client/pdf-types.ts +4 -0
  145. package/src/client/prefs.ts +100 -0
  146. package/src/client/produced-files.ts +87 -0
  147. package/src/client/selection-payload.ts +86 -0
  148. package/src/client/service.ts +434 -0
  149. package/src/client/sidebar.module.css +1917 -0
  150. package/src/client/split-pane.tsx +292 -0
  151. package/src/client/state.ts +1048 -0
  152. package/src/client/subagent-activity.ts +67 -0
  153. package/src/client/subagent-detect.ts +125 -0
  154. package/src/client/subagent-jobs.ts +161 -0
  155. package/src/client/theme.ts +44 -0
  156. package/src/client/xlsx-to-univer.ts +260 -0
  157. package/src/client/xlsx-view.tsx +116 -0
  158. package/src/config.ts +95 -0
  159. package/src/context-types.ts +445 -0
  160. package/src/fs-tree.ts +115 -0
  161. package/src/git.ts +236 -0
  162. package/src/html-route.ts +70 -0
  163. package/src/index.ts +897 -0
  164. package/src/invariant.ts +32 -0
  165. package/src/jobs-routes.ts +257 -0
  166. package/src/prefs-shared.ts +127 -0
  167. package/src/pty-manager.ts +199 -0
  168. package/src/tools.ts +477 -0
  169. package/src/trust-fence.ts +77 -0
  170. package/src/wire.ts +94 -0
package/src/fs-tree.ts ADDED
@@ -0,0 +1,115 @@
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
+ * reported as files without probing their target — the explorer shows what
6
+ * dirent says, keeping the read cheap for arbitrarily large levels.
7
+ */
8
+ import { opendir } from 'node:fs/promises'
9
+ import { basename, dirname, join, resolve } from 'node:path'
10
+ import { SidebarError } from './wire.ts'
11
+
12
+ /** One explorer row. */
13
+ export interface SidebarFsEntry {
14
+ name: string
15
+ path: string
16
+ isDir: boolean
17
+ hidden: boolean
18
+ }
19
+
20
+ /** One listed level. */
21
+ export interface SidebarFsListing {
22
+ path: string
23
+ entries: SidebarFsEntry[]
24
+ truncated: boolean
25
+ }
26
+
27
+ /** Directory-first, case-insensitive name ordering (VSCode explorer order). */
28
+ export function compareEntries(a: SidebarFsEntry, b: SidebarFsEntry): number {
29
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
30
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
31
+ }
32
+
33
+ /**
34
+ * List one directory level.
35
+ * @param path - absolute directory path.
36
+ * @param maxEntries - row bound of one level (extra rows flag `truncated`).
37
+ * @returns the sorted listing.
38
+ * @throws {SidebarError} fs-error when the level is unreadable or not a directory.
39
+ */
40
+ export async function listDirectory(path: string, maxEntries = 1000): Promise<SidebarFsListing> {
41
+ let level
42
+ try {
43
+ level = await opendir(path)
44
+ } catch (error) {
45
+ throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
46
+ }
47
+ const rows: SidebarFsEntry[] = []
48
+ let overflow = 0
49
+ try {
50
+ for await (const dirent of level) {
51
+ if (rows.length >= maxEntries) {
52
+ overflow += 1
53
+ continue
54
+ }
55
+ rows.push({
56
+ name: dirent.name,
57
+ // Platform join: on Windows the level path uses '\' — a hardcoded '/'
58
+ // would leak mixed separators into every row's path.
59
+ path: join(path, dirent.name),
60
+ isDir: dirent.isDirectory(),
61
+ hidden: dirent.name.startsWith('.'),
62
+ })
63
+ }
64
+ } catch (error) {
65
+ throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
66
+ }
67
+ rows.sort(compareEntries)
68
+ return { path, entries: rows, truncated: overflow > 0 }
69
+ }
70
+
71
+ /** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
72
+ export function rootLabel(path: string): string {
73
+ const base = basename(path)
74
+ return base !== '' ? base : path
75
+ }
76
+
77
+ /** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */
78
+ export function parentOf(path: string): string | undefined {
79
+ const parent = dirname(path)
80
+ return parent === path ? undefined : parent
81
+ }
82
+
83
+ /** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
84
+ export function requireAbsolute(path: string): string {
85
+ if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
86
+ throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
87
+ }
88
+ return resolve(path)
89
+ }
90
+
91
+ /**
92
+ * Whether `target` lies under `base` (or equals it), tolerant of separator
93
+ * style and — on Windows, where the filesystem is case-insensitive — of
94
+ * letter case. The media route uses this instead of a raw `startsWith` so a
95
+ * case-mismatched or mixed-separator path can never be misclassified
96
+ * (e.g. `C:\Users\Me` vs `c:/users/me/file.png`).
97
+ * @param platform - filesystem semantics; injectable so both branches are
98
+ * unit-testable on any host.
99
+ */
100
+ export function isWithin(base: string, target: string, platform: NodeJS.Platform = process.platform): boolean {
101
+ const norm = (value: string): string => value.replace(/[\\/]+/g, '/').replace(/\/$/, '')
102
+ const b = norm(base)
103
+ const t = norm(target)
104
+ if (platform === 'win32') {
105
+ const lb = b.toLowerCase()
106
+ const lt = t.toLowerCase()
107
+ return lt === lb || lt.startsWith(`${lb}/`)
108
+ }
109
+ return t === b || t.startsWith(`${b}/`)
110
+ }
111
+
112
+ /** Message text of an unknown thrown value. */
113
+ export function messageOf(error: unknown): string {
114
+ return error instanceof Error ? error.message : String(error)
115
+ }
package/src/git.ts ADDED
@@ -0,0 +1,236 @@
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
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
100
+ })
101
+ let stdout = ''
102
+ let stderr = ''
103
+ const timer = setTimeout(() => {
104
+ child.kill('SIGKILL')
105
+ reject(new GitCommandError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'git-error', args.join(' ')))
106
+ }, timeoutMs)
107
+ child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
108
+ child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
109
+ child.on('error', (error) => {
110
+ clearTimeout(timer)
111
+ reject(new GitCommandError(`cannot run git: ${error.message}`, 'git-error', args.join(' ')))
112
+ })
113
+ child.on('close', (code) => {
114
+ clearTimeout(timer)
115
+ if (code === 0) {
116
+ resolvePromise(stdout)
117
+ } else {
118
+ reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, 'git-error', args.join(' ')))
119
+ }
120
+ })
121
+ })
122
+ }
123
+
124
+ /** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
125
+ export async function isGitRepo(cwd: string): Promise<boolean> {
126
+ try {
127
+ const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'])
128
+ return out.trim() === 'true'
129
+ } catch {
130
+ return false
131
+ }
132
+ }
133
+
134
+ /** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
135
+ export async function repoRoot(cwd: string): Promise<string> {
136
+ const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
137
+ return out.trim()
138
+ }
139
+
140
+ /** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
141
+ export async function currentBranch(cwd: string): Promise<string> {
142
+ const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
143
+ return out.trim()
144
+ }
145
+
146
+ /** Working-tree status (untracked included). */
147
+ export async function status(cwd: string): Promise<GitStatusResult> {
148
+ const repo = await isGitRepo(cwd)
149
+ if (!repo) return { isRepo: false, entries: [] }
150
+ const [branch, raw] = await Promise.all([
151
+ currentBranch(cwd).catch(() => 'HEAD'),
152
+ runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']),
153
+ ])
154
+ return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
155
+ }
156
+
157
+ /** Diff text of the worktree (unstaged) or the index (staged). */
158
+ export async function diff(cwd: string, path: string | undefined, staged: boolean): Promise<string> {
159
+ const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
160
+ if (staged) args.push('--cached')
161
+ if (path !== undefined) args.push('--', path)
162
+ return runGit(cwd, args)
163
+ }
164
+
165
+ /** Stage paths (all when path is undefined). */
166
+ export async function stage(cwd: string, path: string | undefined): Promise<void> {
167
+ await runGit(cwd, ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
168
+ }
169
+
170
+ /** Unstage paths (all when path is undefined). */
171
+ export async function unstage(cwd: string, path: string | undefined): Promise<void> {
172
+ await runGit(cwd, ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
173
+ }
174
+
175
+ /** Commit the staged changes with a message (global identity untouched). */
176
+ export async function commit(cwd: string, message: string): Promise<void> {
177
+ await runGit(cwd, ['commit', '-m', message])
178
+ }
179
+
180
+ /** Branch names (current first). */
181
+ export async function branches(cwd: string): Promise<{ current: string; names: string[] }> {
182
+ const [current, raw] = await Promise.all([
183
+ currentBranch(cwd).catch(() => 'HEAD'),
184
+ runGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
185
+ ])
186
+ const names = raw.split('\n').filter(line => line !== '')
187
+ return { current, names: names.includes(current) ? names : [current, ...names] }
188
+ }
189
+
190
+ /** Switch to an existing branch. */
191
+ export async function checkout(cwd: string, branch: string): Promise<void> {
192
+ await runGit(cwd, ['checkout', branch])
193
+ }
194
+
195
+ /** Recent commit history (newest first), lazily pageable via skip/count. */
196
+ export async function log(cwd: string, count = 30, skip = 0): Promise<GitLogEntry[]> {
197
+ const raw = await runGit(cwd, [
198
+ 'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
199
+ '--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
200
+ ])
201
+ return parseLogLines(raw)
202
+ }
203
+
204
+ /**
205
+ * Content of a file at a revision (`git show <rev>:<path>`), or null when the
206
+ * revision has no such path (a new/untracked file has no HEAD side).
207
+ */
208
+ export async function show(cwd: string, rev: string, path: string): Promise<string | null> {
209
+ try {
210
+ return await runGit(cwd, ['show', `${rev}:${path}`])
211
+ } catch {
212
+ return null
213
+ }
214
+ }
215
+
216
+ /** Full patch text of one commit (`git show` with the commit header suppressed).
217
+ * Merge commits show their diff against the first parent (`-m --first-parent`
218
+ * is a no-op for regular commits), so a history click always has content. */
219
+ export async function commitDiff(cwd: string, hash: string): Promise<string> {
220
+ return runGit(cwd, ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
221
+ }
222
+
223
+ /** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
224
+ export async function discard(cwd: string, path: string): Promise<void> {
225
+ await runGit(cwd, ['checkout', '--', path])
226
+ }
227
+
228
+ /** Revert one commit onto the current branch with an auto-generated message. */
229
+ export async function revert(cwd: string, hash: string): Promise<void> {
230
+ await runGit(cwd, ['revert', '--no-edit', hash])
231
+ }
232
+
233
+ /** Cherry-pick one commit onto the current branch. */
234
+ export async function cherryPick(cwd: string, hash: string): Promise<void> {
235
+ await runGit(cwd, ['cherry-pick', hash])
236
+ }
@@ -0,0 +1,70 @@
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
+ *
17
+ * This module is intentionally dependency-free (no node imports, no wire
18
+ * helpers) so the client bundle can import `encodeHtmlUrl` without tripping
19
+ * the build-time purity gate; the host converts decode failures into
20
+ * SidebarError responses at the route boundary.
21
+ */
22
+
23
+ /** One decoded route reference. */
24
+ export interface HtmlRouteRef {
25
+ sessionId: string
26
+ /** Absolute file path (leading slash; Windows drives keep their colon). */
27
+ path: string
28
+ }
29
+
30
+ /** Decode outcome: the reference, or a client-error description. */
31
+ export type HtmlDecodeResult =
32
+ | { ok: true; ref: HtmlRouteRef }
33
+ | { ok: false; status: 400 | 404; message: string }
34
+
35
+ /** The route prefix both encoders/decoders agree on. */
36
+ export const HTML_ROUTE_PREFIX = '/sidebar/html/'
37
+
38
+ /** Build the route URL for one absolute file path (client + tests). */
39
+ export function encodeHtmlUrl(sessionId: string, path: string): string {
40
+ const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
41
+ return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${segments.map(encodeURIComponent).join('/')}`
42
+ }
43
+
44
+ /**
45
+ * Decode a route pathname into the session + absolute file path. Rejects
46
+ * a wrong prefix (404), an empty or double-slash path, malformed percent
47
+ * encoding, and a missing sessionId or file path (400). The caller still
48
+ * must bound the decoded path with requireAbsolute + isWithin(cwd) — a
49
+ * decoded `..` segment resolves outside the cwd and is refused there.
50
+ */
51
+ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
52
+ if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
53
+ return { ok: false, status: 404, message: 'not an html route' }
54
+ }
55
+ const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
56
+ if (rest === '' || rest.includes('//')) {
57
+ return { ok: false, status: 400, message: 'invalid html route path' }
58
+ }
59
+ let segments: string[]
60
+ try {
61
+ segments = rest.split('/').map(segment => decodeURIComponent(segment))
62
+ } catch {
63
+ return { ok: false, status: 400, message: 'malformed URL encoding' }
64
+ }
65
+ const [sessionId, ...pathSegments] = segments
66
+ if (sessionId === undefined || sessionId === '' || pathSegments.length === 0 || pathSegments.some(segment => segment === '')) {
67
+ return { ok: false, status: 400, message: 'sessionId and file path are required' }
68
+ }
69
+ return { ok: true, ref: { sessionId, path: `/${pathSegments.join('/')}` } }
70
+ }