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