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.
- package/CHANGELOG.md +65 -2
- package/README.md +29 -7
- package/lib/client.js +6770 -117
- package/lib/client.js.map +1 -1
- package/lib/index.js +467 -7
- package/package.json +2 -1
- package/src/client/FileExplorer.tsx +740 -13
- package/src/client/FilePreview.tsx +279 -17
- package/src/client/files.module.css +389 -3
- package/src/client/highlight.ts +18 -1
- package/src/client/index.ts +27 -4
- package/src/client/locales.ts +72 -0
- package/src/client/markdown.ts +69 -0
- package/src/client/store.ts +157 -2
- package/src/dsh.d.ts +9 -0
- package/src/index.ts +601 -10
|
@@ -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).
|
|
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
|
-
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
|
-
|
|
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
|
|
164
|
-
// frame while the
|
|
165
|
-
// events during selection auto-scroll, which would otherwise leave
|
|
166
|
-
// visible layer behind the selection (drag-select
|
|
167
|
-
// layers are `overflow: auto` with identical
|
|
168
|
-
// a no-op whenever they
|
|
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
|
-
|
|
358
|
+
const gutter = gutterRef.current
|
|
359
|
+
if (ta === null || gutter === null) return undefined
|
|
173
360
|
let raf = 0
|
|
174
361
|
const tick = () => {
|
|
175
|
-
pre
|
|
176
|
-
|
|
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
|
-
{
|
|
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}` : ''}`}
|