dsh-coding-sidebar 1.0.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.
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +48229 -0
- package/lib/client-mermaid.js +200745 -0
- package/lib/client-registry.js +21867 -0
- package/lib/client-terminal.js +9361 -0
- package/lib/client.js +21867 -0
- package/lib/index.js +4556 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-opens.d.ts +65 -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 +39 -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 +13 -0
- package/lib/types/client/FileTree.d.ts +38 -0
- package/lib/types/client/FreeWindow.d.ts +29 -0
- package/lib/types/client/GitView.d.ts +10 -0
- package/lib/types/client/MarkdownHtml.d.ts +32 -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 +54 -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 +24 -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 +275 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +17 -0
- package/lib/types/client/browser.d.ts +60 -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 +131 -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/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +59 -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 +25 -0
- package/lib/types/client/intercept.d.ts +46 -0
- package/lib/types/client/lang.d.ts +14 -0
- package/lib/types/client/layout-push.d.ts +15 -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-ar.d.ts +18 -0
- package/lib/types/client/locales-de.d.ts +4 -0
- package/lib/types/client/locales-fr.d.ts +10 -0
- package/lib/types/client/locales-hi.d.ts +17 -0
- package/lib/types/client/locales-id.d.ts +15 -0
- package/lib/types/client/locales-it.d.ts +8 -0
- package/lib/types/client/locales-ja.d.ts +17 -0
- package/lib/types/client/locales-ko.d.ts +9 -0
- package/lib/types/client/locales-nl.d.ts +16 -0
- package/lib/types/client/locales-pl.d.ts +19 -0
- package/lib/types/client/locales-pt.d.ts +1 -0
- package/lib/types/client/locales-ru.d.ts +13 -0
- package/lib/types/client/locales-sv.d.ts +1 -0
- package/lib/types/client/locales-th.d.ts +17 -0
- package/lib/types/client/locales-tr.d.ts +17 -0
- package/lib/types/client/locales-vi.d.ts +17 -0
- package/lib/types/client/locales-zh-HK.d.ts +32 -0
- package/lib/types/client/locales-zh-MO.d.ts +32 -0
- package/lib/types/client/locales-zh-TW.d.ts +32 -0
- package/lib/types/client/locales.d.ts +452 -0
- package/lib/types/client/markdown-html.d.ts +96 -0
- package/lib/types/client/markdown-images.d.ts +45 -0
- package/lib/types/client/markdown-labels.d.ts +38 -0
- package/lib/types/client/md-toc.d.ts +4 -0
- package/lib/types/client/mermaid-blocks.d.ts +47 -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 +85 -0
- package/lib/types/client/paths.d.ts +43 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/pinned.d.ts +86 -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 +29 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +439 -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 +36 -0
- package/lib/types/client/state.d.ts +432 -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 +40 -0
- package/lib/types/client/terminal-font.d.ts +78 -0
- package/lib/types/client/terminal-links.d.ts +119 -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 +605 -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 +124 -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/path-security.d.ts +20 -0
- package/lib/types/prefs-shared.d.ts +236 -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 +34 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +172 -0
- package/scripts/install.ps1 +306 -0
- package/scripts/install.sh +287 -0
- package/src/agent-opens.ts +265 -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 +289 -0
- package/src/client/DiffTab.tsx +114 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +558 -0
- package/src/client/FileTree.tsx +669 -0
- package/src/client/FreeWindow.tsx +337 -0
- package/src/client/GitView.tsx +717 -0
- package/src/client/MarkdownHtml.tsx +296 -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 +1163 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +645 -0
- package/src/client/Sidebar.tsx +1370 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +365 -0
- package/src/client/TerminalView.tsx +424 -0
- package/src/client/TextEditor.tsx +468 -0
- package/src/client/TreePanel.tsx +275 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +357 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +61 -0
- package/src/client/browser.ts +167 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +340 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +358 -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/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +152 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +427 -0
- package/src/client/intercept.tsx +188 -0
- package/src/client/lang.ts +225 -0
- package/src/client/layout-push.ts +24 -0
- package/src/client/layout.css +90 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales-ar.ts +368 -0
- package/src/client/locales-de.ts +353 -0
- package/src/client/locales-fr.ts +360 -0
- package/src/client/locales-hi.ts +367 -0
- package/src/client/locales-id.ts +365 -0
- package/src/client/locales-it.ts +358 -0
- package/src/client/locales-ja.ts +367 -0
- package/src/client/locales-ko.ts +359 -0
- package/src/client/locales-nl.ts +365 -0
- package/src/client/locales-pl.ts +369 -0
- package/src/client/locales-pt.ts +350 -0
- package/src/client/locales-ru.ts +365 -0
- package/src/client/locales-sv.ts +350 -0
- package/src/client/locales-th.ts +367 -0
- package/src/client/locales-tr.ts +367 -0
- package/src/client/locales-vi.ts +367 -0
- package/src/client/locales-zh-HK.ts +382 -0
- package/src/client/locales-zh-MO.ts +382 -0
- package/src/client/locales-zh-TW.ts +382 -0
- package/src/client/locales.ts +894 -0
- package/src/client/markdown-html.ts +331 -0
- package/src/client/markdown-images.ts +137 -0
- package/src/client/markdown-labels.tsx +52 -0
- package/src/client/md-toc.tsx +127 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +406 -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 +143 -0
- package/src/client/paths.ts +63 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/pinned.ts +183 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +96 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +210 -0
- package/src/client/produced-files.ts +120 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +859 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2788 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +331 -0
- package/src/client/state.ts +1497 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +55 -0
- package/src/client/terminal-font.ts +224 -0
- package/src/client/terminal-links.ts +194 -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 +154 -0
- package/src/context-types.ts +611 -0
- package/src/fs-operations.ts +95 -0
- package/src/fs-search.ts +113 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +446 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1291 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/path-security.ts +75 -0
- package/src/prefs-shared.ts +280 -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 +482 -0
- package/src/trust-fence.ts +84 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The markdown preview's raw-HTML renderer. `markdown-html.ts` lifts HTML
|
|
3
|
+
* runs out of the markdown stream; this module renders them as sanitized DOM
|
|
4
|
+
* alongside the markdown runs (which keep flowing through the shared
|
|
5
|
+
* `MarkdownText`), nests markdown into unclosed block elements the way
|
|
6
|
+
* GitHub's linear HTML output does (`<details>` … fence … `</details>`), and
|
|
7
|
+
* runs an inline pass that turns literal tag text inside rendered markdown
|
|
8
|
+
* (table cells with `<br/>`, `<sub>`, `<img>`) back into elements.
|
|
9
|
+
*
|
|
10
|
+
* Security posture: every HTML string (block leaves, inline text, wrapper
|
|
11
|
+
* open-tag attributes) goes through DOMPurify with an explicit denylist on
|
|
12
|
+
* top of its defaults (no script/style/iframe/forms), anchors are forced to
|
|
13
|
+
* open in a new tab with noopener, and local media `src` attributes are
|
|
14
|
+
* rewritten through the session-scoped `/sidebar/file` media route — the same
|
|
15
|
+
* trust fence the markdown image rewriter (`markdown-images.ts`) uses.
|
|
16
|
+
*/
|
|
17
|
+
import { useLayoutEffect, useMemo, useRef } from 'react'
|
|
18
|
+
import { createElement, type ReactNode } from 'react'
|
|
19
|
+
import DOMPurify from 'dompurify'
|
|
20
|
+
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
21
|
+
import type { ComponentType } from 'react'
|
|
22
|
+
import { markdownTextProps } from './markdown-labels.tsx'
|
|
23
|
+
import { lazyChunkComponent } from './lazy-chunk.tsx'
|
|
24
|
+
import { resolveLocalMediaDest } from './markdown-images.ts'
|
|
25
|
+
import {
|
|
26
|
+
analyzeHtmlSegment,
|
|
27
|
+
type AnalyzedMarkdownHtml,
|
|
28
|
+
} from './markdown-html.ts'
|
|
29
|
+
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
|
|
30
|
+
import type { SessionScope } from './api.ts'
|
|
31
|
+
import css from './sidebar.module.css'
|
|
32
|
+
|
|
33
|
+
/** The chunk-resident markdown renderer (mermaid lazy chunk), shared with the
|
|
34
|
+
* legacy no-HTML preview path in TextEditor. */
|
|
35
|
+
export const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
|
|
36
|
+
'mermaid',
|
|
37
|
+
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
/** Everything the sanitizers need to resolve local media + scope the route. */
|
|
41
|
+
export interface MarkdownHtmlMedia {
|
|
42
|
+
scope: SessionScope
|
|
43
|
+
path: string
|
|
44
|
+
origin: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Tag-like text in a rendered text node — the inline pass gate. */
|
|
48
|
+
const TAGLIKE_TEXT_RE = /<\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]/
|
|
49
|
+
|
|
50
|
+
/** Explicit denylist on top of DOMPurify's defaults: no active content, no
|
|
51
|
+
* form chrome, no document-level elements inside a preview. */
|
|
52
|
+
const PURIFY_FORBID_TAGS = [
|
|
53
|
+
'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button',
|
|
54
|
+
'select', 'textarea', 'meta', 'link', 'base', 'frame', 'frameset', 'applet',
|
|
55
|
+
]
|
|
56
|
+
const PURIFY_FORBID_ATTR = ['srcdoc', 'formaction']
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Post-sanitize hardening on a detached element tree: anchors open in a new
|
|
60
|
+
* tab (never navigate the GUI), and local media sources go through the media
|
|
61
|
+
* route so they render instead of being dropped by protocol allowlists.
|
|
62
|
+
*/
|
|
63
|
+
function postProcessSanitized(root: Element, media: MarkdownHtmlMedia): void {
|
|
64
|
+
for (const anchor of root.querySelectorAll('a[href]')) {
|
|
65
|
+
anchor.setAttribute('target', '_blank')
|
|
66
|
+
anchor.setAttribute('rel', 'noopener noreferrer')
|
|
67
|
+
}
|
|
68
|
+
for (const element of root.querySelectorAll('img, video, audio, source')) {
|
|
69
|
+
const src = element.getAttribute('src')
|
|
70
|
+
if (src === null) continue
|
|
71
|
+
element.setAttribute('src', resolveLocalMediaDest(src, media.scope, media.path, media.origin))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Sanitize one balanced HTML span into markup for dangerouslySetInnerHTML. */
|
|
76
|
+
function sanitizeHtmlBlock(source: string, media: MarkdownHtmlMedia): string {
|
|
77
|
+
const holder = document.createElement('div')
|
|
78
|
+
holder.innerHTML = DOMPurify.sanitize(source, {
|
|
79
|
+
FORBID_TAGS: PURIFY_FORBID_TAGS,
|
|
80
|
+
FORBID_ATTR: PURIFY_FORBID_ATTR,
|
|
81
|
+
})
|
|
82
|
+
postProcessSanitized(holder, media)
|
|
83
|
+
return holder.innerHTML
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Sanitize literal tag text from a rendered markdown text node. Returns null
|
|
88
|
+
* when nothing real survived (pure prose like `a < b` — the DOMPurify output
|
|
89
|
+
* has no element children), so the caller leaves the text node untouched.
|
|
90
|
+
*/
|
|
91
|
+
function sanitizeInlineHtml(text: string, media: MarkdownHtmlMedia): string | null {
|
|
92
|
+
const holder = document.createElement('span')
|
|
93
|
+
holder.innerHTML = DOMPurify.sanitize(text, {
|
|
94
|
+
FORBID_TAGS: PURIFY_FORBID_TAGS,
|
|
95
|
+
FORBID_ATTR: PURIFY_FORBID_ATTR,
|
|
96
|
+
})
|
|
97
|
+
if (holder.firstElementChild === null) return null
|
|
98
|
+
postProcessSanitized(holder, media)
|
|
99
|
+
return holder.innerHTML
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sanitize a wrapper open tag (`<details open>`) into React props. Returns
|
|
104
|
+
* null when DOMPurify dropped the whole tag (denied element) — the renderer
|
|
105
|
+
* then treats the wrapper as transparent. `class`/`for` map to their React
|
|
106
|
+
* names; `style` is dropped (React needs an object; wrappers with inline
|
|
107
|
+
* styles are vanishingly rare and not worth a CSS parser).
|
|
108
|
+
*/
|
|
109
|
+
function sanitizeTagProps(tag: string, attrs: string): Record<string, string> | null {
|
|
110
|
+
const probe = DOMPurify.sanitize(`<${tag}${attrs}></${tag}>`, {
|
|
111
|
+
FORBID_TAGS: PURIFY_FORBID_TAGS,
|
|
112
|
+
FORBID_ATTR: PURIFY_FORBID_ATTR,
|
|
113
|
+
})
|
|
114
|
+
const holder = document.createElement('div')
|
|
115
|
+
holder.innerHTML = probe
|
|
116
|
+
const element = holder.firstElementChild
|
|
117
|
+
if (element === null || element.tagName.toLowerCase() !== tag) return null
|
|
118
|
+
const props: Record<string, string> = {}
|
|
119
|
+
for (const attr of element.attributes) {
|
|
120
|
+
if (/^on/i.test(attr.name) || !/^[a-zA-Z][a-zA-Z0-9:._-]*$/.test(attr.name)) continue
|
|
121
|
+
if (attr.name === 'style') continue
|
|
122
|
+
props[attr.name === 'class' ? 'className' : attr.name === 'for' ? 'htmlFor' : attr.name] = attr.value
|
|
123
|
+
}
|
|
124
|
+
return props
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The inline pass: walk the rendered markdown's text nodes and swap any that
|
|
129
|
+
* contain tag-like text for a sanitized `<span data-html-inline>`. Rendered
|
|
130
|
+
* code (inline `code`, `pre`, the host `.md-code-block`, mermaid mounts, and
|
|
131
|
+
* spans this pass already produced) is skipped. The same commit-then-operate
|
|
132
|
+
* pattern the mermaid swap uses: React keeps owning the host tree, only leaf
|
|
133
|
+
* text nodes are replaced, and a text change re-renders the subtree fresh.
|
|
134
|
+
*/
|
|
135
|
+
function runInlineHtmlPass(container: HTMLElement, media: MarkdownHtmlMedia): void {
|
|
136
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
|
137
|
+
acceptNode: (node) => {
|
|
138
|
+
const parent = (node as Text).parentElement
|
|
139
|
+
if (parent === null) return NodeFilter.FILTER_REJECT
|
|
140
|
+
if (parent.closest('code, pre, .md-code-block, [data-mermaid-processed], [data-html-inline]')) {
|
|
141
|
+
return NodeFilter.FILTER_REJECT
|
|
142
|
+
}
|
|
143
|
+
return TAGLIKE_TEXT_RE.test((node as Text).data)
|
|
144
|
+
? NodeFilter.FILTER_ACCEPT
|
|
145
|
+
: NodeFilter.FILTER_REJECT
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
const targets: Text[] = []
|
|
149
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) targets.push(node as Text)
|
|
150
|
+
for (const node of targets) {
|
|
151
|
+
const html = sanitizeInlineHtml(node.data, media)
|
|
152
|
+
if (html === null) continue
|
|
153
|
+
const span = document.createElement('span')
|
|
154
|
+
span.setAttribute('data-html-inline', '')
|
|
155
|
+
span.innerHTML = html
|
|
156
|
+
node.replaceWith(span)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface MarkdownSegmentProps {
|
|
161
|
+
text: string
|
|
162
|
+
hasMermaid: boolean
|
|
163
|
+
media: MarkdownHtmlMedia
|
|
164
|
+
codeLabels: { copyLabel: string; copiedLabel: string }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One markdown run of a split document: the shared MarkdownText pass (or the
|
|
169
|
+
* mermaid chunk renderer when the run contains a mermaid fence), plus the
|
|
170
|
+
* inline HTML pass. The pass runs after every text change and is re-armed by
|
|
171
|
+
* a MutationObserver so it also catches content that appears late (the lazy
|
|
172
|
+
* mermaid chunk mounting, shiki highlighting settling) — it is idempotent and
|
|
173
|
+
* skips its own output, so mutation feedback settles after one extra pass.
|
|
174
|
+
*/
|
|
175
|
+
function MarkdownSegment({ text, hasMermaid, media, codeLabels }: MarkdownSegmentProps): ReactNode {
|
|
176
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
177
|
+
useLayoutEffect(() => {
|
|
178
|
+
const container = containerRef.current
|
|
179
|
+
if (container === null) return
|
|
180
|
+
runInlineHtmlPass(container, media)
|
|
181
|
+
let scheduled = false
|
|
182
|
+
const observer = new MutationObserver(() => {
|
|
183
|
+
if (scheduled) return
|
|
184
|
+
scheduled = true
|
|
185
|
+
queueMicrotask(() => {
|
|
186
|
+
scheduled = false
|
|
187
|
+
runInlineHtmlPass(container, media)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
observer.observe(container, { childList: true, subtree: true })
|
|
191
|
+
return () => { observer.disconnect() }
|
|
192
|
+
}, [text, media])
|
|
193
|
+
return (
|
|
194
|
+
<div ref={containerRef}>
|
|
195
|
+
{hasMermaid
|
|
196
|
+
? <LazyMermaidMarkdown text={text} codeLabels={codeLabels} />
|
|
197
|
+
: <MarkdownText {...markdownTextProps(text, codeLabels)} />}
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A sanitized, balanced HTML span rendered as its own block. */
|
|
203
|
+
function HtmlLeaf({ html }: { html: string }): ReactNode {
|
|
204
|
+
return (
|
|
205
|
+
<div className={css.editorHtmlBlock} data-dsh-html-segment dangerouslySetInnerHTML={{ __html: html }} />
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One fully-prepared HTML run: sanitized leaves + wrapper opens/closes. */
|
|
210
|
+
type PreparedHtmlPart =
|
|
211
|
+
| { kind: 'html'; html: string }
|
|
212
|
+
| { kind: 'open'; tag: string; props: Record<string, string> | null }
|
|
213
|
+
| { kind: 'close' }
|
|
214
|
+
|
|
215
|
+
type PreparedSegment =
|
|
216
|
+
| { kind: 'markdown'; text: string; hasMermaid: boolean }
|
|
217
|
+
| { kind: 'html'; parts: PreparedHtmlPart[] }
|
|
218
|
+
|
|
219
|
+
interface MarkdownDocumentProps {
|
|
220
|
+
info: AnalyzedMarkdownHtml
|
|
221
|
+
media: MarkdownHtmlMedia
|
|
222
|
+
codeLabels: { copyLabel: string; copiedLabel: string }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The split-document renderer: markdown runs render through MarkdownSegment,
|
|
227
|
+
* HTML runs render as sanitized leaves, and unclosed block elements lower the
|
|
228
|
+
* following runs into themselves until their close part pops the frame (the
|
|
229
|
+
* renderer's frame stack persists across segments). Stray closes at the top
|
|
230
|
+
* level render nothing (the sanitizer/parser would drop them anyway), and
|
|
231
|
+
* frames still open at the end of the document are closed like a browser
|
|
232
|
+
* parser would. Sanitization runs once per prepared change, in a memo.
|
|
233
|
+
*/
|
|
234
|
+
export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentProps): ReactNode {
|
|
235
|
+
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
|
|
236
|
+
if (segment.kind === 'markdown') {
|
|
237
|
+
const defs = info.referenceDefinitions
|
|
238
|
+
const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
|
|
239
|
+
return {
|
|
240
|
+
kind: 'markdown',
|
|
241
|
+
text,
|
|
242
|
+
hasMermaid: splitMermaidBlocks(text).some((block) => block.kind === 'mermaid'),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
kind: 'html',
|
|
247
|
+
parts: analyzeHtmlSegment(segment.text).parts.map((part): PreparedHtmlPart => {
|
|
248
|
+
if (part.kind === 'html') return { kind: 'html', html: sanitizeHtmlBlock(part.html, media) }
|
|
249
|
+
if (part.kind === 'open') return { kind: 'open', tag: part.tag, props: sanitizeTagProps(part.tag, part.attrs) }
|
|
250
|
+
return { kind: 'close' }
|
|
251
|
+
}),
|
|
252
|
+
}
|
|
253
|
+
// `media` is a memoized object in the host (TextEditor); identity tracks
|
|
254
|
+
// scope/path/origin changes so sanitization re-runs exactly when needed.
|
|
255
|
+
}), [info, media])
|
|
256
|
+
|
|
257
|
+
const nodes: ReactNode[] = []
|
|
258
|
+
const frames: { tag: string; props: Record<string, string> | null; children: ReactNode[] }[] = []
|
|
259
|
+
const emit = (node: ReactNode): void => {
|
|
260
|
+
const frame = frames[frames.length - 1]
|
|
261
|
+
if (frame !== undefined) frame.children.push(node)
|
|
262
|
+
else nodes.push(node)
|
|
263
|
+
}
|
|
264
|
+
let key = 0
|
|
265
|
+
for (const segment of prepared) {
|
|
266
|
+
if (segment.kind === 'markdown') {
|
|
267
|
+
emit(<MarkdownSegment key={`md-${key += 1}`} text={segment.text} hasMermaid={segment.hasMermaid} media={media} codeLabels={codeLabels} />)
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
for (const part of segment.parts) {
|
|
271
|
+
if (part.kind === 'html') {
|
|
272
|
+
if (part.html.trim() === '') continue
|
|
273
|
+
emit(<HtmlLeaf key={`html-${key += 1}`} html={part.html} />)
|
|
274
|
+
} else if (part.kind === 'open') {
|
|
275
|
+
frames.push({ tag: part.tag, props: part.props, children: [] })
|
|
276
|
+
} else {
|
|
277
|
+
const frame = frames.pop()
|
|
278
|
+
if (frame === undefined) continue
|
|
279
|
+
if (frame.props === null) {
|
|
280
|
+
for (const child of frame.children) emit(child)
|
|
281
|
+
} else {
|
|
282
|
+
emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
while (frames.length > 0) {
|
|
288
|
+
const frame = frames.pop()!
|
|
289
|
+
if (frame.props === null) {
|
|
290
|
+
for (const child of frame.children) emit(child)
|
|
291
|
+
} else {
|
|
292
|
+
emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return <>{nodes}</>
|
|
296
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placeholder shown when a persisted tab's type is not registered (the
|
|
3
|
+
* contributing plugin is not installed / not yet loaded). The tab is kept
|
|
4
|
+
* in state so it can recover if the plugin loads later; the user can close
|
|
5
|
+
* it through the tab bar's X button as usual. This is the graceful-
|
|
6
|
+
* degradation path for the open tab-type set: {@link Sidebar} renders this
|
|
7
|
+
* instead of crashing on an unknown type.
|
|
8
|
+
*/
|
|
9
|
+
import type { ReactNode } from 'react'
|
|
10
|
+
import { t } from './locales.ts'
|
|
11
|
+
import css from './sidebar.module.css'
|
|
12
|
+
import type { TabComponentProps } from './service.ts'
|
|
13
|
+
|
|
14
|
+
export function OrphanedTab(props: TabComponentProps): ReactNode {
|
|
15
|
+
const { tab } = props
|
|
16
|
+
return (
|
|
17
|
+
<div className={css.editor}>
|
|
18
|
+
<div className={css.editorHeader}>
|
|
19
|
+
<span className={css.editorTitle} title={tab.type}>{tab.title}</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div className={css.editorPlaceholder}>
|
|
22
|
+
<span>{t('pluginNotLoaded')}</span>
|
|
23
|
+
<code className={css.orphanedType}>{tab.type}</code>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Browser-native PDF preview with an always-available download fallback. */
|
|
2
|
+
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
|
5
|
+
import { t } from './locales.ts'
|
|
6
|
+
import css from './sidebar.module.css'
|
|
7
|
+
|
|
8
|
+
export function PdfView(props: { scope: SessionScope; path: string; title: string }) {
|
|
9
|
+
const { scope, path, title } = props
|
|
10
|
+
const [load, setLoad] = useState<
|
|
11
|
+
| { status: 'loading' }
|
|
12
|
+
| { status: 'ready'; url: string }
|
|
13
|
+
| { status: 'error'; message: string }
|
|
14
|
+
>({ status: 'loading' })
|
|
15
|
+
const [interactionBlocked, setInteractionBlocked] = useState(false)
|
|
16
|
+
const frameRef = useRef<HTMLIFrameElement>(null)
|
|
17
|
+
const shieldRef = useRef<HTMLDivElement>(null)
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const controller = new AbortController()
|
|
21
|
+
let objectUrl: string | undefined
|
|
22
|
+
setLoad({ status: 'loading' })
|
|
23
|
+
void (async () => {
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
|
|
26
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
27
|
+
const bytes = await response.arrayBuffer()
|
|
28
|
+
if (controller.signal.aborted) return
|
|
29
|
+
// A direct iframe navigation may download when an old host process or
|
|
30
|
+
// proxy cached application/octet-stream. The Blob URL owns an explicit
|
|
31
|
+
// PDF MIME and therefore consistently opens the browser PDF viewer.
|
|
32
|
+
objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
|
|
33
|
+
setLoad({ status: 'ready', url: objectUrl })
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (controller.signal.aborted) return
|
|
36
|
+
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
|
37
|
+
}
|
|
38
|
+
})()
|
|
39
|
+
return () => {
|
|
40
|
+
controller.abort()
|
|
41
|
+
if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl)
|
|
42
|
+
}
|
|
43
|
+
}, [scope.sessionId, scope.cwd, path])
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const block = (): void => {
|
|
47
|
+
setInteractionBlocked(true)
|
|
48
|
+
if (frameRef.current !== null) frameRef.current.style.pointerEvents = 'none'
|
|
49
|
+
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'auto'
|
|
50
|
+
}
|
|
51
|
+
const unblock = (): void => {
|
|
52
|
+
setInteractionBlocked(false)
|
|
53
|
+
if (frameRef.current !== null) frameRef.current.style.pointerEvents = ''
|
|
54
|
+
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'none'
|
|
55
|
+
}
|
|
56
|
+
const blockForResize = (event: PointerEvent): void => {
|
|
57
|
+
const target = event.target
|
|
58
|
+
if (target instanceof Element
|
|
59
|
+
&& target.closest(`.${css.panelResize}, .${css.divider}`) !== null) {
|
|
60
|
+
block()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// An iframe is a separate browsing context and otherwise swallows the
|
|
64
|
+
// dragover/pointer stream as soon as the cursor crosses it. Temporarily
|
|
65
|
+
// remove it from hit-testing while a tab drag or pane resize is active.
|
|
66
|
+
document.addEventListener('dragstart', block, true)
|
|
67
|
+
document.addEventListener('dragend', unblock, true)
|
|
68
|
+
document.addEventListener('drop', unblock, true)
|
|
69
|
+
window.addEventListener('pointerdown', blockForResize, true)
|
|
70
|
+
window.addEventListener('pointerup', unblock, true)
|
|
71
|
+
window.addEventListener('pointercancel', unblock, true)
|
|
72
|
+
window.addEventListener('blur', unblock)
|
|
73
|
+
return () => {
|
|
74
|
+
document.removeEventListener('dragstart', block, true)
|
|
75
|
+
document.removeEventListener('dragend', unblock, true)
|
|
76
|
+
document.removeEventListener('drop', unblock, true)
|
|
77
|
+
window.removeEventListener('pointerdown', blockForResize, true)
|
|
78
|
+
window.removeEventListener('pointerup', unblock, true)
|
|
79
|
+
window.removeEventListener('pointercancel', unblock, true)
|
|
80
|
+
window.removeEventListener('blur', unblock)
|
|
81
|
+
}
|
|
82
|
+
}, [])
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className={css.editorPdf}>
|
|
86
|
+
<div className={css.editorPdfToolbar}>
|
|
87
|
+
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
|
88
|
+
{t('downloadToView')}
|
|
89
|
+
</a>
|
|
90
|
+
</div>
|
|
91
|
+
<div className={css.editorPdfStage}>
|
|
92
|
+
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
|
93
|
+
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
|
94
|
+
{load.status === 'ready' && (
|
|
95
|
+
<iframe
|
|
96
|
+
ref={frameRef}
|
|
97
|
+
className={clsx(css.editorPdfFrame, interactionBlocked && css.editorPdfFrameBlocked)}
|
|
98
|
+
src={load.url}
|
|
99
|
+
title={title}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
<div
|
|
103
|
+
ref={shieldRef}
|
|
104
|
+
className={clsx(css.editorPdfDragShield, interactionBlocked && css.editorPdfDragShieldActive)}
|
|
105
|
+
aria-hidden="true"
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generic render error boundary for the sidebar tree: a render error in
|
|
3
|
+
* the wrapped subtree shows a dismissible error strip (retry re-renders the
|
|
4
|
+
* children) instead of blanking the shell. Used at two scopes:
|
|
5
|
+
*
|
|
6
|
+
* - ROOT (index.tsx, `css.boundaryError`): last-resort containment for
|
|
7
|
+
* errors in the sidebar shell itself (Workbench, drag layout, …) — a full
|
|
8
|
+
* swap keeps the page alive.
|
|
9
|
+
* - PER-TAB (Sidebar.tsx TabContent, `css.tabBoundaryError`): a crashing
|
|
10
|
+
* viewer/editor shows a strip inside ITS OWN pane; the toggle cluster, the
|
|
11
|
+
* other tabs, and the panel itself stay alive (issue #31 — a tab crash
|
|
12
|
+
* must never take down the whole sidebar).
|
|
13
|
+
*
|
|
14
|
+
* The className prop selects the strip's geometry: the root's full-height
|
|
15
|
+
* fixed rail vs. the tab's pane-filling block.
|
|
16
|
+
*/
|
|
17
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
|
18
|
+
import { t } from './locales.ts'
|
|
19
|
+
import css from './sidebar.module.css'
|
|
20
|
+
|
|
21
|
+
export class RenderBoundary extends Component<{ children?: ReactNode; className?: string }, { error: string | null }> {
|
|
22
|
+
state = { error: null as string | null }
|
|
23
|
+
|
|
24
|
+
static getDerivedStateFromError(error: unknown): { error: string } {
|
|
25
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
29
|
+
console.error('[dsh-coding-sidebar] render error:', error, info.componentStack)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
render(): ReactNode {
|
|
33
|
+
if (this.state.error !== null) {
|
|
34
|
+
return (
|
|
35
|
+
<div className={this.props.className}>
|
|
36
|
+
<span>dsh-coding-sidebar: {this.state.error}</span>
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
className={css.terminalRetry}
|
|
40
|
+
onClick={() => { this.setState({ error: null }) }}
|
|
41
|
+
>
|
|
42
|
+
{t('terminalRetry')}
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
return this.props.children
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live sandbox status row of the two built-in web surfaces (HTML
|
|
3
|
+
* preview and the browser tab): a green "sandbox on" state with a one-tap
|
|
4
|
+
* TEMPORARY unlock, or a RED "sandbox off" state (global setting or the
|
|
5
|
+
* temporary unlock) with a restore action.
|
|
6
|
+
*
|
|
7
|
+
* The temporary unlock is component state only — it never writes the
|
|
8
|
+
* global side card setting (`htmlViewerNoSandbox` / `browserNoSandbox`);
|
|
9
|
+
* it lasts until the surface unmounts (tab switch / file switch) or the
|
|
10
|
+
* user restores the sandbox from the row. When the global setting already
|
|
11
|
+
* drops the sandbox, no unlock/restore action is offered (changing the
|
|
12
|
+
* global setting is the settings page's job) — the red warning stands.
|
|
13
|
+
*/
|
|
14
|
+
import clsx from 'clsx'
|
|
15
|
+
import { t } from './locales.ts'
|
|
16
|
+
import css from './sidebar.module.css'
|
|
17
|
+
|
|
18
|
+
export function SandboxStatusBar(props: {
|
|
19
|
+
/** The effective sandbox state (global pref OR the local temporary unlock). */
|
|
20
|
+
sandboxed: boolean
|
|
21
|
+
/** Whether the sandbox is off due to the LOCAL temporary unlock (shows the restore action). */
|
|
22
|
+
local: boolean
|
|
23
|
+
/** The red-state explanation (e.g. "the page runs with full GUI privileges"). */
|
|
24
|
+
dangerCopy: string
|
|
25
|
+
onUnlock: () => void
|
|
26
|
+
onRestore: () => void
|
|
27
|
+
}) {
|
|
28
|
+
const { sandboxed, local, dangerCopy, onUnlock, onRestore } = props
|
|
29
|
+
if (sandboxed) {
|
|
30
|
+
const copy = t('sandboxStatusOn')
|
|
31
|
+
return (
|
|
32
|
+
<div className={clsx(css.sandboxStatus, css.sandboxStatusOn)}>
|
|
33
|
+
<span className={css.sandboxDot} />
|
|
34
|
+
<span className={css.sandboxStatusText} title={copy}>{copy}</span>
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
className={css.sandboxAction}
|
|
38
|
+
onClick={onUnlock}
|
|
39
|
+
>
|
|
40
|
+
{t('sandboxUnlock')}
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
return (
|
|
46
|
+
<div className={clsx(css.sandboxStatus, css.sandboxStatusOff)}>
|
|
47
|
+
<span className={css.sandboxDot} />
|
|
48
|
+
<span className={css.sandboxStatusText} title={dangerCopy}>{dangerCopy}</span>
|
|
49
|
+
{local && (
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
className={css.sandboxAction}
|
|
53
|
+
onClick={onRestore}
|
|
54
|
+
>
|
|
55
|
+
{t('sandboxRestore')}
|
|
56
|
+
</button>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|