dsh-plugin-workbench 0.0.4 → 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 +34 -2
- package/README.md +14 -5
- package/lib/client.js +462 -53
- package/lib/client.js.map +1 -1
- package/lib/index.js +169 -6
- package/package.json +1 -1
- package/src/client/FileExplorer.tsx +232 -4
- package/src/client/FilePreview.tsx +97 -11
- package/src/client/files.module.css +110 -3
- package/src/client/index.ts +5 -1
- package/src/client/locales.ts +28 -0
- package/src/client/store.ts +25 -0
- package/src/dsh.d.ts +9 -0
- package/src/index.ts +204 -6
|
@@ -4,7 +4,7 @@
|
|
|
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
10
|
import { detectLanguage, highlightCode } from './highlight'
|
|
@@ -14,7 +14,7 @@ import { activateFile, closeFile, collapsePreview, moveTab, toggleWrap, useTabsS
|
|
|
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,23 @@ 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
|
+
|
|
40
57
|
/**
|
|
41
58
|
* Above this size the overlay editor (syntax-highlight layer + transparent
|
|
42
59
|
* textarea) falls back to a plain textarea: re-injecting and re-laying out
|
|
@@ -78,6 +95,19 @@ function previewDataOf(result: FsReadResult): TabData {
|
|
|
78
95
|
return { status: 'loaded', content: result.content, draft: result.content, dirty: false, size: result.size }
|
|
79
96
|
}
|
|
80
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
|
+
|
|
81
111
|
export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
82
112
|
const { tabs, active, theme, collapsed, wrap } = useTabsState()
|
|
83
113
|
|
|
@@ -89,11 +119,13 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
89
119
|
const cacheRef = useRef<Map<string, TabData>>(new Map())
|
|
90
120
|
const hasOpenedRef = useRef(false)
|
|
91
121
|
const [, bump] = useState(0)
|
|
122
|
+
const [imageFailed, setImageFailed] = useState<string | undefined>(undefined)
|
|
92
123
|
|
|
93
124
|
const previewRef = useRef<HTMLDivElement>(null)
|
|
94
125
|
const handleRef = useRef<HTMLDivElement>(null)
|
|
95
126
|
const highlightRef = useRef<HTMLPreElement>(null)
|
|
96
127
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
128
|
+
const gutterRef = useRef<HTMLDivElement>(null)
|
|
97
129
|
|
|
98
130
|
const refresh = useCallback(() => bump((v) => v + 1), [])
|
|
99
131
|
|
|
@@ -111,6 +143,13 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
111
143
|
return undefined
|
|
112
144
|
}
|
|
113
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()
|
|
152
|
+
}
|
|
114
153
|
cache.set(target, { status: 'loading' })
|
|
115
154
|
readFile(target, controller.signal)
|
|
116
155
|
.then((result) => {
|
|
@@ -134,6 +173,12 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
134
173
|
return () => controller.abort()
|
|
135
174
|
}, [tabs, active, readFile, refresh])
|
|
136
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])
|
|
181
|
+
|
|
137
182
|
// Animate the last tab closing without a mount/unmount bounce: keep the pane
|
|
138
183
|
// mounted while `hasOpenedRef` is set, then drop it after the transition.
|
|
139
184
|
if (tabs.length > 0) hasOpenedRef.current = true
|
|
@@ -159,21 +204,31 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
159
204
|
const language = active !== undefined ? detectLanguage(active) : undefined
|
|
160
205
|
const tooBig = (activeData?.size ?? 0) > HIGHLIGHT_MAX_BYTES
|
|
161
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
|
+
)
|
|
162
213
|
|
|
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
|
|
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.
|
|
169
220
|
useEffect(() => {
|
|
170
221
|
const pre = highlightRef.current
|
|
171
222
|
const ta = textareaRef.current
|
|
172
|
-
|
|
223
|
+
const gutter = gutterRef.current
|
|
224
|
+
if (ta === null || gutter === null) return undefined
|
|
173
225
|
let raf = 0
|
|
174
226
|
const tick = () => {
|
|
175
|
-
pre
|
|
176
|
-
|
|
227
|
+
if (pre !== null) {
|
|
228
|
+
pre.scrollTop = ta.scrollTop
|
|
229
|
+
pre.scrollLeft = ta.scrollLeft
|
|
230
|
+
}
|
|
231
|
+
gutter.scrollTop = ta.scrollTop
|
|
177
232
|
raf = requestAnimationFrame(tick)
|
|
178
233
|
}
|
|
179
234
|
raf = requestAnimationFrame(tick)
|
|
@@ -258,11 +313,13 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
258
313
|
|
|
259
314
|
const onTextareaScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
|
|
260
315
|
const pre = highlightRef.current
|
|
316
|
+
const gutter = gutterRef.current
|
|
261
317
|
const ta = e.currentTarget
|
|
262
318
|
if (pre !== null) {
|
|
263
319
|
pre.scrollTop = ta.scrollTop
|
|
264
320
|
pre.scrollLeft = ta.scrollLeft
|
|
265
321
|
}
|
|
322
|
+
if (gutter !== null) gutter.scrollTop = ta.scrollTop
|
|
266
323
|
}, [])
|
|
267
324
|
|
|
268
325
|
const onTabDragStart = useCallback((e: ReactDragEvent<HTMLDivElement>, path: string) => {
|
|
@@ -298,6 +355,9 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
298
355
|
className={`${styles.editor}${plain ? ` ${styles.editorPlain}` : ''}`}
|
|
299
356
|
data-wrap={wrap ? 'on' : 'off'}
|
|
300
357
|
>
|
|
358
|
+
<div ref={gutterRef} className={styles.gutter} aria-hidden="true">
|
|
359
|
+
<div className={styles.gutterNumbers}>{gutterNumbers}</div>
|
|
360
|
+
</div>
|
|
301
361
|
{!plain && active !== undefined && (
|
|
302
362
|
<pre ref={highlightRef} className={styles.editorHighlight} aria-hidden="true">
|
|
303
363
|
<code dangerouslySetInnerHTML={{ __html: highlightCode(activeData.draft ?? '', active) }} />
|
|
@@ -317,6 +377,32 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
317
377
|
{saveError !== undefined && <div className={styles.saveError}>{saveError}</div>}
|
|
318
378
|
</div>
|
|
319
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
|
+
)
|
|
405
|
+
}
|
|
320
406
|
case 'binary':
|
|
321
407
|
return (
|
|
322
408
|
<div className={styles.previewHint}>
|
|
@@ -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,11 +468,39 @@
|
|
|
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;
|
|
423
473
|
/* Keep layout/paint churn local to the editor: huge wrapped content must
|
|
424
474
|
not invalidate the whole page on every edit or scroll. */
|
|
425
475
|
contain: layout paint;
|
|
426
476
|
}
|
|
427
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
|
+
|
|
428
504
|
/* Plain (no-overlay) editor: the textarea itself renders the visible text —
|
|
429
505
|
used for files without syntax highlighting (e.g. .txt novels) and for
|
|
430
506
|
files too large to re-highlight per keystroke without lag. */
|
|
@@ -435,7 +511,7 @@
|
|
|
435
511
|
/* Large-file notice chip: syntax highlight was dropped to stay responsive. */
|
|
436
512
|
.editorHint {
|
|
437
513
|
position: absolute;
|
|
438
|
-
left: 8px;
|
|
514
|
+
left: calc(var(--fe-gutter) + 8px);
|
|
439
515
|
bottom: 8px;
|
|
440
516
|
padding: 4px 10px;
|
|
441
517
|
border-radius: 4px;
|
|
@@ -447,7 +523,10 @@
|
|
|
447
523
|
|
|
448
524
|
.editorHighlight {
|
|
449
525
|
position: absolute;
|
|
450
|
-
|
|
526
|
+
top: 0;
|
|
527
|
+
bottom: 0;
|
|
528
|
+
left: var(--fe-gutter);
|
|
529
|
+
right: 0;
|
|
451
530
|
margin: 0;
|
|
452
531
|
padding: 12px;
|
|
453
532
|
/* The textarea reserves one extra line at the bottom (its caret reserve),
|
|
@@ -479,7 +558,10 @@
|
|
|
479
558
|
|
|
480
559
|
.editorTextarea {
|
|
481
560
|
position: absolute;
|
|
482
|
-
|
|
561
|
+
top: 0;
|
|
562
|
+
bottom: 0;
|
|
563
|
+
left: var(--fe-gutter);
|
|
564
|
+
right: 0;
|
|
483
565
|
box-sizing: border-box;
|
|
484
566
|
margin: 0;
|
|
485
567
|
padding: 12px;
|
|
@@ -527,6 +609,31 @@
|
|
|
527
609
|
line-height: 18px;
|
|
528
610
|
}
|
|
529
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
|
+
|
|
530
637
|
.previewMeta {
|
|
531
638
|
color: var(--fe-faint);
|
|
532
639
|
font-size: 11px;
|
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,8 +14,22 @@ 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,8 +50,22 @@ export const en: Record<FilesKey, string> = {
|
|
|
36
50
|
'tab.close': 'Close tab',
|
|
37
51
|
'tab.collapse': 'Collapse file details',
|
|
38
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?',
|
|
39
66
|
'preview.loading': 'Loading…',
|
|
40
67
|
'preview.binary': 'Binary file, cannot preview',
|
|
68
|
+
'preview.imageFailed': 'Failed to load image',
|
|
41
69
|
'preview.tooLarge': 'File too large to preview (limit {limit})',
|
|
42
70
|
'preview.emptyDir': 'Empty directory',
|
|
43
71
|
'preview.error': 'Load failed',
|
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
|
}
|