@keith9681/dsh-sidebar 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +22 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. package/src/wire.ts +100 -0
@@ -0,0 +1,500 @@
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 — unless the host passes
11
+ * `toolbar: 'host'` (the merged editor-explorer mode), in which case this
12
+ * component skips the row and reports state + registers commands through
13
+ * the FileViewerProps toolbar callbacks so the host's path-input header
14
+ * renders the controls instead.
15
+ */
16
+ import { useEffect, useMemo, useRef, useState } from 'react'
17
+ import type { ComponentType } from 'react'
18
+ import { createPortal } from 'react-dom'
19
+ import clsx from 'clsx'
20
+ import { Compartment, EditorState } from '@codemirror/state'
21
+ import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
22
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
23
+ import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
24
+ import { api, htmlUrl } from './api.ts'
25
+ import { isSessionEditable, setSessionEditable, subscribeSessionEditable } from './editable-state.ts'
26
+ import { IconLockClosedOutline16, IconLockOpenOutline16 } from './icons.tsx'
27
+ import { languageForPath } from './lang.ts'
28
+ import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
29
+ import { isDarkScheme, subscribeColorScheme } from './theme.ts'
30
+ import { SandboxStatusBar } from './SandboxStatusBar.tsx'
31
+ import { appendToDraft } from './conversation-draft.ts'
32
+ import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
33
+ import { lazyChunkComponent } from './lazy-chunk.tsx'
34
+ import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
35
+ import { t } from './locales.ts'
36
+ import type { EditorToolbarState, FileViewerProps } from './service.ts'
37
+ import css from './sidebar.module.css'
38
+
39
+ /** Previewable files (rendered output vs source editing). */
40
+ type ViewMode = 'preview' | 'edit'
41
+
42
+ /** The floating "add to conversation" action: payload + viewport anchor. */
43
+ interface SelectionPopup {
44
+ insert: string
45
+ left: number
46
+ top: number
47
+ }
48
+
49
+ /**
50
+ * The chunk-resident markdown preview renderer (mermaid lazy chunk): one
51
+ * MarkdownText pass over the whole source, with rendered mermaid fences
52
+ * swapped for diagrams. Module-level `pick` keeps the load effect stable.
53
+ */
54
+ const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
55
+ 'mermaid',
56
+ (mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
57
+ )
58
+
59
+ /**
60
+ * The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
61
+ * preview must stay in an opaque origin — with the route's own origin it
62
+ * could read session data) and NO allow-top-navigation (a previewed page
63
+ * must not hijack the GUI). The user can disable the sandbox per-feature
64
+ * in the side card settings (warned); the toggle below reflects it.
65
+ */
66
+ export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
67
+
68
+ export function TextEditor(props: FileViewerProps) {
69
+ const { ctx, scope, path, viewerId, content, truncated } = props
70
+ const [mode, setMode] = useState<ViewMode>('preview')
71
+ /** The editor's current text (null while clean); preview renders this. */
72
+ const [draft, setDraft] = useState<string | null>(null)
73
+ const [dirty, setDirty] = useState(false)
74
+ const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
75
+ const hostRef = useRef<HTMLDivElement>(null)
76
+ const viewRef = useRef<CodeMirrorView | null>(null)
77
+ const savingRef = useRef(false)
78
+ /** The theme compartment of the current view (reconfigured on scheme flip). */
79
+ const themeCompRef = useRef<CmThemeCompartment | null>(null)
80
+ /** The read-only compartment of the current view (reconfigured by the writable toggle). */
81
+ const writableCompRef = useRef<Compartment | null>(null)
82
+ /** Whether THIS session may write files (read-only by default; the toolbar toggle flips it). */
83
+ const [writable, setWritable] = useState(() => isSessionEditable(scope.sessionId))
84
+ /** Live mirror of the writable flag for ref-only closures (save / keymap). */
85
+ const writableRef = useRef(writable)
86
+ /** The app's resolved color scheme; the editor re-themes in place on flips. */
87
+ const [dark, setDark] = useState(() => isDarkScheme())
88
+ /** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
89
+ const [popup, setPopup] = useState<SelectionPopup | null>(null)
90
+ /** Live mirror of the popup state for click-time reads (no re-render race). */
91
+ const popupRef = useRef<SelectionPopup | null>(null)
92
+ /** The markdown preview container (selection-containment + line lookup). */
93
+ const mdRef = useRef<HTMLDivElement>(null)
94
+
95
+ const hidePopup = (): void => {
96
+ popupRef.current = null
97
+ setPopup(null)
98
+ }
99
+
100
+ /** Anchor the popup above the selection center; clamp inside the viewport. */
101
+ const showPopup = (insert: string, left: number, top: number): void => {
102
+ const next: SelectionPopup = {
103
+ insert,
104
+ left: Math.min(Math.max(left, 80), window.innerWidth - 80),
105
+ top,
106
+ }
107
+ popupRef.current = next
108
+ setPopup(next)
109
+ }
110
+
111
+ /** The popup button's click: insert the stored payload into the draft. */
112
+ const commitPopup = (): void => {
113
+ const current = popupRef.current
114
+ if (current === null) return
115
+ appendToDraft(ctx, scope.sessionId, current.insert)
116
+ hidePopup()
117
+ }
118
+
119
+ useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
120
+
121
+ // Follow the shared per-session write permission: the toolbar toggle flips
122
+ // it, and any other surface reading the same flag (e.g. the git panel)
123
+ // stays in lockstep through the shared module.
124
+ useEffect(() => subscribeSessionEditable(scope.sessionId, (next) => {
125
+ writableRef.current = next
126
+ setWritable(next)
127
+ }), [scope.sessionId])
128
+
129
+ /** Flip this session's write permission: local first (instant UI), then the
130
+ * host; a host failure rolls the flag back so UI and enforcement agree. */
131
+ const toggleWritable = (): void => {
132
+ const next = !writableRef.current
133
+ setSessionEditable(scope.sessionId, next)
134
+ if (!next) setMode('preview')
135
+ void api.configSetEditable(scope, next).catch(() => {
136
+ setSessionEditable(scope.sessionId, !next)
137
+ })
138
+ }
139
+
140
+ // A new file (tab switch) starts clean: fresh preview mode, no draft.
141
+ useEffect(() => {
142
+ setMode('preview')
143
+ setDraft(null)
144
+ setDirty(false)
145
+ setSaveState('idle')
146
+ hidePopup()
147
+ }, [content])
148
+
149
+ // Create the CodeMirror editor once the content is loaded. The view owns
150
+ // the document; React only tracks dirty/draft state through the update
151
+ // listener. For markdown the view stays mounted while previewing (hidden),
152
+ // so unsaved edits survive the preview/edit toggle. The theme + syntax
153
+ // colors live in a compartment so a scheme flip reconfigures only that
154
+ // part — the document, undo history and scroll position survive.
155
+ useEffect(() => {
156
+ if (content === undefined) return
157
+ const host = hostRef.current
158
+ if (host === null) return
159
+ const language = languageForPath(path)
160
+ const themeComp = new CmThemeCompartment()
161
+ themeCompRef.current = themeComp
162
+ const writableCompartment = new Compartment()
163
+ writableCompRef.current = writableCompartment
164
+ const state = EditorState.create({
165
+ doc: content,
166
+ extensions: [
167
+ CodeMirrorView.lineWrapping,
168
+ lineNumbers(),
169
+ history(),
170
+ EditorState.tabSize.of(2),
171
+ CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
172
+ cmSurfaceTheme,
173
+ themeComp.of(dark),
174
+ // Read-only by default; the toolbar toggle reconfigures this
175
+ // compartment (the document, undo history and scroll survive).
176
+ writableCompartment.of(writableRef.current
177
+ ? []
178
+ : [EditorState.readOnly.of(true), CodeMirrorView.editable.of(false)]),
179
+ ...(language !== null ? [language] : []),
180
+ CodeMirrorView.updateListener.of((update) => {
181
+ if (update.docChanged) {
182
+ setDraft(update.state.doc.toString())
183
+ setDirty(true)
184
+ }
185
+ }),
186
+ keymap.of([
187
+ {
188
+ key: 'Mod-s',
189
+ preventDefault: true,
190
+ run: () => { if (!writableRef.current) return true; save(); return true },
191
+ },
192
+ ...defaultKeymap,
193
+ ...historyKeymap,
194
+ ]),
195
+ // Selection popup (the code and markdown editors): a non-empty
196
+ // selection anchors the floating "add to conversation" button above
197
+ // its head. Scrolling (geometry/viewport change) or losing focus
198
+ // hides it; typing collapses the selection and hides it too.
199
+ ...(viewerId === 'code' || viewerId === 'markdown' ? [
200
+ CodeMirrorView.updateListener.of((update) => {
201
+ if (update.geometryChanged || update.viewportChanged) {
202
+ hidePopup()
203
+ return
204
+ }
205
+ if (!update.view.hasFocus) {
206
+ hidePopup()
207
+ return
208
+ }
209
+ if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
210
+ const sel = update.state.selection.main
211
+ if (sel.empty) {
212
+ hidePopup()
213
+ return
214
+ }
215
+ const text = update.state.sliceDoc(sel.from, sel.to)
216
+ if (text.trim() === '') {
217
+ hidePopup()
218
+ return
219
+ }
220
+ // Page coordinates (the document root may scroll); the popup is
221
+ // position:fixed, so convert to viewport coordinates.
222
+ const rect = update.view.coordsAtPos(sel.head)
223
+ if (rect === null) {
224
+ hidePopup()
225
+ return
226
+ }
227
+ const doc = update.state.doc
228
+ showPopup(
229
+ buildSelectionInsert(path, scope.cwd, {
230
+ start: doc.lineAt(sel.from).number,
231
+ end: doc.lineAt(sel.to).number,
232
+ }, text),
233
+ rect.left - window.scrollX + (rect.right - rect.left) / 2,
234
+ rect.top - window.scrollY,
235
+ )
236
+ }),
237
+ ] : []),
238
+ ],
239
+ })
240
+ const view = new CodeMirrorView({ state, parent: host })
241
+ viewRef.current = view
242
+ return () => {
243
+ view.destroy()
244
+ viewRef.current = null
245
+ themeCompRef.current = null
246
+ writableCompRef.current = null
247
+ }
248
+ // The keymap's save() reads live refs; scope/path are stable for a
249
+ // tab's lifetime, and the dark flip is handled by the reconfigure
250
+ // effect below (recreating the view here would drop the draft).
251
+ }, [content, path])
252
+
253
+ // Scheme flip: re-theme in place (the compartment holds only the
254
+ // scheme-dependent extensions; everything else is untouched).
255
+ useEffect(() => {
256
+ const view = viewRef.current
257
+ const themeComp = themeCompRef.current
258
+ if (view === null || themeComp === null) return
259
+ view.dispatch({ effects: themeComp.reconfigure(dark) })
260
+ }, [dark])
261
+
262
+ // Write-permission flip: switch the read-only compartment in place, so
263
+ // the draft and cursor survive the toggle.
264
+ useEffect(() => {
265
+ const view = viewRef.current
266
+ const writableComp = writableCompRef.current
267
+ if (view === null || writableComp === null) return
268
+ view.dispatch({
269
+ effects: writableComp.reconfigure(writable
270
+ ? []
271
+ : [EditorState.readOnly.of(true), CodeMirrorView.editable.of(false)]),
272
+ })
273
+ }, [writable])
274
+
275
+ // The editor may have been display:none while previewing; re-measure when
276
+ // it becomes visible again (CodeMirror sizes itself on reveal). A mode
277
+ // flip also invalidates any anchored selection popup.
278
+ useEffect(() => {
279
+ hidePopup()
280
+ if (mode === 'edit') viewRef.current?.requestMeasure()
281
+ }, [mode])
282
+
283
+ const save = (): void => {
284
+ const view = viewRef.current
285
+ if (view === null || savingRef.current) return
286
+ if (!writableRef.current) return
287
+ savingRef.current = true
288
+ setSaveState('saving')
289
+ api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
290
+ savingRef.current = false
291
+ setDraft(null)
292
+ setDirty(false)
293
+ setSaveState('saved')
294
+ }).catch(() => {
295
+ savingRef.current = false
296
+ setSaveState('failed')
297
+ })
298
+ }
299
+
300
+ const markdown = viewerId === 'markdown'
301
+ const html = viewerId === 'html'
302
+ /** The markdown source the preview renders (draft wins over saved content). */
303
+ const mdText = draft ?? content ?? ''
304
+ /** md/mermaid block split for the preview (mermaid fences lift out). Split
305
+ * only in preview mode: edit-mode keystrokes must not re-scan the source. */
306
+ const mdBlocks = useMemo(
307
+ () => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
308
+ [markdown, mode, mdText],
309
+ )
310
+ const hasMermaid = useMemo(
311
+ () => mdBlocks.some(block => block.kind === 'mermaid'),
312
+ [mdBlocks],
313
+ )
314
+ const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
315
+
316
+ /**
317
+ * Selection popup for the markdown preview: a mouse-up inside the preview
318
+ * container anchors the floating "add to conversation" button above the
319
+ * selection. Line numbers come from a best-effort reverse-search of the
320
+ * selected text in the source ({@link linesOfSelection} — an ambiguous or
321
+ * missing hit omits them). The button's own mousedown preventDefaults so
322
+ * the selection survives until the click commits.
323
+ */
324
+ const handlePreviewMouseUp = (): void => {
325
+ const sel = window.getSelection()
326
+ if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
327
+ hidePopup()
328
+ return
329
+ }
330
+ const host = mdRef.current
331
+ if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
332
+ hidePopup()
333
+ return
334
+ }
335
+ const text = sel.toString()
336
+ if (text.trim() === '') {
337
+ hidePopup()
338
+ return
339
+ }
340
+ const rect = sel.getRangeAt(0).getBoundingClientRect()
341
+ const lines = linesOfSelection(mdText, text)
342
+ showPopup(
343
+ buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
344
+ rect.left + rect.width / 2,
345
+ rect.top,
346
+ )
347
+ }
348
+ const editable = content !== undefined
349
+ const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
350
+ // Per-feature sandbox escape hatch: the global side card setting (warned)
351
+ // plus a per-surface temporary unlock. The unlock state starts at the
352
+ // "default unsafe" pref so a preview can open straight into the red
353
+ // unsandboxed state (still restorable from the status row). With the
354
+ // sandbox OFF the preview iframe drops its sandbox attribute entirely —
355
+ // the previewed page then runs on the GUI's own origin with full session
356
+ // access.
357
+ const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
358
+ const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
359
+
360
+ // Host-toolbar mode (the merged editor header renders the controls): skip
361
+ // the own toolbar row, report the state after every relevant render (the
362
+ // JSON key guards redundant calls), and register the commands on mount.
363
+ const hostToolbar = props.toolbar === 'host'
364
+ const lastToolbarRef = useRef('')
365
+ useEffect(() => {
366
+ if (!hostToolbar) return
367
+ const state: EditorToolbarState = { modes: markdown || html, mode, dirty, editable, saveState }
368
+ const key = JSON.stringify(state)
369
+ if (lastToolbarRef.current === key) return
370
+ lastToolbarRef.current = key
371
+ props.onToolbarState?.(state)
372
+ })
373
+ useEffect(() => {
374
+ if (!hostToolbar) return
375
+ // `save` reads live refs only, and `setMode` is the stable state setter —
376
+ // registering this render's closures is safe for the mount's lifetime.
377
+ props.onToolbarControls?.({ setMode, save })
378
+ return () => { props.onToolbarControls?.(null) }
379
+ // eslint-disable-next-line react-hooks/exhaustive-deps
380
+ }, [hostToolbar])
381
+
382
+ return (
383
+ <>
384
+ {!hostToolbar && (
385
+ <div className={css.editorHeader}>
386
+ {(markdown || html) && (
387
+ <div className={css.editorModeToggle}>
388
+ <button
389
+ type="button"
390
+ className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
391
+ onClick={() => { setMode('preview') }}
392
+ >
393
+ {t('preview')}
394
+ </button>
395
+ {writable && (
396
+ <button
397
+ type="button"
398
+ className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
399
+ onClick={() => { setMode('edit') }}
400
+ >
401
+ {t('edit')}
402
+ </button>
403
+ )}
404
+ </div>
405
+ )}
406
+ {dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
407
+ {editable && (
408
+ <button
409
+ type="button"
410
+ className={css.iconButton}
411
+ aria-label={writable ? t('switchReadOnlyTitle') : t('switchEditableTitle')}
412
+ title={writable ? t('switchReadOnlyTitle') : t('switchEditableTitle')}
413
+ onClick={toggleWritable}
414
+ >
415
+ {writable ? <IconLockOpenOutline16 size={14} /> : <IconLockClosedOutline16 size={14} />}
416
+ </button>
417
+ )}
418
+ {editable && writable && (
419
+ <button
420
+ type="button"
421
+ className={css.iconButton}
422
+ aria-label={t('save')}
423
+ title={`${t('save')} (Ctrl/Cmd+S)`}
424
+ onClick={save}
425
+ >
426
+ <IconCheckOutline16 />
427
+ </button>
428
+ )}
429
+ {saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
430
+ </div>
431
+ )}
432
+ {editable && (
433
+ <>
434
+ {truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
435
+ <div
436
+ className={clsx(css.editorCm, (markdown || html) && mode === 'preview' && css.editorCmHidden)}
437
+ ref={hostRef}
438
+ />
439
+ </>
440
+ )}
441
+ {markdown && mode === 'preview' && (
442
+ <div
443
+ className={css.editorMd}
444
+ ref={mdRef}
445
+ onMouseUp={handlePreviewMouseUp}
446
+ onScroll={hidePopup}
447
+ >
448
+ {/* The fence copy-button labels must come from this plugin's own
449
+ dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
450
+ fall back to hardcoded Chinese otherwise (same pattern as the
451
+ chat's AssistantMarkdown). Render-time t() keeps them following
452
+ the active locale on live switches. Mermaid fences hand the
453
+ whole document to the mermaid lazy chunk (single markdown
454
+ parse; cross-fence references/footnotes stay intact); files
455
+ without one render exactly as before. */}
456
+ {hasMermaid
457
+ ? <LazyMermaidMarkdown text={mdText} codeLabels={codeLabels} />
458
+ : <MarkdownText text={mdText} codeLabels={codeLabels} />}
459
+ </div>
460
+ )}
461
+ {html && mode === 'preview' && (
462
+ <>
463
+ <SandboxStatusBar
464
+ sandboxed={!htmlNoSandbox}
465
+ local={localUnlock}
466
+ dangerCopy={t('htmlNoSandboxWarning')}
467
+ onUnlock={() => { setLocalUnlock(true) }}
468
+ onRestore={() => { setLocalUnlock(false) }}
469
+ />
470
+ {/* Route-src (never srcdoc — a srcdoc frame inherits the parent
471
+ origin when unsandboxed; the route URL keeps the frame
472
+ cross-origin by construction). The preview shows the SAVED
473
+ file; the draft is only visible in edit mode. */}
474
+ <iframe
475
+ className={css.editorHtml}
476
+ src={htmlUrl(scope, path)}
477
+ sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
478
+ referrerPolicy="no-referrer"
479
+ allow=""
480
+ title={path}
481
+ />
482
+ </>
483
+ )}
484
+ {popup !== null && createPortal(
485
+ <button
486
+ type="button"
487
+ className={css.selectionPopup}
488
+ style={{ left: popup.left, top: popup.top }}
489
+ // Keep the selection (and CodeMirror focus) alive until the click
490
+ // commits — without this the popup unmounts before click lands.
491
+ onMouseDown={(event) => { event.preventDefault() }}
492
+ onClick={commitPopup}
493
+ >
494
+ {t('addToConversation')}
495
+ </button>,
496
+ document.body,
497
+ )}
498
+ </>
499
+ )
500
+ }