dsh-plugin-workbench 0.0.5 → 0.0.7
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 +42 -1
- package/README.md +28 -5
- package/lib/client.js +6396 -155
- package/lib/client.js.map +1 -1
- package/lib/index.js +302 -5
- package/package.json +4 -2
- package/src/client/FileExplorer.tsx +538 -38
- package/src/client/FilePreview.tsx +182 -6
- package/src/client/files.module.css +279 -0
- package/src/client/highlight.ts +18 -1
- package/src/client/index.ts +23 -4
- package/src/client/locales.ts +48 -4
- package/src/client/markdown.ts +69 -0
- package/src/client/store.ts +132 -2
- package/src/index.ts +413 -7
|
@@ -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).
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
{
|
|
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;
|
package/src/client/highlight.ts
CHANGED
|
@@ -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, '&')
|
|
71
71
|
.replace(/</g, '<')
|
|
@@ -74,6 +74,11 @@ function escapeHtml(value: string): string {
|
|
|
74
74
|
.replace(/'/g, ''')
|
|
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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
-
|
|
50
|
+
interface RpcFailure {
|
|
51
|
+
ok: false
|
|
52
|
+
error: { code: string; message: string }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Error carrying the host's machine-readable `code` (always a core union code; copy collisions are reported in the value instead). */
|
|
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)
|
|
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
|
}
|