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.
@@ -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 newly opened tabs and drop cache entries for closed tabs.
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 controllers: AbortController[] = []
90
- for (const path of tabs) {
91
- if (cache.has(path)) continue
92
- cache.set(path, { status: 'loading' })
93
- const controller = new AbortController()
94
- controllers.push(controller)
95
- readFile(path, controller.signal)
96
- .then((result) => {
97
- if (!controller.signal.aborted) {
98
- cache.set(path, previewDataOf(result))
99
- refresh()
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 () => controllers.forEach((c) => c.abort())
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 className={styles.editor} data-wrap={wrap ? 'on' : 'off'}>
252
- <pre ref={highlightRef} className={styles.editorHighlight} aria-hidden="true">
253
- <code dangerouslySetInnerHTML={{ __html: highlighted }} />
254
- </pre>
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
- inset: 0;
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
- inset: 0;
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
- font-weight: bold;
717
+ color: var(--fe-accent);
718
+ font-weight: inherit;
578
719
  }
579
720
 
580
721
  /* ---- Skin chrome fix ----
@@ -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({
@@ -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
  }
@@ -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
  }