dsh-plugin-workbench 0.0.5 → 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,13 +1,17 @@
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
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'
@@ -20,6 +24,10 @@ interface TabData {
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,6 +39,8 @@ 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
@@ -40,6 +50,15 @@ const PREVIEW_TOO_LARGE_LABEL = '512KB'
40
50
  /** Same-origin raw-bytes route registered by the host half (see src/index.ts). */
41
51
  const RAW_PREFIX = '/dsh-plugin-files/raw'
42
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
+
43
62
  const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'])
44
63
 
45
64
  /**
@@ -89,10 +108,28 @@ function clamp(value: number, min: number, max: number): number {
89
108
  return Math.min(max, Math.max(min, value))
90
109
  }
91
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
+
92
117
  function previewDataOf(result: FsReadResult): TabData {
93
118
  if (result.truncated) return { status: 'too-large', size: result.size }
94
119
  if (result.binary) return { status: 'binary', size: result.size }
95
- 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
96
133
  }
97
134
 
98
135
  /**
@@ -108,7 +145,7 @@ function lineNumbersOf(content: string): string {
108
145
  return parts.join('\n')
109
146
  }
110
147
 
111
- export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
148
+ export function FilePreview({ t, readFile, writeFile, watchFiles }: FilePreviewProps) {
112
149
  const { tabs, active, theme, collapsed, wrap } = useTabsState()
113
150
 
114
151
  const [previewWidth, setPreviewWidth] = useState<number | null>(null)
@@ -129,6 +166,96 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
129
166
 
130
167
  const refresh = useCallback(() => bump((v) => v + 1), [])
131
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
+
132
259
  // Read the ACTIVE tab's content. Other tabs load lazily on first
133
260
  // activation, so switching to a workspace with many large files doesn't
134
261
  // re-read every tab at once (which used to freeze the switch).
@@ -204,6 +331,14 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
204
331
  const language = active !== undefined ? detectLanguage(active) : undefined
205
332
  const tooBig = (activeData?.size ?? 0) > HIGHLIGHT_MAX_BYTES
206
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])
207
342
  // Rebuild the gutter text only when the draft changes (not on every bump
208
343
  // from tab switches or focus re-renders).
209
344
  const gutterNumbers = useMemo(
@@ -233,7 +368,7 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
233
368
  }
234
369
  raf = requestAnimationFrame(tick)
235
370
  return () => cancelAnimationFrame(raf)
236
- }, [isOpen, active, activeData?.status, plain])
371
+ }, [isOpen, active, activeData?.status, activeData?.view, plain])
237
372
 
238
373
  // Publish the rendered preview width so the skin's fixed top/bottom trim can
239
374
  // shift past this pane (covering only the chat).
@@ -304,6 +439,15 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
304
439
  refresh()
305
440
  }, [active, refresh])
306
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
+
307
451
  const onKeyDown = useCallback((e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
308
452
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
309
453
  e.preventDefault()
@@ -350,6 +494,14 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
350
494
  case 'loading':
351
495
  return <div className={styles.previewHint}>{t('preview.loading')}</div>
352
496
  case 'loaded':
497
+ if (isMarkdown && activeData.view === 'rendered') {
498
+ return (
499
+ <div
500
+ className={styles.mdPreview}
501
+ dangerouslySetInnerHTML={{ __html: mdHtml }}
502
+ />
503
+ )
504
+ }
353
505
  return (
354
506
  <div
355
507
  className={`${styles.editor}${plain ? ` ${styles.editorPlain}` : ''}`}
@@ -373,7 +525,8 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
373
525
  spellCheck={false}
374
526
  wrap={wrap ? 'soft' : 'off'}
375
527
  />
376
- {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>}
377
530
  {saveError !== undefined && <div className={styles.saveError}>{saveError}</div>}
378
531
  </div>
379
532
  )
@@ -459,6 +612,19 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
459
612
  <span className={styles.tabIcon}><FileIcon name={basenameOf(path)} /></span>
460
613
  <span className={styles.tabName}>{basenameOf(path)}</span>
461
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
+ )}
462
628
  <button
463
629
  type="button"
464
630
  className={styles.tabClose}
@@ -474,6 +640,16 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
474
640
  )
475
641
  })}
476
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
+ )}
477
653
  <button
478
654
  type="button"
479
655
  className={`${styles.tabAction}${wrap ? ` ${styles.tabActionActive}` : ''}`}
@@ -128,6 +128,12 @@
128
128
  padding: 4px;
129
129
  }
130
130
 
131
+ /* Keyboard focus lands on the tree container (rows push focus here so
132
+ Ctrl+C / Ctrl+V work right after a click); no visible focus ring. */
133
+ .treeArea:focus {
134
+ outline: none;
135
+ }
136
+
131
137
  .row {
132
138
  display: flex;
133
139
  align-items: center;
@@ -153,6 +159,42 @@
153
159
  color: var(--fe-fg);
154
160
  }
155
161
 
162
+ /* Cut items stay visible but ghosted until the paste lands (OS explorer look). */
163
+ .rowCut {
164
+ opacity: 0.45;
165
+ }
166
+
167
+ /* A directory row that will receive a drag-drop move gets an accent outline. */
168
+ .rowDropTarget {
169
+ box-shadow: inset 0 0 0 1.5px var(--fe-accent);
170
+ background: var(--fe-selection);
171
+ }
172
+
173
+ /* ---- Clipboard status bar (Copy / Cut + Paste) ---- */
174
+ .clipboardBar {
175
+ flex: none;
176
+ display: flex;
177
+ align-items: center;
178
+ gap: 6px;
179
+ padding: 4px 8px;
180
+ border-bottom: 1px solid var(--fe-border);
181
+ background: var(--fe-hover);
182
+ color: var(--fe-fg);
183
+ font-size: 12px;
184
+ }
185
+
186
+ .clipboardText {
187
+ flex: 1;
188
+ min-width: 0;
189
+ overflow: hidden;
190
+ text-overflow: ellipsis;
191
+ white-space: nowrap;
192
+ }
193
+
194
+ .clipboardHint {
195
+ color: var(--fe-faint);
196
+ }
197
+
156
198
  .chevron {
157
199
  flex: none;
158
200
  width: 12px;
@@ -277,6 +319,12 @@
277
319
  color: #f48771 !important;
278
320
  }
279
321
 
322
+ .contextMenuItemDisabled {
323
+ opacity: 0.45;
324
+ cursor: default;
325
+ pointer-events: none;
326
+ }
327
+
280
328
  .contextMenuDivider {
281
329
  height: 1px;
282
330
  margin: 4px 6px;
@@ -457,6 +505,31 @@
457
505
  opacity: 0.85;
458
506
  }
459
507
 
508
+ /* Disk-changed badge: the file changed on disk while this tab holds unsaved
509
+ edits; click to reload and discard them. */
510
+ .tabDiskBadge {
511
+ flex: none;
512
+ appearance: none;
513
+ border: none;
514
+ background: transparent;
515
+ color: var(--fe-accent);
516
+ cursor: pointer;
517
+ font-size: 12px;
518
+ line-height: 1;
519
+ width: 16px;
520
+ height: 16px;
521
+ border-radius: 3px;
522
+ display: inline-flex;
523
+ align-items: center;
524
+ justify-content: center;
525
+ padding: 0;
526
+ }
527
+
528
+ .tabDiskBadge:hover {
529
+ background: var(--fe-hover);
530
+ color: var(--fe-fg);
531
+ }
532
+
460
533
  .previewBody {
461
534
  flex: 1;
462
535
  min-height: 0;
@@ -639,6 +712,212 @@
639
712
  font-size: 11px;
640
713
  }
641
714
 
715
+ /* ---- Markdown rendered preview ---- */
716
+ .mdPreview {
717
+ box-sizing: border-box;
718
+ height: 100%;
719
+ overflow: auto;
720
+ padding: 16px 20px 48px;
721
+ color: var(--fe-fg);
722
+ font-size: 14px;
723
+ line-height: 1.7;
724
+ word-wrap: break-word;
725
+ }
726
+
727
+ .mdPreview > :first-child {
728
+ margin-top: 0;
729
+ }
730
+
731
+ .mdPreview > :last-child {
732
+ margin-bottom: 0;
733
+ }
734
+
735
+ .mdPreview h1,
736
+ .mdPreview h2,
737
+ .mdPreview h3,
738
+ .mdPreview h4,
739
+ .mdPreview h5,
740
+ .mdPreview h6 {
741
+ margin: 1.2em 0 0.5em;
742
+ line-height: 1.3;
743
+ font-weight: 600;
744
+ color: var(--fe-fg);
745
+ }
746
+
747
+ .mdPreview h1 {
748
+ font-size: 1.7em;
749
+ padding-bottom: 0.3em;
750
+ border-bottom: 1px solid var(--fe-border);
751
+ }
752
+
753
+ .mdPreview h2 {
754
+ font-size: 1.4em;
755
+ padding-bottom: 0.3em;
756
+ border-bottom: 1px solid var(--fe-border);
757
+ }
758
+
759
+ .mdPreview h3 {
760
+ font-size: 1.2em;
761
+ }
762
+
763
+ .mdPreview h4,
764
+ .mdPreview h5,
765
+ .mdPreview h6 {
766
+ font-size: 1.05em;
767
+ }
768
+
769
+ .mdPreview p {
770
+ margin: 0.6em 0;
771
+ }
772
+
773
+ .mdPreview ul,
774
+ .mdPreview ol {
775
+ margin: 0.6em 0;
776
+ padding-left: 1.6em;
777
+ }
778
+
779
+ .mdPreview li {
780
+ margin: 0.2em 0;
781
+ }
782
+
783
+ .mdPreview li > ul,
784
+ .mdPreview li > ol {
785
+ margin: 0.2em 0;
786
+ }
787
+
788
+ .mdPreview a {
789
+ color: var(--fe-accent);
790
+ text-decoration: none;
791
+ }
792
+
793
+ .mdPreview a:hover {
794
+ text-decoration: underline;
795
+ }
796
+
797
+ .mdPreview blockquote {
798
+ margin: 0.8em 0;
799
+ padding: 0.3em 1em;
800
+ border-left: 3px solid var(--fe-border-strong);
801
+ color: var(--fe-muted);
802
+ background: var(--fe-sidebar-bg);
803
+ }
804
+
805
+ .mdPreview code {
806
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
807
+ font-size: 0.92em;
808
+ background: var(--fe-hover);
809
+ padding: 0.15em 0.4em;
810
+ border-radius: 3px;
811
+ }
812
+
813
+ .mdPreview pre {
814
+ margin: 0.8em 0;
815
+ padding: 12px;
816
+ overflow: auto;
817
+ background: var(--fe-sidebar-bg);
818
+ border: 1px solid var(--fe-border);
819
+ border-radius: 4px;
820
+ line-height: 1.5;
821
+ }
822
+
823
+ .mdPreview pre code {
824
+ background: transparent;
825
+ padding: 0;
826
+ font-size: 13px;
827
+ }
828
+
829
+ .mdPreview table {
830
+ border-collapse: collapse;
831
+ margin: 0.8em 0;
832
+ display: block;
833
+ max-width: 100%;
834
+ overflow-x: auto;
835
+ }
836
+
837
+ .mdPreview th,
838
+ .mdPreview td {
839
+ border: 1px solid var(--fe-border-strong);
840
+ padding: 6px 12px;
841
+ }
842
+
843
+ .mdPreview th {
844
+ background: var(--fe-sidebar-bg);
845
+ font-weight: 600;
846
+ }
847
+
848
+ .mdPreview img {
849
+ max-width: 100%;
850
+ border-radius: 4px;
851
+ }
852
+
853
+ .mdPreview hr {
854
+ border: none;
855
+ border-top: 1px solid var(--fe-border-strong);
856
+ margin: 1.5em 0;
857
+ }
858
+
859
+ /* Fenced code blocks reuse the same token palette as the overlay editor. */
860
+ .mdPreview :global(.hljs-keyword),
861
+ .mdPreview :global(.hljs-selector-tag),
862
+ .mdPreview :global(.hljs-literal) {
863
+ color: var(--fe-token-keyword);
864
+ }
865
+
866
+ .mdPreview :global(.hljs-string),
867
+ .mdPreview :global(.hljs-regexp),
868
+ .mdPreview :global(.hljs-addition) {
869
+ color: var(--fe-token-string);
870
+ }
871
+
872
+ .mdPreview :global(.hljs-comment),
873
+ .mdPreview :global(.hljs-quote) {
874
+ color: var(--fe-token-comment);
875
+ font-style: italic;
876
+ }
877
+
878
+ .mdPreview :global(.hljs-number),
879
+ .mdPreview :global(.hljs-symbol),
880
+ .mdPreview :global(.hljs-bullet) {
881
+ color: var(--fe-token-number);
882
+ }
883
+
884
+ .mdPreview :global(.hljs-title),
885
+ .mdPreview :global(.hljs-section),
886
+ .mdPreview :global(.hljs-title.function_) {
887
+ color: var(--fe-token-function);
888
+ }
889
+
890
+ .mdPreview :global(.hljs-type),
891
+ .mdPreview :global(.hljs-built_in),
892
+ .mdPreview :global(.hljs-class .hljs-title) {
893
+ color: var(--fe-token-type);
894
+ }
895
+
896
+ .mdPreview :global(.hljs-attr),
897
+ .mdPreview :global(.hljs-attribute),
898
+ .mdPreview :global(.hljs-variable),
899
+ .mdPreview :global(.hljs-template-variable),
900
+ .mdPreview :global(.hljs-params) {
901
+ color: var(--fe-token-variable);
902
+ }
903
+
904
+ .mdPreview :global(.hljs-tag),
905
+ .mdPreview :global(.hljs-name) {
906
+ color: var(--fe-token-tag);
907
+ }
908
+
909
+ .mdPreview :global(.hljs-meta) {
910
+ color: var(--fe-token-meta);
911
+ }
912
+
913
+ .mdPreview :global(.hljs-emphasis) {
914
+ font-style: italic;
915
+ }
916
+
917
+ .mdPreview :global(.hljs-strong) {
918
+ font-weight: 600;
919
+ }
920
+
642
921
  .placeholder {
643
922
  flex: 1;
644
923
  display: flex;
@@ -65,7 +65,7 @@ export function detectLanguage(path: string): string | undefined {
65
65
  return EXTENSION_TO_LANGUAGE[ext]
66
66
  }
67
67
 
68
- function escapeHtml(value: string): string {
68
+ export function escapeHtml(value: string): string {
69
69
  return value
70
70
  .replace(/&/g, '&amp;')
71
71
  .replace(/</g, '&lt;')
@@ -74,6 +74,11 @@ function escapeHtml(value: string): string {
74
74
  .replace(/'/g, '&#39;')
75
75
  }
76
76
 
77
+ /** True when a language id names a registered highlight.js language. */
78
+ export function canHighlight(language: string): boolean {
79
+ return language !== '' && hljs.getLanguage(language) !== undefined
80
+ }
81
+
77
82
  /** Highlight code for a file; returns safe HTML (code is HTML-escaped). */
78
83
  export function highlightCode(code: string, path: string): string {
79
84
  const language = detectLanguage(path)
@@ -86,3 +91,15 @@ export function highlightCode(code: string, path: string): string {
86
91
  }
87
92
  return escapeHtml(code)
88
93
  }
94
+
95
+ /** Highlight a fenced code block by its declared language; escaped plain text when unknown. */
96
+ export function highlightFence(code: string, language: string): string {
97
+ if (canHighlight(language)) {
98
+ try {
99
+ return hljs.highlight(code, { language, ignoreIllegals: true }).value
100
+ } catch {
101
+ // fall through to escaped text
102
+ }
103
+ }
104
+ return escapeHtml(code)
105
+ }
@@ -23,27 +23,46 @@ export function apply(ctx: Context): void {
23
23
  const listDir = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'list', { path }, signal))
24
24
  const readFile = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'read', { path }, signal))
25
25
  const writeFile = (path: string, content: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'write', { path, content }, signal))
26
+ const watchFiles = (paths: string[]) => unwrap(ctx.connection.rpc.call(CHANNEL, 'watch', { paths }))
26
27
  const createFile = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'createFile', { path }, signal))
