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,382 @@
1
+ /**
2
+ * The code/markdown file viewer: a CodeMirror 6 editor with line wrapping,
3
+ * syntax highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S
4
+ * save, and a preview/edit toggle for markdown files. Registered as the
5
+ * `code` (catch-all) and `markdown` built-in viewers; the editor tab host
6
+ * fetches the content through the fsRead strategy and passes it in props,
7
+ * so this component never fetches or dispatches — it only edits.
8
+ *
9
+ * The toolbar (mode toggle / dirty dot / save / status) renders as its own
10
+ * row below the host's title bar, VSCode-style.
11
+ */
12
+ import { useEffect, useRef, useState } from 'react'
13
+ import { createPortal } from 'react-dom'
14
+ import clsx from 'clsx'
15
+ import { EditorState } from '@codemirror/state'
16
+ import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
17
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
18
+ import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
19
+ import { api, htmlUrl } from './api.ts'
20
+ import { languageForPath } from './lang.ts'
21
+ import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
22
+ import { isDarkScheme, subscribeColorScheme } from './theme.ts'
23
+ import { SandboxStatusBar } from './SandboxStatusBar.tsx'
24
+ import { appendToDraft } from './conversation-draft.ts'
25
+ import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
26
+ import { t } from './locales.ts'
27
+ import type { FileViewerProps } from './service.ts'
28
+ import css from './sidebar.module.css'
29
+
30
+ /** Previewable files (rendered output vs source editing). */
31
+ type ViewMode = 'preview' | 'edit'
32
+
33
+ /** The floating "add to conversation" action: payload + viewport anchor. */
34
+ interface SelectionPopup {
35
+ insert: string
36
+ left: number
37
+ top: number
38
+ }
39
+
40
+ /**
41
+ * The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
42
+ * preview must stay in an opaque origin — with the route's own origin it
43
+ * could read session data) and NO allow-top-navigation (a previewed page
44
+ * must not hijack the GUI). The user can disable the sandbox per-feature
45
+ * in the side card settings (warned); the toggle below reflects it.
46
+ */
47
+ export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
48
+
49
+ export function TextEditor(props: FileViewerProps) {
50
+ const { ctx, scope, path, viewerId, content, truncated } = props
51
+ const [mode, setMode] = useState<ViewMode>('preview')
52
+ /** The editor's current text (null while clean); preview renders this. */
53
+ const [draft, setDraft] = useState<string | null>(null)
54
+ const [dirty, setDirty] = useState(false)
55
+ const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
56
+ const hostRef = useRef<HTMLDivElement>(null)
57
+ const viewRef = useRef<CodeMirrorView | null>(null)
58
+ const savingRef = useRef(false)
59
+ /** The theme compartment of the current view (reconfigured on scheme flip). */
60
+ const themeCompRef = useRef<CmThemeCompartment | null>(null)
61
+ /** The app's resolved color scheme; the editor re-themes in place on flips. */
62
+ const [dark, setDark] = useState(() => isDarkScheme())
63
+ /** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
64
+ const [popup, setPopup] = useState<SelectionPopup | null>(null)
65
+ /** Live mirror of the popup state for click-time reads (no re-render race). */
66
+ const popupRef = useRef<SelectionPopup | null>(null)
67
+ /** The markdown preview container (selection-containment + line lookup). */
68
+ const mdRef = useRef<HTMLDivElement>(null)
69
+
70
+ const hidePopup = (): void => {
71
+ popupRef.current = null
72
+ setPopup(null)
73
+ }
74
+
75
+ /** Anchor the popup above the selection center; clamp inside the viewport. */
76
+ const showPopup = (insert: string, left: number, top: number): void => {
77
+ const next: SelectionPopup = {
78
+ insert,
79
+ left: Math.min(Math.max(left, 80), window.innerWidth - 80),
80
+ top,
81
+ }
82
+ popupRef.current = next
83
+ setPopup(next)
84
+ }
85
+
86
+ /** The popup button's click: insert the stored payload into the draft. */
87
+ const commitPopup = (): void => {
88
+ const current = popupRef.current
89
+ if (current === null) return
90
+ appendToDraft(ctx, scope.sessionId, current.insert)
91
+ hidePopup()
92
+ }
93
+
94
+ useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
95
+
96
+ // A new file (tab switch) starts clean: fresh preview mode, no draft.
97
+ useEffect(() => {
98
+ setMode('preview')
99
+ setDraft(null)
100
+ setDirty(false)
101
+ setSaveState('idle')
102
+ hidePopup()
103
+ }, [content])
104
+
105
+ // Create the CodeMirror editor once the content is loaded. The view owns
106
+ // the document; React only tracks dirty/draft state through the update
107
+ // listener. For markdown the view stays mounted while previewing (hidden),
108
+ // so unsaved edits survive the preview/edit toggle. The theme + syntax
109
+ // colors live in a compartment so a scheme flip reconfigures only that
110
+ // part — the document, undo history and scroll position survive.
111
+ useEffect(() => {
112
+ if (content === undefined) return
113
+ const host = hostRef.current
114
+ if (host === null) return
115
+ const language = languageForPath(path)
116
+ const themeComp = new CmThemeCompartment()
117
+ themeCompRef.current = themeComp
118
+ const state = EditorState.create({
119
+ doc: content,
120
+ extensions: [
121
+ CodeMirrorView.lineWrapping,
122
+ lineNumbers(),
123
+ history(),
124
+ EditorState.tabSize.of(2),
125
+ CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
126
+ cmSurfaceTheme,
127
+ themeComp.of(dark),
128
+ ...(language !== null ? [language] : []),
129
+ CodeMirrorView.updateListener.of((update) => {
130
+ if (update.docChanged) {
131
+ setDraft(update.state.doc.toString())
132
+ setDirty(true)
133
+ }
134
+ }),
135
+ keymap.of([
136
+ {
137
+ key: 'Mod-s',
138
+ preventDefault: true,
139
+ run: () => { save(); return true },
140
+ },
141
+ ...defaultKeymap,
142
+ ...historyKeymap,
143
+ ]),
144
+ // Selection popup (the catch-all code viewer only): a non-empty
145
+ // selection anchors the floating "add to conversation" button above
146
+ // its head. Scrolling (geometry/viewport change) or losing focus
147
+ // hides it; typing collapses the selection and hides it too.
148
+ ...(viewerId === 'code' ? [
149
+ CodeMirrorView.updateListener.of((update) => {
150
+ if (update.geometryChanged || update.viewportChanged) {
151
+ hidePopup()
152
+ return
153
+ }
154
+ if (!update.view.hasFocus) {
155
+ hidePopup()
156
+ return
157
+ }
158
+ if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
159
+ const sel = update.state.selection.main
160
+ if (sel.empty) {
161
+ hidePopup()
162
+ return
163
+ }
164
+ const text = update.state.sliceDoc(sel.from, sel.to)
165
+ if (text.trim() === '') {
166
+ hidePopup()
167
+ return
168
+ }
169
+ // Page coordinates (the document root may scroll); the popup is
170
+ // position:fixed, so convert to viewport coordinates.
171
+ const rect = update.view.coordsAtPos(sel.head)
172
+ if (rect === null) {
173
+ hidePopup()
174
+ return
175
+ }
176
+ const doc = update.state.doc
177
+ showPopup(
178
+ buildSelectionInsert(path, scope.cwd, {
179
+ start: doc.lineAt(sel.from).number,
180
+ end: doc.lineAt(sel.to).number,
181
+ }, text),
182
+ rect.left - window.scrollX + (rect.right - rect.left) / 2,
183
+ rect.top - window.scrollY,
184
+ )
185
+ }),
186
+ ] : []),
187
+ ],
188
+ })
189
+ const view = new CodeMirrorView({ state, parent: host })
190
+ viewRef.current = view
191
+ return () => {
192
+ view.destroy()
193
+ viewRef.current = null
194
+ themeCompRef.current = null
195
+ }
196
+ // The keymap's save() reads live refs; scope/path are stable for a
197
+ // tab's lifetime, and the dark flip is handled by the reconfigure
198
+ // effect below (recreating the view here would drop the draft).
199
+ }, [content, path])
200
+
201
+ // Scheme flip: re-theme in place (the compartment holds only the
202
+ // scheme-dependent extensions; everything else is untouched).
203
+ useEffect(() => {
204
+ const view = viewRef.current
205
+ const themeComp = themeCompRef.current
206
+ if (view === null || themeComp === null) return
207
+ view.dispatch({ effects: themeComp.reconfigure(dark) })
208
+ }, [dark])
209
+
210
+ // The editor may have been display:none while previewing; re-measure when
211
+ // it becomes visible again (CodeMirror sizes itself on reveal). A mode
212
+ // flip also invalidates any anchored selection popup.
213
+ useEffect(() => {
214
+ hidePopup()
215
+ if (mode === 'edit') viewRef.current?.requestMeasure()
216
+ }, [mode])
217
+
218
+ const save = (): void => {
219
+ const view = viewRef.current
220
+ if (view === null || savingRef.current) return
221
+ savingRef.current = true
222
+ setSaveState('saving')
223
+ api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
224
+ savingRef.current = false
225
+ setDraft(null)
226
+ setDirty(false)
227
+ setSaveState('saved')
228
+ }).catch(() => {
229
+ savingRef.current = false
230
+ setSaveState('failed')
231
+ })
232
+ }
233
+
234
+ const markdown = viewerId === 'markdown'
235
+ const html = viewerId === 'html'
236
+
237
+ /**
238
+ * Selection popup for the markdown preview: a mouse-up inside the preview
239
+ * container anchors the floating "add to conversation" button above the
240
+ * selection. Line numbers come from a best-effort reverse-search of the
241
+ * selected text in the source ({@link linesOfSelection} — an ambiguous or
242
+ * missing hit omits them). The button's own mousedown preventDefaults so
243
+ * the selection survives until the click commits.
244
+ */
245
+ const handlePreviewMouseUp = (): void => {
246
+ const sel = window.getSelection()
247
+ if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
248
+ hidePopup()
249
+ return
250
+ }
251
+ const host = mdRef.current
252
+ if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
253
+ hidePopup()
254
+ return
255
+ }
256
+ const text = sel.toString()
257
+ if (text.trim() === '') {
258
+ hidePopup()
259
+ return
260
+ }
261
+ const rect = sel.getRangeAt(0).getBoundingClientRect()
262
+ const lines = linesOfSelection(draft ?? content ?? '', text)
263
+ showPopup(
264
+ buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
265
+ rect.left + rect.width / 2,
266
+ rect.top,
267
+ )
268
+ }
269
+ const editable = content !== undefined
270
+ const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
271
+ // Per-feature sandbox escape hatch: the global side card setting (warned)
272
+ // plus a per-surface temporary unlock. The unlock state starts at the
273
+ // "default unsafe" pref so a preview can open straight into the red
274
+ // unsandboxed state (still restorable from the status row). With the
275
+ // sandbox OFF the preview iframe drops its sandbox attribute entirely —
276
+ // the previewed page then runs on the GUI's own origin with full session
277
+ // access.
278
+ const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
279
+ const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
280
+
281
+ return (
282
+ <>
283
+ <div className={css.editorHeader}>
284
+ {(markdown || html) && (
285
+ <div className={css.editorModeToggle}>
286
+ <button
287
+ type="button"
288
+ className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
289
+ onClick={() => { setMode('preview') }}
290
+ >
291
+ {t('preview')}
292
+ </button>
293
+ <button
294
+ type="button"
295
+ className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
296
+ onClick={() => { setMode('edit') }}
297
+ >
298
+ {t('edit')}
299
+ </button>
300
+ </div>
301
+ )}
302
+ {dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
303
+ {editable && (
304
+ <button
305
+ type="button"
306
+ className={css.iconButton}
307
+ aria-label={t('save')}
308
+ title={`${t('save')} (Ctrl/Cmd+S)`}
309
+ onClick={save}
310
+ >
311
+ <IconCheckOutline16 />
312
+ </button>
313
+ )}
314
+ {saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
315
+ </div>
316
+ {editable && (
317
+ <>
318
+ {truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
319
+ <div
320
+ className={clsx(css.editorCm, (markdown || html) && mode === 'preview' && css.editorCmHidden)}
321
+ ref={hostRef}
322
+ />
323
+ </>
324
+ )}
325
+ {markdown && mode === 'preview' && (
326
+ <div
327
+ className={css.editorMd}
328
+ ref={mdRef}
329
+ onMouseUp={handlePreviewMouseUp}
330
+ onScroll={hidePopup}
331
+ >
332
+ {/* The fence copy-button labels must come from this plugin's own
333
+ dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
334
+ fall back to hardcoded Chinese otherwise (same pattern as the
335
+ chat's AssistantMarkdown). Render-time t() keeps them following
336
+ the active locale on live switches. */}
337
+ <MarkdownText
338
+ text={draft ?? content ?? ''}
339
+ codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}
340
+ />
341
+ </div>
342
+ )}
343
+ {html && mode === 'preview' && (
344
+ <>
345
+ <SandboxStatusBar
346
+ sandboxed={!htmlNoSandbox}
347
+ local={localUnlock}
348
+ dangerCopy={t('htmlNoSandboxWarning')}
349
+ onUnlock={() => { setLocalUnlock(true) }}
350
+ onRestore={() => { setLocalUnlock(false) }}
351
+ />
352
+ {/* Route-src (never srcdoc — a srcdoc frame inherits the parent
353
+ origin when unsandboxed; the route URL keeps the frame
354
+ cross-origin by construction). The preview shows the SAVED
355
+ file; the draft is only visible in edit mode. */}
356
+ <iframe
357
+ className={css.editorHtml}
358
+ src={htmlUrl(scope, path)}
359
+ sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
360
+ referrerPolicy="no-referrer"
361
+ allow=""
362
+ title={path}
363
+ />
364
+ </>
365
+ )}
366
+ {popup !== null && createPortal(
367
+ <button
368
+ type="button"
369
+ className={css.selectionPopup}
370
+ style={{ left: popup.left, top: popup.top }}
371
+ // Keep the selection (and CodeMirror focus) alive until the click
372
+ // commits — without this the popup unmounts before click lands.
373
+ onMouseDown={(event) => { event.preventDefault() }}
374
+ onClick={commitPopup}
375
+ >
376
+ {t('addToConversation')}
377
+ </button>,
378
+ document.body,
379
+ )}
380
+ </>
381
+ )
382
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Typed fetch wrapper over the /sidebar JSON API. Every call posts to
3
+ * `/sidebar/api/<method>` with the sessionId and — when known — the session's
4
+ * cwd from the client's own list summary. The host prefers its attached
5
+ * session header and uses the summary cwd only while the session is still
6
+ * hydrating at page load (a detached session would otherwise fail the
7
+ * request). Failures surface as {@link SidebarApiError} with the wire code.
8
+ */
9
+ import { encodeHtmlUrl } from '../html-route.ts'
10
+ import type { BrowserProbeResult } from './browser.ts'
11
+
12
+ /** One wire failure. */
13
+ export class SidebarApiError extends Error {
14
+ constructor(
15
+ readonly code: string,
16
+ message: string,
17
+ ) {
18
+ super(message)
19
+ }
20
+ }
21
+
22
+ /** Explorer row (host fs-tree shape). */
23
+ export interface FsEntry {
24
+ name: string
25
+ path: string
26
+ isDir: boolean
27
+ hidden: boolean
28
+ }
29
+
30
+ /** Git status entry (host git shape). */
31
+ export interface GitStatusEntry {
32
+ path: string
33
+ xy: string
34
+ }
35
+
36
+ /** Git status snapshot. */
37
+ export interface GitStatusResult {
38
+ isRepo: boolean
39
+ branch?: string
40
+ entries: GitStatusEntry[]
41
+ }
42
+
43
+ /** One git log row. */
44
+ export interface GitLogEntry {
45
+ /** Short hash (7+ chars, display). */
46
+ hash: string
47
+ /** Full 40-char hash (advanced operations). */
48
+ hashFull: string
49
+ subject: string
50
+ author: string
51
+ /** ISO 8601 author date (`%ai`). */
52
+ date: string
53
+ /** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
54
+ refs: string
55
+ }
56
+
57
+ /** Text read result. */
58
+ export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
59
+ /** Binary read result (no content; images load through the media route).
60
+ * `head` carries the first bytes (base64) for viewer detect sniffing. */
61
+ export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
62
+
63
+ /**
64
+ * One jobs.output response: the output the MODEL has read so far for the
65
+ * job (replayed from the owner session's event log — the model's
66
+ * job_output cursor is never touched, so the pane can never steal the
67
+ * agent's bytes). `read` is false until the model actually called
68
+ * job_output for the job.
69
+ */
70
+ export interface JobOutputResult {
71
+ text: string
72
+ /** True when the host capped the text at its output limit. */
73
+ truncated: boolean
74
+ /** Whether the model has read the job at least once. */
75
+ read: boolean
76
+ }
77
+
78
+ async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
79
+ let response: Response
80
+ try {
81
+ response = await fetch(`/sidebar/api/${method}`, {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json' },
84
+ body: JSON.stringify(payload),
85
+ signal,
86
+ })
87
+ } catch (error) {
88
+ throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
89
+ }
90
+ const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
91
+ = await response.json().catch(() => null)
92
+ if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
93
+ throw new SidebarApiError(
94
+ parsed?.error?.code ?? 'http',
95
+ parsed?.error?.message ?? `HTTP ${response.status}`,
96
+ )
97
+ }
98
+ return parsed.value as T
99
+ }
100
+
101
+ /** One request's session scope: the conversation id plus its cwd when known. */
102
+ export interface SessionScope {
103
+ sessionId: string
104
+ /** The session's working directory from the client list summary (optional). */
105
+ cwd?: string
106
+ }
107
+
108
+ /** Fold a scope into a JSON payload ({cwd} only when present). */
109
+ function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
110
+ return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
111
+ }
112
+
113
+ /** The sidebar API surface (session scope threaded through every call). */
114
+ export const api = {
115
+ sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
116
+ call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
117
+ fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
118
+ call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
119
+ fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
120
+ call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
121
+ fsWrite: (scope: SessionScope, path: string, content: string) =>
122
+ call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
123
+ gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
124
+ call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
125
+ gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
126
+ call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
127
+ gitStage: (scope: SessionScope, path?: string) =>
128
+ call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
129
+ gitUnstage: (scope: SessionScope, path?: string) =>
130
+ call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
131
+ gitCommit: (scope: SessionScope, message: string) =>
132
+ call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
133
+ gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
134
+ call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
135
+ gitCheckout: (scope: SessionScope, branch: string) =>
136
+ call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
137
+ /** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
138
+ gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) =>
139
+ call<GitLogEntry[]>('git.log', scopePayload(scope, {
140
+ ...(count !== undefined ? { count } : {}),
141
+ ...(skip !== undefined ? { skip } : {}),
142
+ }), signal),
143
+ /** Full patch text of one commit (diff display for the history rows). */
144
+ gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) =>
145
+ call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal),
146
+ /** Discard the worktree changes of one file (the index is untouched). */
147
+ gitDiscard: (scope: SessionScope, path: string) =>
148
+ call<{ ok: true }>('git.discard', scopePayload(scope, { path })),
149
+ /** Revert one commit onto the current branch. */
150
+ gitRevert: (scope: SessionScope, hash: string) =>
151
+ call<{ ok: true }>('git.revert', scopePayload(scope, { hash })),
152
+ /** Cherry-pick one commit onto the current branch. */
153
+ gitCherryPick: (scope: SessionScope, hash: string) =>
154
+ call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })),
155
+ /** Release a terminal's process immediately (tab closed; the WS close frame
156
+ * may be unreachable while the socket is down, so the host also accepts
157
+ * this explicit route). */
158
+ ptyClose: (scope: SessionScope, tab: string) =>
159
+ call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
160
+ /** Release an agent terminal by uuid (tab closed while WS was down). */
161
+ agentPtyClose: (uuid: string) =>
162
+ call<{ ok: true }>('agent-pty.close', { uuid }),
163
+ /**
164
+ * The output the model has read so far for one background job (replayed
165
+ * from the owner session's event log — never the model's job_output
166
+ * cursor). The scope MUST be the job's OWNER session.
167
+ */
168
+ jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
169
+ call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
170
+ /** Request cancellation of one background job (live jobs flip to stopping). */
171
+ jobKill: (scope: SessionScope, id: string, reason?: string) =>
172
+ call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
173
+ id,
174
+ ...(reason !== undefined ? { reason } : {}),
175
+ })),
176
+ /** Read the side card preferences (plugin-global, no session scope). */
177
+ settingsGet: () =>
178
+ call<{ value?: unknown; revision?: number }>('settings.get', {}),
179
+ /** Merge a patch into the side card preferences (revision-guarded). */
180
+ settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
181
+ call<{ value?: unknown; revision?: number }>('settings.update', {
182
+ patch,
183
+ ...(expectedRevision !== undefined ? { expectedRevision } : {}),
184
+ }),
185
+ /** Probe a URL's response headers (the sidebar browser's embeddability
186
+ * check; see the host's browser.probe route). */
187
+ browserProbe: (url: string, signal?: AbortSignal) =>
188
+ call<BrowserProbeResult>('browser.probe', { url }, signal),
189
+ }
190
+
191
+ /** Absolute URL of the media route for one path (images only). */
192
+ export function mediaUrl(scope: SessionScope, path: string): string {
193
+ return fileUrl(scope, path, false)
194
+ }
195
+
196
+ /** Absolute URL of the download route: serves raw bytes (binary-safe) with
197
+ * `Content-Disposition: attachment`, so the browser saves the file. */
198
+ export function downloadUrl(scope: SessionScope, path: string): string {
199
+ return fileUrl(scope, path, true)
200
+ }
201
+
202
+ /** Shared URL builder for the /sidebar/file route (media vs download). */
203
+ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
204
+ const params = new URLSearchParams({ sessionId: scope.sessionId, path })
205
+ if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
206
+ if (download) params.set('download', '1')
207
+ return `/sidebar/file?${params.toString()}`
208
+ }
209
+
210
+ /** Absolute URL of the HTML preview route (see html-route.ts): the path is
211
+ * fully encoded so the previewed page's relative assets resolve back into
212
+ * the same route with the session scope intact. */
213
+ export function htmlUrl(scope: SessionScope, path: string): string {
214
+ return encodeHtmlUrl(scope.sessionId, path)
215
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The "no preview — download instead" pane: shown by the editor host when no
3
+ * viewer can render a file (binary without a registered renderer, or the
4
+ * `binary-download` strategy) and registered as the `binary-download` viewer
5
+ * component so the declarative route and the host fallback share one UI.
6
+ */
7
+ import type { ReactNode } from 'react'
8
+ import type { SessionScope } from './api.ts'
9
+ import { downloadUrl } from './api.ts'
10
+ import { t } from './locales.ts'
11
+ import css from './sidebar.module.css'
12
+
13
+ export function BinaryDownload(props: { scope: SessionScope; path: string }): ReactNode {
14
+ const { scope, path } = props
15
+ return (
16
+ <div className={css.editorBinary}>
17
+ <span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
18
+ <a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
19
+ {t('downloadToView')}
20
+ </a>
21
+ </div>
22
+ )
23
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Narrow-viewport ("mobile") breakpoint for the sidebar. Width-based, shared
3
+ * by the layout logic (JS) and the style gates (CSS). The CSS side pairs
4
+ * with this file via `@media (max-width: 767px)` rules (sidebar.module.css)
5
+ * — 767px ≡ widths below NARROW_MAX_WIDTH, documented at both ends.
6
+ *
7
+ * "Real narrow" on purpose: the mobile layout (one full-screen drawer, the
8
+ * bottom panel's tabs merged into the right sidebar) is a phone / portrait
9
+ * tablet experience. The value is deliberately NOT aligned to the DSH app
10
+ * shell's own 1024px breakpoint — 1024px windows (small laptops, split
11
+ * panes) keep the desktop two-panel layout.
12
+ */
13
+ import { useEffect, useState } from 'react'
14
+
15
+ /** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 767px). */
16
+ export const NARROW_MAX_WIDTH = 768
17
+
18
+ /** Whether a viewport width is narrow (mobile). */
19
+ export function isNarrowWidth(width: number): boolean {
20
+ return width < NARROW_MAX_WIDTH
21
+ }
22
+
23
+ /**
24
+ * Live narrow-viewport flag for components. Reads `window.innerWidth` and
25
+ * re-measures on resize (rAF-throttled, the repo's existing drag pattern).
26
+ * Deliberately avoids `matchMedia` (jsdom does not implement it) — the
27
+ * resize listener is equally exact for a breakpoint that never changes
28
+ * while the page is open.
29
+ */
30
+ export function useNarrowViewport(): boolean {
31
+ const [narrow, setNarrow] = useState(
32
+ () => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth),
33
+ )
34
+ useEffect(() => {
35
+ if (typeof window === 'undefined') return
36
+ let frame: number | null = null
37
+ const measure = (): void => {
38
+ frame = null
39
+ setNarrow(isNarrowWidth(window.innerWidth))
40
+ }
41
+ const onResize = (): void => {
42
+ if (frame === null) frame = requestAnimationFrame(measure)
43
+ }
44
+ window.addEventListener('resize', onResize)
45
+ return () => {
46
+ window.removeEventListener('resize', onResize)
47
+ if (frame !== null) cancelAnimationFrame(frame)
48
+ }
49
+ }, [])
50
+ return narrow
51
+ }