dsh-plugin-workbench 0.0.3 → 0.0.5
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 +54 -0
- package/README.md +17 -7
- package/lib/client.js +641 -160
- package/lib/client.js.map +1 -1
- package/lib/index.js +169 -6
- package/package.json +1 -1
- package/src/client/FileExplorer.tsx +357 -64
- package/src/client/FilePreview.tsx +171 -34
- package/src/client/files.module.css +144 -3
- package/src/client/index.ts +5 -1
- package/src/client/locales.ts +30 -0
- package/src/client/store.ts +25 -0
- package/src/dsh.d.ts +9 -0
- package/src/index.ts +204 -6
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
* a syntax-highlighted layer (Ctrl/Cmd+S saves). Tabs are drag-reorderable.
|
|
5
5
|
* Opening/closing the first/last tab is animated.
|
|
6
6
|
*/
|
|
7
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
8
8
|
import type { DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react'
|
|
9
9
|
import styles from './files.module.css'
|
|
10
|
-
import { highlightCode } from './highlight'
|
|
10
|
+
import { detectLanguage, highlightCode } from './highlight'
|
|
11
11
|
import { FileIcon } from './fileIcons'
|
|
12
12
|
import type { FilesKey } from './locales'
|
|
13
13
|
import { activateFile, closeFile, collapsePreview, moveTab, toggleWrap, useTabsState } from './store'
|
|
14
14
|
import type { FsReadResult } from './FileExplorer'
|
|
15
15
|
|
|
16
16
|
interface TabData {
|
|
17
|
-
status: 'loading' | 'loaded' | 'binary' | 'too-large' | 'error'
|
|
17
|
+
status: 'loading' | 'loaded' | 'image' | 'binary' | 'too-large' | 'error'
|
|
18
18
|
content?: string
|
|
19
19
|
draft?: string
|
|
20
20
|
dirty?: boolean
|
|
@@ -37,6 +37,39 @@ const PREVIEW_MIN = 240
|
|
|
37
37
|
const CHAT_MIN = 240
|
|
38
38
|
const PREVIEW_TOO_LARGE_LABEL = '512KB'
|
|
39
39
|
|
|
40
|
+
/** Same-origin raw-bytes route registered by the host half (see src/index.ts). */
|
|
41
|
+
const RAW_PREFIX = '/dsh-plugin-files/raw'
|
|
42
|
+
|
|
43
|
+
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'])
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Same-origin URL serving the file's bytes when it is a previewable image;
|
|
47
|
+
* undefined otherwise. Image tabs never go through the text RPC read — the
|
|
48
|
+
* host route resolves the path through the sandboxed fs service itself.
|
|
49
|
+
*/
|
|
50
|
+
function imageSrcOf(path: string): string | undefined {
|
|
51
|
+
const idx = path.lastIndexOf('.')
|
|
52
|
+
if (idx < 0 || idx === path.length - 1) return undefined
|
|
53
|
+
const ext = path.slice(idx + 1).toLowerCase()
|
|
54
|
+
return IMAGE_EXTENSIONS.has(ext) ? `${RAW_PREFIX}/${encodeURIComponent(path)}` : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Above this size the overlay editor (syntax-highlight layer + transparent
|
|
59
|
+
* textarea) falls back to a plain textarea: re-injecting and re-laying out
|
|
60
|
+
* hundreds of KB of wrapped text on every keystroke is what makes the page
|
|
61
|
+
* lag. The plain textarea keeps editing, wrapping and scrolling — it only
|
|
62
|
+
* loses the colors, which files this big rarely need anyway.
|
|
63
|
+
*/
|
|
64
|
+
const HIGHLIGHT_MAX_BYTES = 64 * 1024
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Languages always rendered as a plain textarea, never the overlay: the
|
|
68
|
+
* highlight layer costs a full extra layout pass (and with CJK text a
|
|
69
|
+
* fragile alignment surface) for prose formats where colors add little.
|
|
70
|
+
*/
|
|
71
|
+
const PLAIN_LANGUAGES = new Set(['markdown'])
|
|
72
|
+
|
|
40
73
|
function basenameOf(path: string): string {
|
|
41
74
|
const trimmed = path.replace(/[\\/]+$/, '')
|
|
42
75
|
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
@@ -62,6 +95,19 @@ function previewDataOf(result: FsReadResult): TabData {
|
|
|
62
95
|
return { status: 'loaded', content: result.content, draft: result.content, dirty: false, size: result.size }
|
|
63
96
|
}
|
|
64
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Gutter text for an editor: one logical line number per source line, as a
|
|
100
|
+
* single pre-formatted block (VS Code shows logical numbers even when soft
|
|
101
|
+
* wrap makes a line occupy several visual rows).
|
|
102
|
+
*/
|
|
103
|
+
function lineNumbersOf(content: string): string {
|
|
104
|
+
const count = content.split('\n').length
|
|
105
|
+
if (count <= 1) return '1'
|
|
106
|
+
const parts = new Array<string>(count)
|
|
107
|
+
for (let i = 0; i < count; i += 1) parts[i] = String(i + 1)
|
|
108
|
+
return parts.join('\n')
|
|
109
|
+
}
|
|
110
|
+
|
|
65
111
|
export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
66
112
|
const { tabs, active, theme, collapsed, wrap } = useTabsState()
|
|
67
113
|
|
|
@@ -73,42 +119,65 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
73
119
|
const cacheRef = useRef<Map<string, TabData>>(new Map())
|
|
74
120
|
const hasOpenedRef = useRef(false)
|
|
75
121
|
const [, bump] = useState(0)
|
|
122
|
+
const [imageFailed, setImageFailed] = useState<string | undefined>(undefined)
|
|
76
123
|
|
|
77
124
|
const previewRef = useRef<HTMLDivElement>(null)
|
|
78
125
|
const handleRef = useRef<HTMLDivElement>(null)
|
|
79
126
|
const highlightRef = useRef<HTMLPreElement>(null)
|
|
127
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
128
|
+
const gutterRef = useRef<HTMLDivElement>(null)
|
|
80
129
|
|
|
81
130
|
const refresh = useCallback(() => bump((v) => v + 1), [])
|
|
82
131
|
|
|
83
|
-
// Read
|
|
132
|
+
// Read the ACTIVE tab's content. Other tabs load lazily on first
|
|
133
|
+
// activation, so switching to a workspace with many large files doesn't
|
|
134
|
+
// re-read every tab at once (which used to freeze the switch).
|
|
84
135
|
useEffect(() => {
|
|
85
136
|
const cache = cacheRef.current
|
|
86
137
|
for (const [path] of cache) {
|
|
87
138
|
if (!tabs.includes(path)) cache.delete(path)
|
|
88
139
|
}
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
})
|
|
102
|
-
.catch((error) => {
|
|
103
|
-
if (!controller.signal.aborted) {
|
|
104
|
-
cache.set(path, { status: 'error', message: error instanceof Error ? error.message : String(error) })
|
|
105
|
-
refresh()
|
|
106
|
-
}
|
|
107
|
-
})
|
|
140
|
+
const target = active ?? tabs[0]
|
|
141
|
+
if (target === undefined || cache.has(target)) {
|
|
142
|
+
refresh()
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
145
|
+
const controller = new AbortController()
|
|
146
|
+
// Image files skip the text RPC read entirely: the raw-bytes route serves
|
|
147
|
+
// them straight into an <img> tag, so there is nothing to load here.
|
|
148
|
+
if (imageSrcOf(target) !== undefined) {
|
|
149
|
+
cache.set(target, { status: 'image' })
|
|
150
|
+
refresh()
|
|
151
|
+
return () => controller.abort()
|
|
108
152
|
}
|
|
153
|
+
cache.set(target, { status: 'loading' })
|
|
154
|
+
readFile(target, controller.signal)
|
|
155
|
+
.then((result) => {
|
|
156
|
+
if (!controller.signal.aborted) {
|
|
157
|
+
cache.set(target, previewDataOf(result))
|
|
158
|
+
// Large content: yield one frame so the browser paints the
|
|
159
|
+
// loading→loaded transition before the heavy text layout runs —
|
|
160
|
+
// the UI stays responsive instead of freezing in the same frame
|
|
161
|
+
// as the tab-open interaction.
|
|
162
|
+
if (result.size > HIGHLIGHT_MAX_BYTES) requestAnimationFrame(refresh)
|
|
163
|
+
else refresh()
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
.catch((error) => {
|
|
167
|
+
if (!controller.signal.aborted) {
|
|
168
|
+
cache.set(target, { status: 'error', message: error instanceof Error ? error.message : String(error) })
|
|
169
|
+
refresh()
|
|
170
|
+
}
|
|
171
|
+
})
|
|
109
172
|
refresh()
|
|
110
|
-
return () =>
|
|
111
|
-
}, [tabs, readFile, refresh])
|
|
173
|
+
return () => controller.abort()
|
|
174
|
+
}, [tabs, active, readFile, refresh])
|
|
175
|
+
|
|
176
|
+
// A failed <img> (deleted file, oversized, …) must not keep showing the
|
|
177
|
+
// broken-image placeholder when the user switches away and back.
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
setImageFailed(undefined)
|
|
180
|
+
}, [active])
|
|
112
181
|
|
|
113
182
|
// Animate the last tab closing without a mount/unmount bounce: keep the pane
|
|
114
183
|
// mounted while `hasOpenedRef` is set, then drop it after the transition.
|
|
@@ -131,6 +200,41 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
131
200
|
|
|
132
201
|
const isOpen = !collapsed && (tabs.length > 0 || closing || hasOpenedRef.current)
|
|
133
202
|
|
|
203
|
+
const activeData = active !== undefined ? cacheRef.current.get(active) : undefined
|
|
204
|
+
const language = active !== undefined ? detectLanguage(active) : undefined
|
|
205
|
+
const tooBig = (activeData?.size ?? 0) > HIGHLIGHT_MAX_BYTES
|
|
206
|
+
const plain = language === undefined || tooBig || (language !== undefined && PLAIN_LANGUAGES.has(language))
|
|
207
|
+
// Rebuild the gutter text only when the draft changes (not on every bump
|
|
208
|
+
// from tab switches or focus re-renders).
|
|
209
|
+
const gutterNumbers = useMemo(
|
|
210
|
+
() => (activeData?.status === 'loaded' ? lineNumbersOf(activeData.draft ?? '') : ''),
|
|
211
|
+
[activeData?.status, activeData?.draft],
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
// Keep the highlight layer and the line-number gutter in lockstep with the
|
|
215
|
+
// textarea on every frame while the editor is open: some engines don't fire
|
|
216
|
+
// scroll events during selection auto-scroll, which would otherwise leave
|
|
217
|
+
// the visible layer and the numbers behind the selection (drag-select
|
|
218
|
+
// misalignment). All layers are `overflow: auto|hidden` with identical
|
|
219
|
+
// content geometry, so the assignment is a no-op whenever they align.
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
const pre = highlightRef.current
|
|
222
|
+
const ta = textareaRef.current
|
|
223
|
+
const gutter = gutterRef.current
|
|
224
|
+
if (ta === null || gutter === null) return undefined
|
|
225
|
+
let raf = 0
|
|
226
|
+
const tick = () => {
|
|
227
|
+
if (pre !== null) {
|
|
228
|
+
pre.scrollTop = ta.scrollTop
|
|
229
|
+
pre.scrollLeft = ta.scrollLeft
|
|
230
|
+
}
|
|
231
|
+
gutter.scrollTop = ta.scrollTop
|
|
232
|
+
raf = requestAnimationFrame(tick)
|
|
233
|
+
}
|
|
234
|
+
raf = requestAnimationFrame(tick)
|
|
235
|
+
return () => cancelAnimationFrame(raf)
|
|
236
|
+
}, [isOpen, active, activeData?.status, plain])
|
|
237
|
+
|
|
134
238
|
// Publish the rendered preview width so the skin's fixed top/bottom trim can
|
|
135
239
|
// shift past this pane (covering only the chat).
|
|
136
240
|
useEffect(() => {
|
|
@@ -209,11 +313,13 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
209
313
|
|
|
210
314
|
const onTextareaScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
|
|
211
315
|
const pre = highlightRef.current
|
|
316
|
+
const gutter = gutterRef.current
|
|
212
317
|
const ta = e.currentTarget
|
|
213
318
|
if (pre !== null) {
|
|
214
319
|
pre.scrollTop = ta.scrollTop
|
|
215
320
|
pre.scrollLeft = ta.scrollLeft
|
|
216
321
|
}
|
|
322
|
+
if (gutter !== null) gutter.scrollTop = ta.scrollTop
|
|
217
323
|
}, [])
|
|
218
324
|
|
|
219
325
|
const onTabDragStart = useCallback((e: ReactDragEvent<HTMLDivElement>, path: string) => {
|
|
@@ -236,8 +342,6 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
236
342
|
setDragPath(undefined)
|
|
237
343
|
}, [])
|
|
238
344
|
|
|
239
|
-
const activeData = active !== undefined ? cacheRef.current.get(active) : undefined
|
|
240
|
-
|
|
241
345
|
if (!isOpen) return null
|
|
242
346
|
|
|
243
347
|
const renderBody = () => {
|
|
@@ -245,14 +349,22 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
245
349
|
switch (activeData.status) {
|
|
246
350
|
case 'loading':
|
|
247
351
|
return <div className={styles.previewHint}>{t('preview.loading')}</div>
|
|
248
|
-
case 'loaded':
|
|
249
|
-
const highlighted = active !== undefined ? highlightCode(activeData.draft ?? '', active) : ''
|
|
352
|
+
case 'loaded':
|
|
250
353
|
return (
|
|
251
|
-
<div
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
354
|
+
<div
|
|
355
|
+
className={`${styles.editor}${plain ? ` ${styles.editorPlain}` : ''}`}
|
|
356
|
+
data-wrap={wrap ? 'on' : 'off'}
|
|
357
|
+
>
|
|
358
|
+
<div ref={gutterRef} className={styles.gutter} aria-hidden="true">
|
|
359
|
+
<div className={styles.gutterNumbers}>{gutterNumbers}</div>
|
|
360
|
+
</div>
|
|
361
|
+
{!plain && active !== undefined && (
|
|
362
|
+
<pre ref={highlightRef} className={styles.editorHighlight} aria-hidden="true">
|
|
363
|
+
<code dangerouslySetInnerHTML={{ __html: highlightCode(activeData.draft ?? '', active) }} />
|
|
364
|
+
</pre>
|
|
365
|
+
)}
|
|
255
366
|
<textarea
|
|
367
|
+
ref={textareaRef}
|
|
256
368
|
className={styles.editorTextarea}
|
|
257
369
|
value={activeData.draft ?? ''}
|
|
258
370
|
onChange={(e) => onTextareaChange(e.target.value)}
|
|
@@ -260,11 +372,36 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
260
372
|
onKeyDown={onKeyDown}
|
|
261
373
|
spellCheck={false}
|
|
262
374
|
wrap={wrap ? 'soft' : 'off'}
|
|
263
|
-
title={t('action.saveHint')}
|
|
264
375
|
/>
|
|
376
|
+
{language !== undefined && tooBig && <div className={styles.editorHint}>{t('preview.highlightOff')}</div>}
|
|
265
377
|
{saveError !== undefined && <div className={styles.saveError}>{saveError}</div>}
|
|
266
378
|
</div>
|
|
267
379
|
)
|
|
380
|
+
case 'image': {
|
|
381
|
+
const src = active !== undefined ? imageSrcOf(active) : undefined
|
|
382
|
+
const name = active !== undefined ? basenameOf(active) : ''
|
|
383
|
+
if (src === undefined) {
|
|
384
|
+
return <div className={styles.previewHint}>{t('preview.binary')}</div>
|
|
385
|
+
}
|
|
386
|
+
if (imageFailed === active) {
|
|
387
|
+
return (
|
|
388
|
+
<div className={styles.previewHint}>
|
|
389
|
+
<div>{t('preview.imageFailed')}</div>
|
|
390
|
+
<div className={styles.previewMeta}>{name}</div>
|
|
391
|
+
</div>
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
return (
|
|
395
|
+
<div className={styles.imageView}>
|
|
396
|
+
<img
|
|
397
|
+
className={styles.image}
|
|
398
|
+
src={src}
|
|
399
|
+
alt={name}
|
|
400
|
+
onError={() => setImageFailed(active)}
|
|
401
|
+
/>
|
|
402
|
+
<div className={styles.previewMeta}>{name}</div>
|
|
403
|
+
</div>
|
|
404
|
+
)
|
|
268
405
|
}
|
|
269
406
|
case 'binary':
|
|
270
407
|
return (
|
|
@@ -235,6 +235,54 @@
|
|
|
235
235
|
padding: 2px 8px 2px 0;
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/* ---- Right-click context menu (VS Code-style) ---- */
|
|
239
|
+
/* The maid-atelier skin styles every [role='menu'] with its own palette —
|
|
240
|
+
`color` follows the PAGE theme there, while our background follows the
|
|
241
|
+
WORKBENCH theme. When the two disagree (page dark + workbench light or
|
|
242
|
+
vice versa) the skin's higher-specificity color would paint menu text in a
|
|
243
|
+
shade that matches our background, making it unreadable. Lock the menu's
|
|
244
|
+
own colors to the workbench tokens so text always contrasts its bg. */
|
|
245
|
+
.contextMenu {
|
|
246
|
+
position: fixed;
|
|
247
|
+
z-index: 10000;
|
|
248
|
+
min-width: 180px;
|
|
249
|
+
padding: 4px;
|
|
250
|
+
box-sizing: border-box;
|
|
251
|
+
border: 1px solid var(--fe-border-strong);
|
|
252
|
+
border-radius: 6px;
|
|
253
|
+
background: var(--fe-bg) !important;
|
|
254
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28);
|
|
255
|
+
font-size: 13px;
|
|
256
|
+
color: var(--fe-fg) !important;
|
|
257
|
+
user-select: none;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.contextMenuItem {
|
|
261
|
+
display: flex;
|
|
262
|
+
align-items: center;
|
|
263
|
+
gap: 8px;
|
|
264
|
+
padding: 5px 10px;
|
|
265
|
+
border-radius: 4px;
|
|
266
|
+
cursor: pointer;
|
|
267
|
+
white-space: nowrap;
|
|
268
|
+
color: inherit;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.contextMenuItem:hover {
|
|
272
|
+
background: var(--fe-selection) !important;
|
|
273
|
+
color: var(--fe-fg) !important;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.contextMenuItemDanger {
|
|
277
|
+
color: #f48771 !important;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.contextMenuDivider {
|
|
281
|
+
height: 1px;
|
|
282
|
+
margin: 4px 6px;
|
|
283
|
+
background: var(--fe-border);
|
|
284
|
+
}
|
|
285
|
+
|
|
238
286
|
/* ---- Split preview + tabs ---- */
|
|
239
287
|
.preview {
|
|
240
288
|
flex: 0 0 55%;
|
|
@@ -420,13 +468,73 @@
|
|
|
420
468
|
position: relative;
|
|
421
469
|
height: 100%;
|
|
422
470
|
min-height: 0;
|
|
471
|
+
/* Line-number gutter width; the textarea and highlight layers shift past it. */
|
|
472
|
+
--fe-gutter: 44px;
|
|
473
|
+
/* Keep layout/paint churn local to the editor: huge wrapped content must
|
|
474
|
+
not invalidate the whole page on every edit or scroll. */
|
|
475
|
+
contain: layout paint;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* Line-number gutter: a fixed column on the left that scrolls in lockstep
|
|
479
|
+
with the editor text (scrollTop synced from the textarea every frame). */
|
|
480
|
+
.gutter {
|
|
481
|
+
position: absolute;
|
|
482
|
+
top: 0;
|
|
483
|
+
bottom: 0;
|
|
484
|
+
left: 0;
|
|
485
|
+
width: var(--fe-gutter);
|
|
486
|
+
box-sizing: border-box;
|
|
487
|
+
overflow: hidden;
|
|
488
|
+
border-right: 1px solid var(--fe-border);
|
|
489
|
+
background: var(--fe-sidebar-bg);
|
|
490
|
+
color: var(--fe-faint);
|
|
491
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
492
|
+
font-size: 13px;
|
|
493
|
+
line-height: 1.5;
|
|
494
|
+
user-select: none;
|
|
495
|
+
pointer-events: none;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.gutterNumbers {
|
|
499
|
+
padding: 12px 8px;
|
|
500
|
+
white-space: pre;
|
|
501
|
+
text-align: right;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/* Plain (no-overlay) editor: the textarea itself renders the visible text —
|
|
505
|
+
used for files without syntax highlighting (e.g. .txt novels) and for
|
|
506
|
+
files too large to re-highlight per keystroke without lag. */
|
|
507
|
+
.editorPlain .editorTextarea {
|
|
508
|
+
color: var(--fe-fg);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/* Large-file notice chip: syntax highlight was dropped to stay responsive. */
|
|
512
|
+
.editorHint {
|
|
513
|
+
position: absolute;
|
|
514
|
+
left: calc(var(--fe-gutter) + 8px);
|
|
515
|
+
bottom: 8px;
|
|
516
|
+
padding: 4px 10px;
|
|
517
|
+
border-radius: 4px;
|
|
518
|
+
background: var(--fe-hover);
|
|
519
|
+
color: var(--fe-muted);
|
|
520
|
+
font-size: 11px;
|
|
521
|
+
pointer-events: none;
|
|
423
522
|
}
|
|
424
523
|
|
|
425
524
|
.editorHighlight {
|
|
426
525
|
position: absolute;
|
|
427
|
-
|
|
526
|
+
top: 0;
|
|
527
|
+
bottom: 0;
|
|
528
|
+
left: var(--fe-gutter);
|
|
529
|
+
right: 0;
|
|
428
530
|
margin: 0;
|
|
429
531
|
padding: 12px;
|
|
532
|
+
/* The textarea reserves one extra line at the bottom (its caret reserve),
|
|
533
|
+
so it can scroll ~1 line further than this layer. Match that range so
|
|
534
|
+
the two layers never desync at the bottom of a long file — otherwise
|
|
535
|
+
drag-selection near the end would highlight text one line ABOVE the
|
|
536
|
+
visible text. */
|
|
537
|
+
padding-bottom: calc(12px + 1.5em);
|
|
430
538
|
/* auto (not hidden): the highlight layer gets the same scrollbar gutter as
|
|
431
539
|
the textarea, so both content boxes have identical widths and wrap at the
|
|
432
540
|
same points. Its scrollbars stack invisibly beneath the textarea's. */
|
|
@@ -450,7 +558,10 @@
|
|
|
450
558
|
|
|
451
559
|
.editorTextarea {
|
|
452
560
|
position: absolute;
|
|
453
|
-
|
|
561
|
+
top: 0;
|
|
562
|
+
bottom: 0;
|
|
563
|
+
left: var(--fe-gutter);
|
|
564
|
+
right: 0;
|
|
454
565
|
box-sizing: border-box;
|
|
455
566
|
margin: 0;
|
|
456
567
|
padding: 12px;
|
|
@@ -498,6 +609,31 @@
|
|
|
498
609
|
line-height: 18px;
|
|
499
610
|
}
|
|
500
611
|
|
|
612
|
+
/* Image preview: raw bytes arrive from the plugin's same-origin route. The
|
|
613
|
+
viewer centers the picture and shrinks it to fit the pane, letting the
|
|
614
|
+
body scroll when the file is larger than the available space. */
|
|
615
|
+
.imageView {
|
|
616
|
+
height: 100%;
|
|
617
|
+
min-height: 0;
|
|
618
|
+
box-sizing: border-box;
|
|
619
|
+
display: flex;
|
|
620
|
+
flex-direction: column;
|
|
621
|
+
align-items: center;
|
|
622
|
+
justify-content: center;
|
|
623
|
+
gap: 8px;
|
|
624
|
+
padding: 12px;
|
|
625
|
+
overflow: auto;
|
|
626
|
+
background: var(--fe-bg);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
.image {
|
|
630
|
+
max-width: 100%;
|
|
631
|
+
max-height: 100%;
|
|
632
|
+
object-fit: contain;
|
|
633
|
+
border-radius: 4px;
|
|
634
|
+
box-shadow: 0 0 0 1px var(--fe-border);
|
|
635
|
+
}
|
|
636
|
+
|
|
501
637
|
.previewMeta {
|
|
502
638
|
color: var(--fe-faint);
|
|
503
639
|
font-size: 11px;
|
|
@@ -573,8 +709,13 @@
|
|
|
573
709
|
font-style: italic;
|
|
574
710
|
}
|
|
575
711
|
|
|
712
|
+
/* Strong emphasis is color-only on purpose: `font-weight: bold` widens
|
|
713
|
+
glyphs, so the highlight layer would wrap at different points than the
|
|
714
|
+
textarea beneath it — drag-selection would then highlight text that no
|
|
715
|
+
longer matches the visible lines. Italic above is metric-neutral. */
|
|
576
716
|
.editorHighlight :global(.hljs-strong) {
|
|
577
|
-
|
|
717
|
+
color: var(--fe-accent);
|
|
718
|
+
font-weight: inherit;
|
|
578
719
|
}
|
|
579
720
|
|
|
580
721
|
/* ---- Skin chrome fix ----
|
package/src/client/index.ts
CHANGED
|
@@ -23,12 +23,16 @@ 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 createFile = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'createFile', { path }, signal))
|
|
27
|
+
const createDir = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'createDir', { path }, signal))
|
|
28
|
+
const renameFile = (path: string, to: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'rename', { path, to }, signal))
|
|
29
|
+
const removePath = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'delete', { path }, signal))
|
|
26
30
|
const openPath = (path: string) => ctx.workspaces.openPath(path)
|
|
27
31
|
|
|
28
32
|
ctx.slots.inject('explorer', () => ctx.slots.register({
|
|
29
33
|
name: 'explorer',
|
|
30
34
|
locale: NS,
|
|
31
|
-
inject: () => ({ listDir, openPath }),
|
|
35
|
+
inject: () => ({ listDir, openPath, createFile, createDir, renameFile, removePath }),
|
|
32
36
|
}, FileExplorer))
|
|
33
37
|
|
|
34
38
|
ctx.slots.inject('explorer.preview', () => ctx.slots.register({
|
package/src/client/locales.ts
CHANGED
|
@@ -14,11 +14,26 @@ export const zh = {
|
|
|
14
14
|
'tab.close': '关闭标签页',
|
|
15
15
|
'tab.collapse': '收起文件详情',
|
|
16
16
|
'tab.expand': '弹出文件详情',
|
|
17
|
+
'menu.open': '打开预览',
|
|
18
|
+
'menu.newFile': '新建文件',
|
|
19
|
+
'menu.newFolder': '新建文件夹',
|
|
20
|
+
'menu.rename': '重命名',
|
|
21
|
+
'menu.delete': '删除',
|
|
22
|
+
'menu.copyPath': '复制路径',
|
|
23
|
+
'menu.refresh': '刷新',
|
|
24
|
+
'menu.openSystem': '在系统中打开',
|
|
25
|
+
'prompt.newFileName': '新文件名',
|
|
26
|
+
'prompt.newFolderName': '新文件夹名',
|
|
27
|
+
'prompt.renameTo': '重命名为',
|
|
28
|
+
'confirm.deleteFile': '确定删除文件「{name}」吗?',
|
|
29
|
+
'confirm.deleteDir': '确定删除文件夹「{name}」及其全部内容吗?',
|
|
17
30
|
'preview.loading': '加载中…',
|
|
18
31
|
'preview.binary': '二进制文件,无法预览',
|
|
32
|
+
'preview.imageFailed': '图片加载失败',
|
|
19
33
|
'preview.tooLarge': '文件过大,仅支持预览不超过 {limit}',
|
|
20
34
|
'preview.emptyDir': '空目录',
|
|
21
35
|
'preview.error': '加载失败',
|
|
36
|
+
'preview.highlightOff': '大文件:已关闭语法高亮(仍可编辑)',
|
|
22
37
|
} as const
|
|
23
38
|
|
|
24
39
|
export type FilesKey = keyof typeof zh
|
|
@@ -35,9 +50,24 @@ export const en: Record<FilesKey, string> = {
|
|
|
35
50
|
'tab.close': 'Close tab',
|
|
36
51
|
'tab.collapse': 'Collapse file details',
|
|
37
52
|
'tab.expand': 'Expand file details',
|
|
53
|
+
'menu.open': 'Open preview',
|
|
54
|
+
'menu.newFile': 'New File',
|
|
55
|
+
'menu.newFolder': 'New Folder',
|
|
56
|
+
'menu.rename': 'Rename',
|
|
57
|
+
'menu.delete': 'Delete',
|
|
58
|
+
'menu.copyPath': 'Copy Path',
|
|
59
|
+
'menu.refresh': 'Refresh',
|
|
60
|
+
'menu.openSystem': 'Open in System',
|
|
61
|
+
'prompt.newFileName': 'New file name',
|
|
62
|
+
'prompt.newFolderName': 'New folder name',
|
|
63
|
+
'prompt.renameTo': 'Rename to',
|
|
64
|
+
'confirm.deleteFile': 'Delete file "{name}"?',
|
|
65
|
+
'confirm.deleteDir': 'Delete folder "{name}" and all its contents?',
|
|
38
66
|
'preview.loading': 'Loading…',
|
|
39
67
|
'preview.binary': 'Binary file, cannot preview',
|
|
68
|
+
'preview.imageFailed': 'Failed to load image',
|
|
40
69
|
'preview.tooLarge': 'File too large to preview (limit {limit})',
|
|
41
70
|
'preview.emptyDir': 'Empty directory',
|
|
42
71
|
'preview.error': 'Load failed',
|
|
72
|
+
'preview.highlightOff': 'Large file: syntax highlighting off (still editable)',
|
|
43
73
|
}
|
package/src/client/store.ts
CHANGED
|
@@ -140,6 +140,31 @@ export function activateFile(path: string): void {
|
|
|
140
140
|
updateCurrent((ws) => (ws.active === path || !ws.tabs.includes(path) ? ws : { ...ws, active: path }))
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/** Point any open tab at a new path after a rename (the disk path changed). */
|
|
144
|
+
export function retargetFile(oldPath: string, newPath: string): void {
|
|
145
|
+
updateCurrent((ws) => {
|
|
146
|
+
if (!ws.tabs.includes(oldPath)) return ws
|
|
147
|
+
const tabs = ws.tabs.map((t) => (t === oldPath ? newPath : t))
|
|
148
|
+
return { ...ws, tabs, active: ws.active === oldPath ? newPath : ws.active }
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Close every open tab at or under a path (used after deleting it). */
|
|
153
|
+
export function closeFilesUnder(path: string): void {
|
|
154
|
+
updateCurrent((ws) => {
|
|
155
|
+
const sep = path.includes('\\') ? '\\' : '/'
|
|
156
|
+
const prefix = path.endsWith('\\') || path.endsWith('/') ? path : path + sep
|
|
157
|
+
const kept = ws.tabs.filter((t) => t !== path && !t.startsWith(prefix))
|
|
158
|
+
if (kept.length === ws.tabs.length) return ws
|
|
159
|
+
let active = ws.active
|
|
160
|
+
if (active !== undefined && (active === path || active.startsWith(prefix))) {
|
|
161
|
+
const index = ws.tabs.indexOf(active)
|
|
162
|
+
active = kept[Math.min(index, kept.length - 1)]
|
|
163
|
+
}
|
|
164
|
+
return { ...ws, tabs: kept, active }
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
143
168
|
/** Move a tab before another tab (drag-to-reorder). */
|
|
144
169
|
export function moveTab(dragged: string, target: string): void {
|
|
145
170
|
updateCurrent((ws) => {
|
package/src/dsh.d.ts
CHANGED
|
@@ -55,8 +55,17 @@ declare module '@deepseek-ai/cordis' {
|
|
|
55
55
|
stat(target: unknown, signal?: AbortSignal): Promise<{ version: unknown; type: 'file' | 'directory' | 'other'; size?: number } | undefined>
|
|
56
56
|
listDir(target: unknown, signal?: AbortSignal): Promise<Array<{ name: string; type: 'file' | 'directory' | 'other'; target: unknown; version?: unknown; size?: number }>>
|
|
57
57
|
readText(target: unknown, signal?: AbortSignal): Promise<string>
|
|
58
|
+
readBytes(target: unknown, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>
|
|
58
59
|
writeText(target: unknown, content: string, version?: unknown, signal?: AbortSignal, options?: { mode?: string; workspaceRoot?: string }): Promise<void>
|
|
59
60
|
processPath(target: unknown): string
|
|
60
61
|
}
|
|
62
|
+
webServer: {
|
|
63
|
+
/** Register a named HTTP route (exact path or prefix). Returns the disposer. */
|
|
64
|
+
register(route: {
|
|
65
|
+
kind: 'exact' | 'prefix'
|
|
66
|
+
path: string
|
|
67
|
+
handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void | Promise<void>
|
|
68
|
+
}): () => void
|
|
69
|
+
}
|
|
61
70
|
}
|
|
62
71
|
}
|