dsh-plugin-workbench 0.0.4 → 0.0.6

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.
@@ -1,25 +1,33 @@
1
1
  /**
2
2
  * Split file preview with VS Code-style tabs and a directly-editable editor.
3
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.
4
+ * a syntax-highlighted layer (Ctrl/Cmd+S saves). Markdown files open in a
5
+ * RENDERED preview by default, with a button to switch to the editable source
6
+ * view. Tabs are drag-reorderable. Open files are watched on disk: external
7
+ * changes auto-sync clean tabs and flag dirty ones (click the badge to reload,
8
+ * discarding unsaved edits). Opening/closing the first/last tab is animated.
6
9
  */
7
- import { useCallback, useEffect, useRef, useState } from 'react'
10
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
8
11
  import type { DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react'
9
12
  import styles from './files.module.css'
10
13
  import { detectLanguage, highlightCode } from './highlight'
14
+ import { renderMarkdown } from './markdown'
11
15
  import { FileIcon } from './fileIcons'
12
16
  import type { FilesKey } from './locales'
13
17
  import { activateFile, closeFile, collapsePreview, moveTab, toggleWrap, useTabsState } from './store'
14
18
  import type { FsReadResult } from './FileExplorer'
15
19
 
16
20
  interface TabData {
17
- status: 'loading' | 'loaded' | 'binary' | 'too-large' | 'error'
21
+ status: 'loading' | 'loaded' | 'image' | 'binary' | 'too-large' | 'error'
18
22
  content?: string
19
23
  draft?: string
20
24
  dirty?: boolean
21
25
  size?: number
22
26
  message?: string
27
+ /** Markdown tabs: 'rendered' shows the compiled preview, 'source' the editor. */
28
+ view?: 'rendered' | 'source'
29
+ /** The file changed on disk while this tab holds unsaved edits. */
30
+ diskChanged?: boolean
23
31
  }
24
32
 
25
33
  export interface FsWriteResult {
@@ -31,12 +39,40 @@ export interface FilePreviewProps {
31
39
  t: (key: FilesKey, params?: Record<string, unknown>) => string
32
40
  readFile: (path: string, signal?: AbortSignal) => Promise<FsReadResult>
33
41
  writeFile: (path: string, content: string, signal?: AbortSignal) => Promise<FsWriteResult>
42
+ /** Replace the host-side watch set (the open tab paths). Idempotent diff. */
43
+ watchFiles: (paths: string[]) => Promise<void>
34
44
  }
35
45
 
36
46
  const PREVIEW_MIN = 240
37
47
  const CHAT_MIN = 240
38
48
  const PREVIEW_TOO_LARGE_LABEL = '512KB'
39
49
 
50
+ /** Same-origin raw-bytes route registered by the host half (see src/index.ts). */
51
+ const RAW_PREFIX = '/dsh-plugin-files/raw'
52
+
53
+ /** Same-origin SSE endpoint pushed by the host half (see src/index.ts). */
54
+ const EVENTS_ENDPOINT = '/dsh-plugin-files/events'
55
+
56
+ /**
57
+ * Above this size an .md file opens in source view: compiling a multi-hundred
58
+ * KB document and laying out its DOM is what makes the pane lag.
59
+ */
60
+ const MD_RENDER_MAX_BYTES = 256 * 1024
61
+
62
+ const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'])
63
+
64
+ /**
65
+ * Same-origin URL serving the file's bytes when it is a previewable image;
66
+ * undefined otherwise. Image tabs never go through the text RPC read — the
67
+ * host route resolves the path through the sandboxed fs service itself.
68
+ */
69
+ function imageSrcOf(path: string): string | undefined {
70
+ const idx = path.lastIndexOf('.')
71
+ if (idx < 0 || idx === path.length - 1) return undefined
72
+ const ext = path.slice(idx + 1).toLowerCase()
73
+ return IMAGE_EXTENSIONS.has(ext) ? `${RAW_PREFIX}/${encodeURIComponent(path)}` : undefined
74
+ }
75
+
40
76
  /**
41
77
  * Above this size the overlay editor (syntax-highlight layer + transparent
42
78
  * textarea) falls back to a plain textarea: re-injecting and re-laying out
@@ -72,13 +108,44 @@ function clamp(value: number, min: number, max: number): number {
72
108
  return Math.min(max, Math.max(min, value))
73
109
  }
74
110
 
111
+ /** Parent directory of a path ('C:/a/b.md' → 'C:/a'; '' when there is none). */
112
+ function dirnameOf(path: string): string {
113
+ const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
114
+ return idx > 0 ? path.slice(0, idx) : ''
115
+ }
116
+
75
117
  function previewDataOf(result: FsReadResult): TabData {
76
118
  if (result.truncated) return { status: 'too-large', size: result.size }
77
119
  if (result.binary) return { status: 'binary', size: result.size }
78
- return { status: 'loaded', content: result.content, draft: result.content, dirty: false, size: result.size }
120
+ const data: TabData = {
121
+ status: 'loaded',
122
+ content: result.content,
123
+ draft: result.content,
124
+ dirty: false,
125
+ size: result.size,
126
+ }
127
+ // Markdown opens in the rendered preview by default (source view for files
128
+ // too large to render responsively).
129
+ if (detectLanguage(result.path) === 'markdown' && result.size <= MD_RENDER_MAX_BYTES) {
130
+ data.view = 'rendered'
131
+ }
132
+ return data
133
+ }
134
+
135
+ /**
136
+ * Gutter text for an editor: one logical line number per source line, as a
137
+ * single pre-formatted block (VS Code shows logical numbers even when soft
138
+ * wrap makes a line occupy several visual rows).
139
+ */
140
+ function lineNumbersOf(content: string): string {
141
+ const count = content.split('\n').length
142
+ if (count <= 1) return '1'
143
+ const parts = new Array<string>(count)
144
+ for (let i = 0; i < count; i += 1) parts[i] = String(i + 1)
145
+ return parts.join('\n')
79
146
  }
80
147
 
81
- export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
148
+ export function FilePreview({ t, readFile, writeFile, watchFiles }: FilePreviewProps) {
82
149
  const { tabs, active, theme, collapsed, wrap } = useTabsState()
83
150
 
84
151
  const [previewWidth, setPreviewWidth] = useState<number | null>(null)
@@ -89,14 +156,106 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
89
156
  const cacheRef = useRef<Map<string, TabData>>(new Map())
90
157
  const hasOpenedRef = useRef(false)
91
158
  const [, bump] = useState(0)
159
+ const [imageFailed, setImageFailed] = useState<string | undefined>(undefined)
92
160
 
93
161
  const previewRef = useRef<HTMLDivElement>(null)
94
162
  const handleRef = useRef<HTMLDivElement>(null)
95
163
  const highlightRef = useRef<HTMLPreElement>(null)
96
164
  const textareaRef = useRef<HTMLTextAreaElement>(null)
165
+ const gutterRef = useRef<HTMLDivElement>(null)
97
166
 
98
167
  const refresh = useCallback(() => bump((v) => v + 1), [])
99
168
 
169
+ // Mirror of the current tab list for the SSE handler (avoids stale closures).
170
+ const tabsRef = useRef(tabs)
171
+ useEffect(() => {
172
+ tabsRef.current = tabs
173
+ }, [tabs])
174
+
175
+ /**
176
+ * Re-read one open file from disk (external edit sync, or the disk-changed
177
+ * badge click). A reload always takes disk content — the auto-sync path only
178
+ * runs on clean tabs, and the badge click is the user's explicit choice to
179
+ * drop unsaved edits.
180
+ */
181
+ const reloadFromDisk = useCallback(async (path: string) => {
182
+ const data = cacheRef.current.get(path)
183
+ if (data === undefined || data.status !== 'loaded') return
184
+ try {
185
+ const result = await readFile(path)
186
+ const next = previewDataOf(result)
187
+ // Keep the user's chosen view; a reload never flips rendered/source.
188
+ cacheRef.current.set(path, { ...next, view: data.view, diskChanged: false })
189
+ } catch (error) {
190
+ // File gone / unreadable: surface the failure instead of stale content.
191
+ cacheRef.current.set(path, {
192
+ ...data,
193
+ status: 'error',
194
+ message: error instanceof Error ? error.message : String(error),
195
+ diskChanged: false,
196
+ })
197
+ }
198
+ refresh()
199
+ }, [readFile, refresh])
200
+
201
+ /**
202
+ * Handle one disk-change event for an open path. Clean tabs auto-sync;
203
+ * dirty tabs keep their unsaved edits and show the reload badge instead.
204
+ */
205
+ const syncFromDisk = useCallback((path: string) => {
206
+ const data = cacheRef.current.get(path)
207
+ if (data === undefined || data.status === 'loading' || data.status === 'image') return
208
+ if (data.status === 'error') {
209
+ // Previously failed tab: retry the read (e.g. the file was re-created).
210
+ void reloadFromDisk(path)
211
+ return
212
+ }
213
+ if (data.dirty === true) {
214
+ if (data.diskChanged !== true) {
215
+ data.diskChanged = true
216
+ refresh()
217
+ }
218
+ return
219
+ }
220
+ void reloadFromDisk(path)
221
+ }, [reloadFromDisk, refresh])
222
+
223
+ // Disk watch: tell the host which paths to watch (debounced), and re-send on
224
+ // every SSE (re)connect so a dropped stream heals itself.
225
+ useEffect(() => {
226
+ const timer = setTimeout(() => {
227
+ void watchFiles([...tabs]).catch(() => {
228
+ // Host side not ready yet — the next tab change or SSE open retries.
229
+ })
230
+ }, 400)
231
+ return () => clearTimeout(timer)
232
+ }, [tabs, watchFiles])
233
+
234
+ // Disk change events pushed by the host (fs.watch + SSE).
235
+ useEffect(() => {
236
+ const source = new EventSource(EVENTS_ENDPOINT)
237
+ source.onopen = () => {
238
+ void watchFiles([...tabsRef.current]).catch(() => {
239
+ // Ignore — the next onopen or tab change re-syncs.
240
+ })
241
+ }
242
+ const onChange = (event: MessageEvent<string>) => {
243
+ try {
244
+ const payload = JSON.parse(event.data) as { path?: unknown }
245
+ if (typeof payload.path === 'string' && tabsRef.current.includes(payload.path)) {
246
+ syncFromDisk(payload.path)
247
+ }
248
+ } catch {
249
+ // Malformed frame — ignore.
250
+ }
251
+ }
252
+ source.addEventListener('change', onChange)
253
+ return () => {
254
+ source.removeEventListener('change', onChange)
255
+ source.close()
256
+ }
257
+ }, [syncFromDisk, watchFiles])
258
+
100
259
  // Read the ACTIVE tab's content. Other tabs load lazily on first
101
260
  // activation, so switching to a workspace with many large files doesn't
102
261
  // re-read every tab at once (which used to freeze the switch).
@@ -111,6 +270,13 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
111
270
  return undefined
112
271
  }
113
272
  const controller = new AbortController()
273
+ // Image files skip the text RPC read entirely: the raw-bytes route serves
274
+ // them straight into an <img> tag, so there is nothing to load here.
275
+ if (imageSrcOf(target) !== undefined) {
276
+ cache.set(target, { status: 'image' })
277
+ refresh()
278
+ return () => controller.abort()
279
+ }
114
280
  cache.set(target, { status: 'loading' })
115
281
  readFile(target, controller.signal)
116
282
  .then((result) => {
@@ -134,6 +300,12 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
134
300
  return () => controller.abort()
135
301
  }, [tabs, active, readFile, refresh])
136
302
 
303
+ // A failed <img> (deleted file, oversized, …) must not keep showing the
304
+ // broken-image placeholder when the user switches away and back.
305
+ useEffect(() => {
306
+ setImageFailed(undefined)
307
+ }, [active])
308
+
137
309
  // Animate the last tab closing without a mount/unmount bounce: keep the pane
138
310
  // mounted while `hasOpenedRef` is set, then drop it after the transition.
139
311
  if (tabs.length > 0) hasOpenedRef.current = true
@@ -159,26 +331,44 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
159
331
  const language = active !== undefined ? detectLanguage(active) : undefined
160
332
  const tooBig = (activeData?.size ?? 0) > HIGHLIGHT_MAX_BYTES
161
333
  const plain = language === undefined || tooBig || (language !== undefined && PLAIN_LANGUAGES.has(language))
334
+ const isMarkdown = language === 'markdown'
335
+ const mdRenderable = (activeData?.size ?? 0) <= MD_RENDER_MAX_BYTES
336
+ // Rebuild the compiled markdown only when the draft (or the view mode)
337
+ // changes — never on tab switches or focus re-renders.
338
+ const mdHtml = useMemo(() => {
339
+ if (activeData?.status !== 'loaded' || !isMarkdown || activeData.view !== 'rendered') return ''
340
+ return renderMarkdown(activeData.draft ?? '', dirnameOf(active ?? ''))
341
+ }, [activeData?.status, activeData?.draft, activeData?.view, isMarkdown, active])
342
+ // Rebuild the gutter text only when the draft changes (not on every bump
343
+ // from tab switches or focus re-renders).
344
+ const gutterNumbers = useMemo(
345
+ () => (activeData?.status === 'loaded' ? lineNumbersOf(activeData.draft ?? '') : ''),
346
+ [activeData?.status, activeData?.draft],
347
+ )
162
348
 
163
- // Keep the highlight layer's scroll in lockstep with the textarea on every
164
- // frame while the overlay editor is open: some engines don't fire scroll
165
- // events during selection auto-scroll, which would otherwise leave the
166
- // visible layer behind the selection (drag-select misalignment). Both
167
- // layers are `overflow: auto` with identical content, so the assignment is
168
- // a no-op whenever they are already aligned.
349
+ // Keep the highlight layer and the line-number gutter in lockstep with the
350
+ // textarea on every frame while the editor is open: some engines don't fire
351
+ // scroll events during selection auto-scroll, which would otherwise leave
352
+ // the visible layer and the numbers behind the selection (drag-select
353
+ // misalignment). All layers are `overflow: auto|hidden` with identical
354
+ // content geometry, so the assignment is a no-op whenever they align.
169
355
  useEffect(() => {
170
356
  const pre = highlightRef.current
171
357
  const ta = textareaRef.current
172
- if (pre === null || ta === null || plain) return undefined
358
+ const gutter = gutterRef.current
359
+ if (ta === null || gutter === null) return undefined
173
360
  let raf = 0
174
361
  const tick = () => {
175
- pre.scrollTop = ta.scrollTop
176
- pre.scrollLeft = ta.scrollLeft
362
+ if (pre !== null) {
363
+ pre.scrollTop = ta.scrollTop
364
+ pre.scrollLeft = ta.scrollLeft
365
+ }
366
+ gutter.scrollTop = ta.scrollTop
177
367
  raf = requestAnimationFrame(tick)
178
368
  }
179
369
  raf = requestAnimationFrame(tick)
180
370
  return () => cancelAnimationFrame(raf)
181
- }, [isOpen, active, activeData?.status, plain])
371
+ }, [isOpen, active, activeData?.status, activeData?.view, plain])
182
372
 