27
28
  const createDir = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'createDir', { path }, signal))
28
29
  const renameFile = (path: string, to: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'rename', { path, to }, signal))
29
30
  const removePath = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'delete', { path }, signal))
31
+ const copyPath = (from: string, to: string, overwrite: boolean, signal?: AbortSignal) =>
32
+ unwrap(ctx.connection.rpc.call(CHANNEL, 'copy', { from, to, overwrite }, signal))
33
+ const revealInExplorer = (path: string, kind: 'file' | 'dir', signal?: AbortSignal) =>
34
+ unwrap(ctx.connection.rpc.call(CHANNEL, 'reveal', { path, kind }, signal))
30
35
  const openPath = (path: string) => ctx.workspaces.openPath(path)
31
36
 
32
37
  ctx.slots.inject('explorer', () => ctx.slots.register({
33
38
  name: 'explorer',
34
39
  locale: NS,
35
- inject: () => ({ listDir, openPath, createFile, createDir, renameFile, removePath }),
40
+ inject: () => ({ listDir, openPath, revealInExplorer, createFile, createDir, renameFile, removePath, copyPath }),
36
41
  }, FileExplorer))
37
42
 
38
43
  ctx.slots.inject('explorer.preview', () => ctx.slots.register({
39
44
  name: 'explorer.preview',
40
45
  locale: NS,
41
- inject: () => ({ readFile, writeFile }),
46
+ inject: () => ({ readFile, writeFile, watchFiles }),
42
47
  }, FilePreview))
43
48
  }
44
49
 
45
- async function unwrap<T>(result: Promise<{ ok: true; value: T } | { ok: false; error: { message: string } }>): Promise<T> {
50
+ interface RpcFailure {
51
+ ok: false
52
+ error: { code: string; message: string }
53
+ }
54
+
55
+ /** Error carrying the host's machine-readable `code` (e.g. 'exists' on copy collision). */
56
+ export interface RpcError extends Error {
57
+ code?: string
58
+ }
59
+
60
+ async function unwrap<T>(result: Promise<{ ok: true; value: T } | RpcFailure>): Promise<T> {
46
61
  const resolved = await result
47
- if (!resolved.ok) throw new Error(resolved.error.message)
62
+ if (!resolved.ok) {
63
+ const error = new Error(resolved.error.message) as RpcError
64
+ error.code = resolved.error.code
65
+ throw error
66
+ }
48
67
  return resolved.value
49
68
  }