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
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The real diff surface for the git panel: parses the host's unified diff
3
+ * text (`git diff` / `git show`) and renders it VSCode-style — per-file
4
+ * sections with hunks (`@@ -a,b +c,d @@` headers), old/new line-number
5
+ * gutters, and aligned context / deleted / added rows colored through the
6
+ * DSH tokens. Untracked files produce no `git diff` output, so the caller
7
+ * can pass the file content to render as a full-file addition instead.
8
+ *
9
+ * The parser is a pure function (`parseUnifiedDiff`) so the interesting
10
+ * cases are unit-tested without a DOM.
11
+ */
12
+ import { useMemo, useState, type ReactNode } from 'react'
13
+ import clsx from 'clsx'
14
+ import { t } from './locales.ts'
15
+ import css from './sidebar.module.css'
16
+
17
+ /** One rendered diff line. */
18
+ export interface DiffLine {
19
+ kind: 'ctx' | 'del' | 'add' | 'meta'
20
+ /** The line content without its diff marker ('' for the no-newline marker). */
21
+ text: string
22
+ /** Old-side line number (null for pure additions / metadata). */
23
+ oldNum: number | null
24
+ /** New-side line number (null for pure deletions / metadata). */
25
+ newNum: number | null
26
+ }
27
+
28
+ /** One parsed hunk. */
29
+ export interface DiffHunk {
30
+ /** The old-side start line (`-a[,b]`). */
31
+ oldStart: number
32
+ /** The new-side start line (`+c[,d]`). */
33
+ newStart: number
34
+ /** The section text after the trailing `@@` (may be empty). */
35
+ header: string
36
+ lines: DiffLine[]
37
+ }
38
+
39
+ /** One parsed file section of a unified diff. */
40
+ export interface DiffFile {
41
+ /** The `---` path verbatim ('/dev/null' for a new file). */
42
+ oldPath: string
43
+ /** The `+++` path verbatim ('/dev/null' for a deleted file). */
44
+ newPath: string
45
+ /** The file changed with binary content: no hunks to draw. */
46
+ binary: boolean
47
+ hunks: DiffHunk[]
48
+ }
49
+
50
+ /** The parsed unified diff. */
51
+ export interface ParsedDiff {
52
+ files: DiffFile[]
53
+ }
54
+
55
+ /** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */
56
+ function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null {
57
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line)
58
+ if (match === null) return null
59
+ return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' }
60
+ }
61
+
62
+ /**
63
+ * Parse `git diff --no-color` output into file sections and hunks. Rows
64
+ * outside a file section (leading noise) and metadata rows between the
65
+ * `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode
66
+ * changes, rename/similarity lines) are skipped; a section that never
67
+ * reaches a hunk (a mode/rename-only change) stays hunkless so the caller
68
+ * can still draw its path.
69
+ */
70
+ export function parseUnifiedDiff(text: string): ParsedDiff {
71
+ const files: DiffFile[] = []
72
+ let current: DiffFile | null = null
73
+ let inHunk = false
74
+ let hunk: DiffHunk | null = null
75
+ let oldNum = 0
76
+ let newNum = 0
77
+ const flushHunk = (): void => {
78
+ if (current !== null && hunk !== null) current.hunks.push(hunk)
79
+ hunk = null
80
+ inHunk = false
81
+ }
82
+ for (const raw of text.split('\n')) {
83
+ if (raw.startsWith('diff --git ')) {
84
+ flushHunk()
85
+ current = { oldPath: '', newPath: '', binary: false, hunks: [] }
86
+ files.push(current)
87
+ continue
88
+ }
89
+ if (current === null) continue
90
+ if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
91
+ flushHunk()
92
+ current.binary = true
93
+ continue
94
+ }
95
+ if (raw.startsWith('--- ')) {
96
+ flushHunk()
97
+ current.oldPath = raw.slice(4)
98
+ continue
99
+ }
100
+ if (raw.startsWith('+++ ')) {
101
+ current.newPath = raw.slice(4)
102
+ continue
103
+ }
104
+ const header = parseHunkHeader(raw)
105
+ if (header !== null) {
106
+ flushHunk()
107
+ hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] }
108
+ oldNum = header.oldStart
109
+ newNum = header.newStart
110
+ inHunk = true
111
+ continue
112
+ }
113
+ if (!inHunk || hunk === null) continue
114
+ const marker = raw[0]
115
+ if (marker === '\\') {
116
+ // ``: metadata attached to the previous row.
117
+ hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null })
118
+ continue
119
+ }
120
+ if (marker === ' ') {
121
+ hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum })
122
+ oldNum += 1
123
+ newNum += 1
124
+ } else if (marker === '-') {
125
+ hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null })
126
+ oldNum += 1
127
+ } else if (marker === '+') {
128
+ hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum })
129
+ newNum += 1
130
+ } else {
131
+ // Not a diff line (a hunk can never contain one): stop the hunk.
132
+ flushHunk()
133
+ }
134
+ }
135
+ flushHunk()
136
+ return { files }
137
+ }
138
+
139
+ /** Build the untracked-file shape: one file, one hunk of pure additions. */
140
+ function untrackedFile(path: string, content: string): DiffFile {
141
+ const lines: DiffLine[] = []
142
+ const body = content.endsWith('\n') ? content.slice(0, -1) : content
143
+ if (body !== '') {
144
+ let num = 1
145
+ for (const line of body.split('\n')) {
146
+ lines.push({ kind: 'add', text: line, oldNum: null, newNum: num })
147
+ num += 1
148
+ }
149
+ }
150
+ return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
151
+ }
152
+
153
+ /** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
154
+ function displayPath(path: string): string {
155
+ if (path === '/dev/null') return path
156
+ if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
157
+ return path
158
+ }
159
+
160
+ /** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
161
+ function fileTag(file: DiffFile): string | null {
162
+ if (file.binary) return t('diffBinary')
163
+ if (file.oldPath === '/dev/null') return t('diffAdded')
164
+ if (file.newPath === '/dev/null') return t('diffDeleted')
165
+ const oldPath = displayPath(file.oldPath)
166
+ const newPath = displayPath(file.newPath)
167
+ if (oldPath !== newPath) return t('diffRenamed')
168
+ return null
169
+ }
170
+
171
+ /** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
172
+ const MAX_DIFF_ROWS = 500
173
+
174
+ export interface DiffViewProps {
175
+ /** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
176
+ diff: string
177
+ /** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
178
+ untrackedPath?: string
179
+ untrackedContent?: string
180
+ }
181
+
182
+ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) {
183
+ const parsed = useMemo<ParsedDiff>(() => {
184
+ if (untrackedPath !== undefined) {
185
+ return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
186
+ }
187
+ return parseUnifiedDiff(diff)
188
+ }, [diff, untrackedPath, untrackedContent])
189
+ const [expanded, setExpanded] = useState(false)
190
+
191
+ // Flatten into display rows so the cap can slice a single list.
192
+ const rows = useMemo(() => {
193
+ const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
194
+ parsed.files.forEach((file, fileIndex) => {
195
+ out.push({ key: `f${fileIndex}`, file, type: 'path' })
196
+ if (file.binary) return
197
+ file.hunks.forEach((hunk, hunkIndex) => {
198
+ out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk })
199
+ hunk.lines.forEach((line, lineIndex) => {
200
+ out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line })
201
+ })
202
+ })
203
+ })
204
+ return out
205
+ }, [parsed])
206
+
207
+ const hidden = rows.length - MAX_DIFF_ROWS
208
+ const capped = hidden > 0 && !expanded
209
+ const headLines = Math.ceil(MAX_DIFF_ROWS / 2)
210
+ const tailLines = MAX_DIFF_ROWS - headLines
211
+ const head = capped ? rows.slice(0, headLines) : rows
212
+ const tail = capped ? rows.slice(rows.length - tailLines) : []
213
+
214
+ if (rows.length === 0) return null
215
+
216
+ const renderRow = (row: (typeof rows)[number]): ReactNode => {
217
+ if (row.type === 'path') {
218
+ const tag = fileTag(row.file)
219
+ const from = displayPath(row.file.oldPath)
220
+ const to = displayPath(row.file.newPath)
221
+ return (
222
+ <div key={row.key} className={css.gitDiffFile}>
223
+ <span className={css.gitDiffFilePath}>{to}</span>
224
+ {from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
225
+ {tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
226
+ </div>
227
+ )
228
+ }
229
+ if (row.type === 'hunk') {
230
+ const hunk = row.hunk!
231
+ return (
232
+ <div key={row.key} className={css.gitDiffHunk}>
233
+ <span className={css.gitDiffHunkHeader}>@@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@</span>
234
+ {hunk.header !== '' && <span className={css.gitDiffHunkSection}>{hunk.header}</span>}
235
+ </div>
236
+ )
237
+ }
238
+ const line = row.line!
239
+ const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
240
+ return (
241
+ <div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
242
+ {line.kind === 'meta'
243
+ ? <span className={css.gitDiffMetaText}>{line.text}</span>
244
+ : (
245
+ <>
246
+ <span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
247
+ <span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
248
+ <span className={css.gitDiffCode}>{line.text}</span>
249
+ </>
250
+ )}
251
+ </div>
252
+ )
253
+ }
254
+
255
+ return (
256
+ <div className={css.gitDiff}>
257
+ {head.map(renderRow)}
258
+ {hidden > 0 && (
259
+ <button type="button" className={css.gitDiffExpand} aria-expanded={expanded} onClick={() => { setExpanded(value => !value) }}>
260
+ {expanded ? t('diffCollapse') : t('diffExpand', { count: hidden })}
261
+ </button>
262
+ )}
263
+ {tail.map(renderRow)}
264
+ </div>
265
+ )
266
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The editor tab host: resolves a file's previewer through the sidebar
3
+ * registry (`matchFileViewer`), fetches bytes per the matched viewer's
4
+ * fetch strategy, and renders its component — or the shared download pane
5
+ * when nothing can render the file. The header shows the file title; the
6
+ * editable code/markdown viewers render their own toolbar below it.
7
+ *
8
+ * The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
9
+ * editor-load.ts); this component only wires it to the host APIs.
10
+ */
11
+ import { useEffect, useState } from 'react'
12
+ import { createElement } from 'react'
13
+ import type { Context } from '../context-types.ts'
14
+ import { api, mediaUrl, type SessionScope } from './api.ts'
15
+ import { BinaryDownload } from './binary-download.tsx'
16
+ import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
17
+ import { t } from './locales.ts'
18
+ import type { FileViewerDescriptor } from './service.ts'
19
+ import type { SidebarStore } from './state.ts'
20
+ import css from './sidebar.module.css'
21
+
22
+ type EditorLoad =
23
+ | { status: 'loading' }
24
+ | { status: 'error'; message: string }
25
+ | { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
26
+ | { status: 'binary' }
27
+
28
+ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
29
+ const { ctx, store, scope, path, title } = props
30
+ const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
31
+
32
+ useEffect(() => {
33
+ let cancelled = false
34
+ setLoad({ status: 'loading' })
35
+ const mediaUrlOf = (): string => mediaUrl(scope, path)
36
+ const apply = (action: EditorLoadAction): void => {
37
+ if (cancelled) return
38
+ switch (action.kind) {
39
+ case 'binary':
40
+ setLoad({ status: 'binary' })
41
+ return
42
+ case 'render':
43
+ setLoad({
44
+ status: 'ready',
45
+ viewer: action.viewer,
46
+ content: action.content,
47
+ truncated: action.truncated,
48
+ mediaUrl: action.mediaUrl,
49
+ customData: action.customData,
50
+ })
51
+ return
52
+ case 'customLoad':
53
+ void action.viewer.load?.(path, scope).then((data) => {
54
+ if (cancelled) return
55
+ setLoad({ status: 'ready', viewer: action.viewer, customData: data })
56
+ }).catch((error: unknown) => {
57
+ if (cancelled) return
58
+ setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
59
+ })
60
+ return
61
+ case 'fetchFsRead':
62
+ api.fsRead(scope, path).then((result) => {
63
+ if (cancelled) return
64
+ // Binary reads carry the head bytes for the detect re-match.
65
+ const outcome = planFsReadOutcome(action.viewer, {
66
+ binary: result.kind === 'binary',
67
+ content: result.kind === 'text' ? result.content : '',
68
+ truncated: result.truncated,
69
+ head: result.kind === 'binary' ? result.head : undefined,
70
+ }, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf)
71
+ apply(outcome)
72
+ }).catch((error: unknown) => {
73
+ if (cancelled) return
74
+ setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
75
+ })
76
+ return
77
+ }
78
+ }
79
+ apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
80
+ return () => { cancelled = true }
81
+ }, [scope.sessionId, scope.cwd, path, ctx])
82
+
83
+ return (
84
+ <div className={css.editor}>
85
+ <div className={css.editorHeader}>
86
+ <span className={css.editorTitle} title={path}>{title}</span>
87
+ </div>
88
+ {load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
89
+ {load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
90
+ {load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
91
+ {load.status === 'ready' && createElement(load.viewer.component, {
92
+ ctx, store, scope, path, title,
93
+ viewerId: load.viewer.id,
94
+ content: load.content,
95
+ truncated: load.truncated,
96
+ mediaUrl: load.mediaUrl,
97
+ customData: load.customData,
98
+ })}
99
+ </div>
100
+ )
101
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The file explorer: a lazy VSCode-style tree rooted at the session's
3
+ * working directory. Levels load on expansion (one API call per directory),
4
+ * directories sort first, hidden entries render dimmed, and the expansion
5
+ * set lives in the per-session state. Clicking a file opens an editor tab.
6
+ *
7
+ * Row actions: hovering a row reveals an @-reference button on the far
8
+ * right (appends `@<relative path>` to the composer draft), and right-click
9
+ * opens a context menu to copy the relative or absolute path (with a brief
10
+ * "copied" label replacing the button after a successful write); file rows
11
+ * also offer a download action (the host serves raw bytes, binary-safe).
12
+ */
13
+ import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
14
+ import clsx from 'clsx'
15
+ import {
16
+ IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
17
+ IconRefreshOutline16, Menu, writeClipboard,
18
+ } from '@deepseek-ai/dsh-client-ui-primitives'
19
+ import { api, downloadUrl, type FsEntry } from './api.ts'
20
+ import { relativeTo } from './paths.ts'
21
+ import { t } from './locales.ts'
22
+ import css from './sidebar.module.css'
23
+
24
+ interface LevelData {
25
+ entries?: FsEntry[]
26
+ error?: string
27
+ }
28
+
29
+ /** Root label: the last path segment (mirror of the host rootLabel). */
30
+ function baseName(path: string): string {
31
+ const trimmed = path.replace(/[\\/]+$/, '')
32
+ const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
33
+ return at === -1 ? trimmed : trimmed.slice(at + 1)
34
+ }
35
+
36
+ /** How long the row's "copied" label stays after a successful write. */
37
+ const COPIED_MS = 1200
38
+
39
+ export function ExplorerView(props: {
40
+ sessionId: string
41
+ cwd: string | undefined
42
+ expanded: string[]
43
+ onToggle: (path: string) => void
44
+ onOpenFile: (path: string) => void
45
+ /** Insert `@<relative path>` into the composer draft. */
46
+ onReferenceFile: (path: string) => void
47
+ }) {
48
+ const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props
49
+ const [data, setData] = useState<Record<string, LevelData>>({})
50
+ const dataRef = useRef(data)
51
+ const [refreshTick, setRefreshTick] = useState(0)
52
+ /** The row whose path was just copied ("copied" label replaces its button). */
53
+ const [copiedPath, setCopiedPath] = useState<string | null>(null)
54
+ /** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
55
+ const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
56
+
57
+ const storeLevel = useCallback((path: string, level: LevelData) => {
58
+ dataRef.current = { ...dataRef.current, [path]: level }
59
+ setData(dataRef.current)
60
+ }, [])
61
+
62
+ const loadDir = useCallback((dir: string) => {
63
+ if (dataRef.current[dir] !== undefined) return
64
+ storeLevel(dir, {})
65
+ api.fsTree({ sessionId, cwd }, dir).then((listing) => {
66
+ storeLevel(dir, { entries: listing.entries })
67
+ }).catch((error: unknown) => {
68
+ storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
69
+ })
70
+ }, [sessionId, cwd, storeLevel])
71
+
72
+ useEffect(() => {
73
+ // Load the visible set; already-loaded levels (kept in the cache) are
74
+ // not refetched. Only the refresh button wipes the cache.
75
+ const root = cwd
76
+ if (root === undefined) return
77
+ loadDir(root)
78
+ for (const dir of expanded) loadDir(dir)
79
+ }, [cwd, expanded, refreshTick, loadDir])
80
+
81
+ /** Copy `text`; on success flip the row's copied label for a moment. */
82
+ const copyPath = useCallback((text: string, path: string): void => {
83
+ void writeClipboard(text).then((ok) => {
84
+ if (!ok) return
85
+ setCopiedPath(path)
86
+ window.setTimeout(() => {
87
+ setCopiedPath(current => current === path ? null : current)
88
+ }, COPIED_MS)
89
+ })
90
+ }, [])
91
+
92
+ /** The row's trailing actions: the @-reference button, or the copied label. */
93
+ const rowActions = (entry: FsEntry): ReactNode => {
94
+ if (copiedPath === entry.path) {
95
+ return <span className={css.explorerCopied}>{t('copied')}</span>
96
+ }
97
+ return (
98
+ <button
99
+ type="button"
100
+ className={css.explorerRef}
101
+ aria-label={t('referenceFile')}
102
+ title={t('referenceFile')}
103
+ onClick={(event) => {
104
+ event.stopPropagation()
105
+ onReferenceFile(entry.path)
106
+ }}
107
+ >
108
+ {t('referenceFile')}
109
+ </button>
110
+ )
111
+ }
112
+
113
+ const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => {
114
+ event.preventDefault()
115
+ event.stopPropagation()
116
+ setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
117
+ }
118
+
119
+ /** Download a file through the host route (raw bytes, binary-safe). */
120
+ const downloadFile = (path: string): void => {
121
+ const url = downloadUrl({ sessionId, cwd }, path)
122
+ const anchor = document.createElement('a')
123
+ anchor.href = url
124
+ anchor.style.display = 'none'
125
+ document.body.appendChild(anchor)
126
+ anchor.click()
127
+ anchor.remove()
128
+ }
129
+
130
+ const root = cwd
131
+
132
+ const renderLevel = (dir: string, depth: number): ReactNode => {
133
+ const level = data[dir]
134
+ if (level === undefined) {
135
+ return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
136
+ }
137
+ if (level.error !== undefined) {
138
+ return (
139
+ <div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
140
+ {level.error}
141
+ </div>
142
+ )
143
+ }
144
+ const entries = level.entries ?? []
145
+ return entries.map(entry => {
146
+ if (entry.isDir) {
147
+ const isOpen = expanded.includes(entry.path)
148
+ return (
149
+ <div key={entry.path}>
150
+ <div
151
+ role="button"
152
+ tabIndex={0}
153
+ className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
154
+ style={{ paddingLeft: depth * 22 + 6 }}
155
+ onClick={() => { onToggle(entry.path) }}
156
+ onKeyDown={(event) => {
157
+ if (event.key === 'Enter' || event.key === ' ') {
158
+ event.preventDefault()
159
+ onToggle(entry.path)
160
+ }
161
+ }}
162
+ onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
163
+ >
164
+ {isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
165
+ <span className={css.explorerName}>{entry.name}</span>
166
+ {rowActions(entry)}
167
+ </div>
168
+ {isOpen && renderLevel(entry.path, depth + 1)}
169
+ </div>
170
+ )
171
+ }
172
+ return (
173
+ <div
174
+ key={entry.path}
175
+ role="button"
176
+ tabIndex={0}
177
+ className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
178
+ style={{ paddingLeft: depth * 22 + 6 }}
179
+ title={entry.path}
180
+ onClick={() => { onOpenFile(entry.path) }}
181
+ onKeyDown={(event) => {
182
+ if (event.key === 'Enter' || event.key === ' ') {
183
+ event.preventDefault()
184
+ onOpenFile(entry.path)
185
+ }
186
+ }}
187
+ onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
188
+ >
189
+ <IconCodeOutline16 size={14} />
190
+ <span className={css.explorerName}>{entry.name}</span>
191
+ {rowActions(entry)}
192
+ </div>
193
+ )
194
+ })
195
+ }
196
+
197
+ return (
198
+ <div className={css.explorer}>
199
+ <div className={css.explorerHeader}>
200
+ <span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span>
201
+ <button
202
+ type="button"
203
+ className={css.iconButton}
204
+ aria-label={t('refresh')}
205
+ title={t('refresh')}
206
+ onClick={() => {
207
+ dataRef.current = {}
208
+ setData({})
209
+ setRefreshTick(tick => tick + 1)
210
+ }}
211
+ >
212
+ <IconRefreshOutline16 />
213
+ </button>
214
+ </div>
215
+ <div className={css.explorerBody}>
216
+ {root === undefined ? (
217
+ <div className={css.explorerEmpty}>{t('noSession')}</div>
218
+ ) : (
219
+ <>
220
+ <div
221
+ className={css.explorerRow}
222
+ style={{ paddingLeft: 6 }}
223
+ onContextMenu={(event) => { openRowMenu(event, root, true) }}
224
+ >
225
+ <IconFolderOpen16 size={14} />
226
+ <span className={css.explorerName}>{baseName(root)}</span>
227
+ {copiedPath === root
228
+ ? <span className={css.explorerCopied}>{t('copied')}</span>
229
+ : (
230
+ <button
231
+ type="button"
232
+ className={css.explorerRef}
233
+ aria-label={t('referenceFile')}
234
+ title={t('referenceFile')}
235
+ onClick={(event) => {
236
+ event.stopPropagation()
237
+ onReferenceFile(root)
238
+ }}
239
+ >
240
+ {t('referenceFile')}
241
+ </button>
242
+ )}
243
+ </div>
244
+ {data[root] !== undefined && renderLevel(root, 1)}
245
+ </>
246
+ )}
247
+ </div>
248
+ {/*
249
+ The one shared context menu, positioned at the right-click cursor
250
+ (portal so the explorer's overflow clip cannot crop it).
251
+ */}
252
+ <Menu
253
+ open={rowMenu !== null}
254
+ onClose={() => { setRowMenu(null) }}
255
+ items={[
256
+ // Download applies to files only (the host route refuses directories).
257
+ ...(rowMenu?.isDir === false
258
+ ? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
259
+ : []),
260
+ { id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
261
+ { id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
262
+ ]}
263
+ onSelect={(id) => {
264
+ const target = rowMenu
265
+ if (target === null) return
266
+ setRowMenu(null)
267
+ if (id === 'download') {
268
+ downloadFile(target.path)
269
+ return
270
+ }
271
+ copyPath(
272
+ id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
273
+ target.path,
274
+ )
275
+ }}
276
+ portal
277
+ align="start"
278
+ getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
279
+ anchor={<span />}
280
+ />
281
+ </div>
282
+ )
283
+ }