183
373
  // Publish the rendered preview width so the skin's fixed top/bottom trim can
184
374
  // shift past this pane (covering only the chat).
@@ -249,6 +439,15 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
249
439
  refresh()
250
440
  }, [active, refresh])
251
441
 
442
+ /** Flip the active markdown tab between rendered preview and source editor. */
443
+ const toggleMdView = useCallback(() => {
444
+ if (active === undefined) return
445
+ const data = cacheRef.current.get(active)
446
+ if (data === undefined || data.status !== 'loaded') return
447
+ data.view = data.view === 'rendered' ? 'source' : 'rendered'
448
+ refresh()
449
+ }, [active, refresh])
450
+
252
451
  const onKeyDown = useCallback((e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
253
452
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
254
453
  e.preventDefault()
@@ -258,11 +457,13 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
258
457
 
259
458
  const onTextareaScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
260
459
  const pre = highlightRef.current
460
+ const gutter = gutterRef.current
261
461
  const ta = e.currentTarget
262
462
  if (pre !== null) {
263
463
  pre.scrollTop = ta.scrollTop
264
464
  pre.scrollLeft = ta.scrollLeft
265
465
  }
466
+ if (gutter !== null) gutter.scrollTop = ta.scrollTop
266
467
  }, [])
267
468
 
