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.
@@ -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's scroll in lockstep with the textarea on every
164
- // frame while the overlay editor is open: some engines don't fire scroll
165
- // events during selection auto-scroll, which would otherwise leave the
166
- // visible layer behind the selection (drag-select misalignment). Both
167
- // layers are `overflow: auto` with identical content, so the assignment is
168
- // a no-op whenever they are already aligned.
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
- if (pre === null || ta === null || plain) return undefined
223
+ const gutter = gutterRef.current
224
+ if (ta === null || gutter === null) return undefined
173
225
  let raf = 0
174
226
  const tick = () => {
175
- pre.scrollTop = ta.scrollTop
176
- pre.scrollLeft = ta.scrollLeft
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
- inset: 0;
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
- inset: 0;
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;
@@ -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,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',
@@ -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
  }