@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.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- 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
|
+
}
|