dsh-plugin-workbench 0.0.1

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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Split file preview with VS Code-style tabs and a directly-editable editor.
3
+ * Files open in tabs; the active tab is an always-editable textarea overlaid on
4
+ * a syntax-highlighted layer (Ctrl/Cmd+S saves). Tabs are drag-reorderable.
5
+ * Opening/closing the first/last tab is animated.
6
+ */
7
+ import { useCallback, useEffect, useRef, useState } from 'react'
8
+ import type { DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react'
9
+ import styles from './files.module.css'
10
+ import { highlightCode } from './highlight'
11
+ import type { FilesKey } from './locales'
12
+ import { activateFile, closeFile, collapsePreview, moveTab, useTabsState } from './store'
13
+ import type { FsReadResult } from './FileExplorer'
14
+
15
+ interface TabData {
16
+ status: 'loading' | 'loaded' | 'binary' | 'too-large' | 'error'
17
+ content?: string
18
+ draft?: string
19
+ dirty?: boolean
20
+ size?: number
21
+ message?: string
22
+ }
23
+
24
+ export interface FsWriteResult {
25
+ path: string
26
+ size: number
27
+ }
28
+
29
+ export interface FilePreviewProps {
30
+ t: (key: FilesKey, params?: Record<string, unknown>) => string
31
+ readFile: (path: string, signal?: AbortSignal) => Promise<FsReadResult>
32
+ writeFile: (path: string, content: string, signal?: AbortSignal) => Promise<FsWriteResult>
33
+ }
34
+
35
+ const PREVIEW_MIN = 240
36
+ const CHAT_MIN = 240
37
+ const PREVIEW_TOO_LARGE_LABEL = '512KB'
38
+
39
+ function basenameOf(path: string): string {
40
+ const trimmed = path.replace(/[\\/]+$/, '')
41
+ const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
42
+ return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
43
+ }
44
+
45
+ function formatSize(bytes: number | undefined): string {
46
+ if (bytes === undefined) return ''
47
+ if (!Number.isFinite(bytes) || bytes < 0) return ''
48
+ if (bytes < 1024) return `${bytes} B`
49
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
50
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
51
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
52
+ }
53
+
54
+ function clamp(value: number, min: number, max: number): number {
55
+ return Math.min(max, Math.max(min, value))
56
+ }
57
+
58
+ function previewDataOf(result: FsReadResult): TabData {
59
+ if (result.truncated) return { status: 'too-large', size: result.size }
60
+ if (result.binary) return { status: 'binary', size: result.size }
61
+ return { status: 'loaded', content: result.content, draft: result.content, dirty: false, size: result.size }
62
+ }
63
+
64
+ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
65
+ const { tabs, active, theme, collapsed } = useTabsState()
66
+
67
+ const [previewWidth, setPreviewWidth] = useState<number | null>(null)
68
+ const [closing, setClosing] = useState(false)
69
+ const [dragPath, setDragPath] = useState<string | undefined>(undefined)
70
+ const [saveError, setSaveError] = useState<string | undefined>(undefined)
71
+
72
+ const cacheRef = useRef<Map<string, TabData>>(new Map())
73
+ const hasOpenedRef = useRef(false)
74
+ const [, bump] = useState(0)
75
+
76
+ const previewRef = useRef<HTMLDivElement>(null)
77
+ const handleRef = useRef<HTMLDivElement>(null)
78
+ const highlightRef = useRef<HTMLPreElement>(null)
79
+
80
+ const refresh = useCallback(() => bump((v) => v + 1), [])
81
+
82
+ // Read newly opened tabs and drop cache entries for closed tabs.
83
+ useEffect(() => {
84
+ const cache = cacheRef.current
85
+ for (const [path] of cache) {
86
+ if (!tabs.includes(path)) cache.delete(path)
87
+ }
88
+ const controllers: AbortController[] = []
89
+ for (const path of tabs) {
90
+ if (cache.has(path)) continue
91
+ cache.set(path, { status: 'loading' })
92
+ const controller = new AbortController()
93
+ controllers.push(controller)
94
+ readFile(path, controller.signal)
95
+ .then((result) => {
96
+ if (!controller.signal.aborted) {
97
+ cache.set(path, previewDataOf(result))
98
+ refresh()
99
+ }
100
+ })
101
+ .catch((error) => {
102
+ if (!controller.signal.aborted) {
103
+ cache.set(path, { status: 'error', message: error instanceof Error ? error.message : String(error) })
104
+ refresh()
105
+ }
106
+ })
107
+ }
108
+ refresh()
109
+ return () => controllers.forEach((c) => c.abort())
110
+ }, [tabs, readFile, refresh])
111
+
112
+ // Animate the last tab closing without a mount/unmount bounce: keep the pane
113
+ // mounted while `hasOpenedRef` is set, then drop it after the transition.
114
+ if (tabs.length > 0) hasOpenedRef.current = true
115
+ useEffect(() => {
116
+ if (tabs.length > 0) {
117
+ setClosing(false)
118
+ return undefined
119
+ }
120
+ if (hasOpenedRef.current) {
121
+ setClosing(true)
122
+ const timer = setTimeout(() => {
123
+ setClosing(false)
124
+ hasOpenedRef.current = false
125
+ }, 200)
126
+ return () => clearTimeout(timer)
127
+ }
128
+ return undefined
129
+ }, [tabs.length])
130
+
131
+ const isOpen = !collapsed && (tabs.length > 0 || closing || hasOpenedRef.current)
132
+
133
+ // Publish the rendered preview width so the skin's fixed top/bottom trim can
134
+ // shift past this pane (covering only the chat).
135
+ useEffect(() => {
136
+ if (!isOpen) {
137
+ document.body.style.removeProperty('--dsh-preview-width')
138
+ return undefined
139
+ }
140
+ const el = previewRef.current
141
+ if (el === null) return undefined
142
+ const update = () => {
143
+ document.body.style.setProperty('--dsh-preview-width', `${el.getBoundingClientRect().width}px`)
144
+ }
145
+ update()
146
+ const observer = new ResizeObserver(update)
147
+ observer.observe(el)
148
+ return () => {
149
+ observer.disconnect()
150
+ document.body.style.removeProperty('--dsh-preview-width')
151
+ }
152
+ }, [isOpen])
153
+
154
+ const onHandleDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
155
+ e.preventDefault()
156
+ const handle = handleRef.current
157
+ const preview = previewRef.current
158
+ if (handle === null || preview === null) return
159
+ const center = handle.parentElement
160
+ if (center === null) return
161
+ const startX = e.clientX
162
+ const startWidth = preview.getBoundingClientRect().width
163
+ const centerWidth = center.getBoundingClientRect().width
164
+ const max = Math.max(PREVIEW_MIN, centerWidth - CHAT_MIN)
165
+ const onMove = (ev: PointerEvent) => {
166
+ setPreviewWidth(clamp(startWidth + ev.clientX - startX, PREVIEW_MIN, max))
167
+ }
168
+ const onUp = () => {
169
+ window.removeEventListener('pointermove', onMove)
170
+ window.removeEventListener('pointerup', onUp)
171
+ }
172
+ window.addEventListener('pointermove', onMove)
173
+ window.addEventListener('pointerup', onUp)
174
+ }, [])
175
+
176
+ const onSave = useCallback(async () => {
177
+ if (active === undefined) return
178
+ const data = cacheRef.current.get(active)
179
+ if (data === undefined || data.status !== 'loaded' || data.draft === undefined || !data.dirty) return
180
+ try {
181
+ const result = await writeFile(active, data.draft)
182
+ data.content = data.draft
183
+ data.dirty = false
184
+ data.size = result.size
185
+ setSaveError(undefined)
186
+ } catch (error) {
187
+ setSaveError(error instanceof Error ? error.message : String(error))
188
+ }
189
+ refresh()
190
+ }, [active, writeFile, refresh])
191
+
192
+ const onTextareaChange = useCallback((value: string) => {
193
+ if (active === undefined) return
194
+ const data = cacheRef.current.get(active)
195
+ if (data === undefined || data.status !== 'loaded') return
196
+ data.draft = value
197
+ data.dirty = value !== data.content
198
+ if (data.dirty === false) setSaveError(undefined)
199
+ refresh()
200
+ }, [active, refresh])
201
+
202
+ const onKeyDown = useCallback((e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
203
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
204
+ e.preventDefault()
205
+ void onSave()
206
+ }
207
+ }, [onSave])
208
+
209
+ const onTextareaScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
210
+ const pre = highlightRef.current
211
+ const ta = e.currentTarget
212
+ if (pre !== null) {
213
+ pre.scrollTop = ta.scrollTop
214
+ pre.scrollLeft = ta.scrollLeft
215
+ }
216
+ }, [])
217
+
218
+ const onTabDragStart = useCallback((e: ReactDragEvent<HTMLDivElement>, path: string) => {
219
+ setDragPath(path)
220
+ e.dataTransfer.effectAllowed = 'move'
221
+ try {
222
+ e.dataTransfer.setData('text/plain', path)
223
+ } catch {
224
+ /* some browsers restrict dataTransfer — ignore */
225
+ }
226
+ }, [])
227
+
228
+ const onTabDragOver = useCallback((e: ReactDragEvent<HTMLDivElement>, path: string) => {
229
+ e.preventDefault()
230
+ e.dataTransfer.dropEffect = 'move'
231
+ if (dragPath !== undefined && dragPath !== path) moveTab(dragPath, path)
232
+ }, [dragPath])
233
+
234
+ const onTabDragEnd = useCallback(() => {
235
+ setDragPath(undefined)
236
+ }, [])
237
+
238
+ const activeData = active !== undefined ? cacheRef.current.get(active) : undefined
239
+
240
+ if (!isOpen) return null
241
+
242
+ const renderBody = () => {
243
+ if (activeData === undefined) return <div className={styles.previewHint}>{t('preview.loading')}</div>
244
+ switch (activeData.status) {
245
+ case 'loading':
246
+ return <div className={styles.previewHint}>{t('preview.loading')}</div>
247
+ case 'loaded': {
248
+ const highlighted = active !== undefined ? highlightCode(activeData.draft ?? '', active) : ''
249
+ return (
250
+ <div className={styles.editor}>
251
+ <pre ref={highlightRef} className={styles.editorHighlight} aria-hidden="true">
252
+ <code dangerouslySetInnerHTML={{ __html: highlighted }} />
253
+ </pre>
254
+ <textarea
255
+ className={styles.editorTextarea}
256
+ value={activeData.draft ?? ''}
257
+ onChange={(e) => onTextareaChange(e.target.value)}
258
+ onScroll={onTextareaScroll}
259
+ onKeyDown={onKeyDown}
260
+ spellCheck={false}
261
+ wrap="off"
262
+ title={t('action.saveHint')}
263
+ />
264
+ {saveError !== undefined && <div className={styles.saveError}>{saveError}</div>}
265
+ </div>
266
+ )
267
+ }
268
+ case 'binary':
269
+ return (
270
+ <div className={styles.previewHint}>
271
+ <div>{t('preview.binary')}</div>
272
+ <div className={styles.previewMeta}>{formatSize(activeData.size)}</div>
273
+ </div>
274
+ )
275
+ case 'too-large':
276
+ return (
277
+ <div className={styles.previewHint}>
278
+ <div>{t('preview.tooLarge', { limit: PREVIEW_TOO_LARGE_LABEL })}</div>
279
+ <div className={styles.previewMeta}>{formatSize(activeData.size)}</div>
280
+ </div>
281
+ )
282
+ case 'error':
283
+ return (
284
+ <div className={styles.previewHint}>
285
+ <div>{t('preview.error')}</div>
286
+ <div className={styles.previewMeta}>{activeData.message}</div>
287
+ </div>
288
+ )
289
+ }
290
+ }
291
+
292
+ const previewClassName = closing && tabs.length === 0
293
+ ? `${styles.preview} ${styles.previewClosing}`
294
+ : styles.preview
295
+
296
+ return (
297
+ <>
298
+ <div
299
+ ref={previewRef}
300
+ className={previewClassName}
301
+ style={previewWidth !== null ? { flex: `0 0 ${previewWidth}px` } : undefined}
302
+ data-pane="explorer-preview"
303
+ data-fe-theme={theme}
304
+ >
305
+ {tabs.length > 0 && (
306
+ <div className={styles.tabBar}>
307
+ <div className={styles.tabScroller}>
308
+ {tabs.map((path) => {
309
+ const data = cacheRef.current.get(path)
310
+ return (
311
+ <div
312
+ key={path}
313
+ className={`${styles.tab} ${path === active ? styles.tabActive : ''}`}
314
+ onClick={() => activateFile(path)}
315
+ draggable
316
+ onDragStart={(e) => onTabDragStart(e, path)}
317
+ onDragOver={(e) => onTabDragOver(e, path)}
318
+ onDragEnd={onTabDragEnd}
319
+ title={path}
320
+ >
321
+ <span className={styles.tabName}>{basenameOf(path)}</span>
322
+ {data?.dirty === true && <span className={styles.tabDot} />}
323
+ <button
324
+ type="button"
325
+ className={styles.tabClose}
326
+ title={t('tab.close')}
327
+ onClick={(e) => {
328
+ e.stopPropagation()
329
+ closeFile(path)
330
+ }}
331
+ >
332
+
333
+ </button>
334
+ </div>
335
+ )
336
+ })}
337
+ </div>
338
+ <button
339
+ type="button"
340
+ className={styles.collapse}
341
+ title={t('tab.collapse')}
342
+ onClick={collapsePreview}
343
+ >
344
+ {'<'}
345
+ </button>
346
+ </div>
347
+ )}
348
+ <div className={styles.previewBody}>{renderBody()}</div>
349
+ </div>
350
+ <div ref={handleRef} className={styles.handle} onPointerDown={onHandleDown} role="separator" aria-orientation="vertical" />
351
+ </>
352
+ )
353
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Ambient declarations for CSS Modules. tsdown inlines these at build time;
3
+ * this file only gives `tsc --noEmit` a stable shape to type against.
4
+ */
5
+ declare module '*.module.css' {
6
+ const classes: { readonly [key: string]: string }
7
+ export default classes
8
+ }