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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +8629 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +179 -0
- package/package.json +71 -0
- package/scripts/patch-layout.mjs +323 -0
- package/src/client/FileExplorer.tsx +374 -0
- package/src/client/FilePreview.tsx +353 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/files.module.css +536 -0
- package/src/client/highlight.ts +88 -0
- package/src/client/index.ts +45 -0
- package/src/client/locales.ts +39 -0
- package/src/client/store.ts +151 -0
- package/src/dsh.d.ts +62 -0
- package/src/index.ts +203 -0
|
@@ -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
|
+
}
|