268
469
  const onTabDragStart = useCallback((e: ReactDragEvent<HTMLDivElement>, path: string) => {
@@ -293,11 +494,22 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
293
494
  case 'loading':
294
495
  return <div className={styles.previewHint}>{t('preview.loading')}</div>
295
496
  case 'loaded':
497
+ if (isMarkdown && activeData.view === 'rendered') {
498
+ return (
499
+ <div
500
+ className={styles.mdPreview}
501
+ dangerouslySetInnerHTML={{ __html: mdHtml }}
502
+ />
503
+ )
504
+ }
296
505
  return (
297
506
  <div
298
507
  className={`${styles.editor}${plain ? ` ${styles.editorPlain}` : ''}`}
299
508
  data-wrap={wrap ? 'on' : 'off'}
300
509
  >
510
+ <div ref={gutterRef} className={styles.gutter} aria-hidden="true">
511
+ <div className={styles.gutterNumbers}>{gutterNumbers}</div>
512
+ </div>
301
513
  {!plain && active !== undefined && (
302
514
  <pre ref={highlightRef} className={styles.editorHighlight} aria-hidden="true">
303
515
  <code dangerouslySetInnerHTML={{ __html: highlightCode(activeData.draft ?? '', active) }} />
@@ -313,10 +525,37 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
313
525
  spellCheck={false}
314
526
  wrap={wrap ? 'soft' : 'off'}
315
527
  />
316
- {language !== undefined && tooBig && <div className={styles.editorHint}>{t('preview.highlightOff')}</div>}
528
+ {isMarkdown && !mdRenderable && <div className={styles.editorHint}>{t('preview.mdRenderOff')}</div>}
529
+ {language !== undefined && !isMarkdown && tooBig && <div className={styles.editorHint}>{t('preview.highlightOff')}</div>}
317
530
  {saveError !== undefined && <div className={styles.saveError}>{saveError}</div>}
318
531
  </div>
319
532
  )
533
+ case 'image': {
534
+ const src = active !== undefined ? imageSrcOf(active) : undefined
535
+ const name = active !== undefined ? basenameOf(active) : ''
536
+ if (src === undefined) {
537
+ return <div className={styles.previewHint}>{t('preview.binary')}</div>
538
+ }
539
+ if (imageFailed === active) {
540
+ return (
541
+ <div className={styles.previewHint}>
542
+ <div>{t('preview.imageFailed')}</div>
543
+ <div className={styles.previewMeta}>{name}</div>
544
+ </div>
545
+ )
546
+ }
547
+ return (
548
+ <div className={styles.imageView}>
549
+ <img
550
+ className={styles.image}
551
+ src={src}
552
+ alt={name}
553
+ onError={() => setImageFailed(active)}
554
+ />
555
+ <div className={styles.previewMeta}>{name}</div>
556
+ </div>
557
+ )
558
+ }
320
559
  case 'binary':
321
560
  return (
322
561
  <div className={styles.previewHint}>
@@ -373,6 +612,19 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
373
612
  <span className={styles.tabIcon}><FileIcon name={basenameOf(path)} /></span>
374
613
  <span className={styles.tabName}>{basenameOf(path)}</span>
375
614
  {data?.dirty === true && <span className={styles.tabDot} />}
615
+ {data?.diskChanged === true && (
616
+ <button
617
+ type="button"
618
+ className={styles.tabDiskBadge}
619
+ title={t('tab.diskChanged')}
620
+ onClick={(e) => {
621
+ e.stopPropagation()
622
+ void reloadFromDisk(path)
623
+ }}
624
+ >
625
+ {'⟳'}
626
+ </button>
627
+ )}
376
628
  <button
377
629
  type="button"
378
630
  className={styles.tabClose}
@@ -388,6 +640,16 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
388
640
  )
389
641
  })}
390
642
  </div>
643
+ {isMarkdown && activeData?.status === 'loaded' && mdRenderable && (
644
+ <button
645
+ type="button"
646
+ className={styles.tabAction}
647
+ title={activeData.view === 'rendered' ? t('action.mdSource') : t('action.mdRender')}
648
+ onClick={toggleMdView}
649
+ >
650
+ {activeData.view === 'rendered' ? '📝' : '👁'}
651
+ </button>
652
+ )}
391
653
  <button
392
654
  type="button"
393
655
  className={`${styles.tabAction}${wrap ? ` ${styles.tabActionActive}` : ''}`}