dsh-plugin-workbench 0.0.3 → 0.0.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-workbench",
3
3
  "description": "VS Code-style workspace file explorer + editable preview for the dsh web GUI",
4
- "version": "0.0.3",
4
+ "version": "0.0.4",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",
7
7
  "engines": {
@@ -3,7 +3,7 @@
3
3
  * (patched) ui-layout AppFrame. File selection is pushed into the shared
4
4
  * selection store so the `explorer.preview` slot can render the split view.
5
5
  */
6
- import { useCallback, useEffect, useRef, useState } from 'react'
6
+ import { memo, useCallback, useEffect, useRef, useState } from 'react'
7
7
  import styles from './files.module.css'
8
8
  import { FileIcon } from './fileIcons'
9
9
  import type { FilesKey } from './locales'
@@ -64,6 +64,12 @@ function formatSize(bytes: number): string {
64
64
  /** How often the visible tree is re-listed to pick up disk changes. */
65
65
  const REFRESH_MS = 2000
66
66
 
67
+ /** How many previously-expanded folders to re-list at once on workspace switch. */
68
+ const DIR_LOAD_BATCH = 4
69
+
70
+ /** Pause between batches when restoring expanded folders. */
71
+ const DIR_LOAD_GAP_MS = 50
72
+
67
73
  const EMPTY_EXPANDED = new Set<string>()
68
74
 
69
75
  /** True when two directory listings are identical (name/kind/size). */
@@ -75,6 +81,95 @@ function sameEntries(a: FsListEntry[], b: FsListEntry[]): boolean {
75
81
  return true
76
82
  }
77
83
 
84
+ interface TreeRowProps {
85
+ entry: FsListEntry
86
+ depth: number
87
+ isActive: boolean
88
+ isExpanded: boolean
89
+ onToggleDir: (path: string) => void
90
+ onRefreshDir: (path: string) => void
91
+ onSelect: (entry: FsListEntry) => void
92
+ onDoubleClick: (entry: FsListEntry) => void
93
+ onOpenExternal: (path: string) => Promise<void>
94
+ t: (key: FilesKey, params?: Record<string, unknown>) => string
95
+ }
96
+
97
+ /**
98
+ * One tree row, memoized so opening/activating a file (which changes the
99
+ * active-path highlight) re-renders only the affected row — with a workspace
100
+ * full of files, re-rendering the whole tree on every click is what made
101
+ * opening files feel laggy.
102
+ */
103
+ const TreeRow = memo(function TreeRow({
104
+ entry,
105
+ depth,
106
+ isActive,
107
+ isExpanded,
108
+ onToggleDir,
109
+ onRefreshDir,
110
+ onSelect,
111
+ onDoubleClick,
112
+ onOpenExternal,
113
+ t,
114
+ }: TreeRowProps) {
115
+ const isDir = entry.kind === 'dir'
116
+ return (
117
+ <div
118
+ className={`${styles.row} ${isActive ? styles.rowSelected : ''}`}
119
+ style={{ paddingLeft: 8 + depth * 14 }}
120
+ onClick={() => onSelect(entry)}
121
+ onDoubleClick={() => onDoubleClick(entry)}
122
+ role="treeitem"
123
+ aria-selected={isActive}
124
+ aria-expanded={isDir ? isExpanded : undefined}
125
+ title={entry.name}
126
+ >
127
+ <span
128
+ className={styles.chevron}
129
+ onClick={(e) => {
130
+ e.stopPropagation()
131
+ if (isDir) onToggleDir(entry.path)
132
+ }}
133
+ >
134
+ {isDir ? (isExpanded ? '▾' : '▸') : ''}
135
+ </span>
136
+ <span className={styles.icon}>
137
+ {isDir ? (isExpanded ? '📂' : '📁') : entry.kind === 'file' ? <FileIcon name={entry.name} /> : '·'}
138
+ </span>
139
+ <span className={styles.name}>{entry.name}</span>
140
+ {entry.kind === 'file' && entry.size !== undefined && (
141
+ <span className={styles.size}>{formatSize(entry.size)}</span>
142
+ )}
143
+ <span className={styles.actions}>
144
+ <button
145
+ type="button"
146
+ className={styles.action}
147
+ title={t('action.open')}
148
+ onClick={(e) => {
149
+ e.stopPropagation()
150
+ void onOpenExternal(entry.path)
151
+ }}
152
+ >
153
+
154
+ </button>
155
+ {isDir && (
156
+ <button
157
+ type="button"
158
+ className={styles.action}
159
+ title={t('action.refresh')}
160
+ onClick={(e) => {
161
+ e.stopPropagation()
162
+ onRefreshDir(entry.path)
163
+ }}
164
+ >
165
+
166
+ </button>
167
+ )}
168
+ </span>
169
+ </div>
170
+ )
171
+ })
172
+
78
173
  export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileExplorerProps) {
79
174
  const sessionList = useSessions((s) => s)
80
175
  const currentId = sessionList.current
@@ -98,6 +193,10 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
98
193
  // Latest tree snapshot for the polling tick (avoids stale closures).
99
194
  const treeRef = useRef({ root, children, expanded, rootLoading })
100
195
  treeRef.current = { root, children, expanded, rootLoading }
196
+ // Stable view of the loaded listings so toggleDir doesn't depend on the
197
+ // children state (keeps memoized rows from re-rendering on listing updates).
198
+ const childrenRef = useRef(children)
199
+ childrenRef.current = children
101
200
 
102
201
  // Expose the explorer width so the maid-atelier fixed chrome (top/bottom
103
202
  // trim) can shift past this column instead of covering it.
@@ -129,15 +228,22 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
129
228
  const controller = new AbortController()
130
229
  rootAbortRef.current = controller
131
230
  listDir(cwd, controller.signal)
132
- .then((result) => {
231
+ .then(async (result) => {
133
232
  if (controller.signal.aborted) return
134
233
  setRoot(result.root)
135
234
  setChildren({ [result.root]: result.entries })
136
235
  setRootError(undefined)
137
- // Reload the contents of this workspace's previously-expanded folders
138
- // right away instead of waiting for the auto-refresh tick.
139
- for (const dirPath of expanded) {
140
- if (dirPath !== result.root) void loadDir(dirPath)
236
+ // Restore previously-expanded folders in small batches so a workspace
237
+ // with many expanded folders doesn't flood the tree (and the page)
238
+ // all at once; the auto-refresh tick fills any remainder shortly after.
239
+ const dirs = [...expanded].filter((dirPath) => dirPath !== result.root)
240
+ for (let i = 0; i < dirs.length; i += DIR_LOAD_BATCH) {
241
+ if (controller.signal.aborted) return
242
+ const batch = dirs.slice(i, i + DIR_LOAD_BATCH)
243
+ await Promise.all(batch.map((dirPath) => loadDir(dirPath)))
244
+ if (i + DIR_LOAD_BATCH < dirs.length) {
245
+ await new Promise((resolve) => setTimeout(resolve, DIR_LOAD_GAP_MS))
246
+ }
141
247
  }
142
248
  })
143
249
  .catch((error) => {
@@ -240,8 +346,8 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
240
346
  else next.add(dirPath)
241
347
  return { ...prev, [cwdKey]: next }
242
348
  })
243
- if (!children[dirPath]) void loadDir(dirPath)
244
- }, [cwdKey, children, loadDir])
349
+ if (!childrenRef.current[dirPath]) void loadDir(dirPath)
350
+ }, [cwdKey, loadDir])
245
351
 
246
352
  const refreshDir = useCallback((dirPath: string) => {
247
353
  setChildren((prev) => {
@@ -285,59 +391,18 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
285
391
 
286
392
  return (
287
393
  <div key={entry.path}>
288
- <div
289
- className={`${styles.row} ${entry.kind === 'file' && activePath === entry.path ? styles.rowSelected : ''}`}
290
- style={{ paddingLeft: 8 + depth * 14 }}
291
- onClick={() => onRowClick(entry)}
292
- onDoubleClick={() => onRowDoubleClick(entry)}
293
- role="treeitem"
294
- aria-selected={entry.kind === 'file' && activePath === entry.path}
295
- aria-expanded={isDir ? isExpanded : undefined}
296
- title={entry.name}
297
- >
298
- <span
299
- className={styles.chevron}
300
- onClick={(e) => {
301
- e.stopPropagation()
302
- if (isDir) toggleDir(entry.path)
303
- }}
304
- >
305
- {isDir ? (isExpanded ? '▾' : '▸') : ''}
306
- </span>
307
- <span className={styles.icon}>
308
- {isDir ? (isExpanded ? '📂' : '📁') : entry.kind === 'file' ? <FileIcon name={entry.name} /> : '·'}
309
- </span>
310
- <span className={styles.name}>{entry.name}</span>
311
- {entry.kind === 'file' && entry.size !== undefined && (
312
- <span className={styles.size}>{formatSize(entry.size)}</span>
313
- )}
314
- <span className={styles.actions}>
315
- <button
316
- type="button"
317
- className={styles.action}
318
- title={t('action.open')}
319
- onClick={(e) => {
320
- e.stopPropagation()
321
- void openPath(entry.path)
322
- }}
323
- >
324
-
325
- </button>
326
- {isDir && (
327
- <button
328
- type="button"
329
- className={styles.action}
330
- title={t('action.refresh')}
331
- onClick={(e) => {
332
- e.stopPropagation()
333
- refreshDir(entry.path)
334
- }}
335
- >
336
-
337
- </button>
338
- )}
339
- </span>
340
- </div>
394
+ <TreeRow
395
+ entry={entry}
396
+ depth={depth}
397
+ isActive={entry.kind === 'file' && activePath === entry.path}
398
+ isExpanded={isExpanded}
399
+ onToggleDir={toggleDir}
400
+ onRefreshDir={refreshDir}
401
+ onSelect={onRowClick}
402
+ onDoubleClick={onRowDoubleClick}
403
+ onOpenExternal={openPath}
404
+ t={t}
405
+ />
341
406
  {isDir && isExpanded && (
342
407
  <div>
343
408
  {isLoading && <div className={styles.rowHint} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{t('preview.loading')}</div>}
@@ -7,7 +7,7 @@
7
7
  import { useCallback, useEffect, 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'
@@ -37,6 +37,22 @@ const PREVIEW_MIN = 240
37
37
  const CHAT_MIN = 240
38
38
  const PREVIEW_TOO_LARGE_LABEL = '512KB'
39
39
 
40
+ /**
41
+ * Above this size the overlay editor (syntax-highlight layer + transparent
42
+ * textarea) falls back to a plain textarea: re-injecting and re-laying out
43
+ * hundreds of KB of wrapped text on every keystroke is what makes the page
44
+ * lag. The plain textarea keeps editing, wrapping and scrolling — it only
45
+ * loses the colors, which files this big rarely need anyway.
46
+ */
47
+ const HIGHLIGHT_MAX_BYTES = 64 * 1024
48
+
49
+ /**
50
+ * Languages always rendered as a plain textarea, never the overlay: the
51
+ * highlight layer costs a full extra layout pass (and with CJK text a
52
+ * fragile alignment surface) for prose formats where colors add little.
53
+ */
54
+ const PLAIN_LANGUAGES = new Set(['markdown'])
55
+
40
56
  function basenameOf(path: string): string {
41
57
  const trimmed = path.replace(/[\\/]+$/, '')
42
58
  const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
@@ -77,38 +93,46 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
77
93
  const previewRef = useRef<HTMLDivElement>(null)
78
94
  const handleRef = useRef<HTMLDivElement>(null)
79
95
  const highlightRef = useRef<HTMLPreElement>(null)
96
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
80
97
 
81
98
  const refresh = useCallback(() => bump((v) => v + 1), [])
82
99
 
83
- // Read newly opened tabs and drop cache entries for closed tabs.
100
+ // Read the ACTIVE tab's content. Other tabs load lazily on first
101
+ // activation, so switching to a workspace with many large files doesn't
102
+ // re-read every tab at once (which used to freeze the switch).
84
103
  useEffect(() => {
85
104
  const cache = cacheRef.current
86
105
  for (const [path] of cache) {
87
106
  if (!tabs.includes(path)) cache.delete(path)
88
107
  }
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
- })
108
+ const target = active ?? tabs[0]
109
+ if (target === undefined || cache.has(target)) {
110
+ refresh()
111
+ return undefined
108
112
  }
113
+ const controller = new AbortController()
114
+ cache.set(target, { status: 'loading' })
115
+ readFile(target, controller.signal)
116
+ .then((result) => {
117
+ if (!controller.signal.aborted) {
118
+ cache.set(target, previewDataOf(result))
119
+ // Large content: yield one frame so the browser paints the
120
+ // loading→loaded transition before the heavy text layout runs —
121
+ // the UI stays responsive instead of freezing in the same frame
122
+ // as the tab-open interaction.
123
+ if (result.size > HIGHLIGHT_MAX_BYTES) requestAnimationFrame(refresh)
124
+ else refresh()
125
+ }
126
+ })
127
+ .catch((error) => {
128
+ if (!controller.signal.aborted) {
129
+ cache.set(target, { status: 'error', message: error instanceof Error ? error.message : String(error) })
130
+ refresh()
131
+ }
132
+ })
109
133
  refresh()
110
- return () => controllers.forEach((c) => c.abort())
111
- }, [tabs, readFile, refresh])
134
+ return () => controller.abort()
135
+ }, [tabs, active, readFile, refresh])
112
136
 
113
137
  // Animate the last tab closing without a mount/unmount bounce: keep the pane
114
138
  // mounted while `hasOpenedRef` is set, then drop it after the transition.
@@ -131,6 +155,31 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
131
155
 
132
156
  const isOpen = !collapsed && (tabs.length > 0 || closing || hasOpenedRef.current)
133
157
 
158
+ const activeData = active !== undefined ? cacheRef.current.get(active) : undefined
159
+ const language = active !== undefined ? detectLanguage(active) : undefined
160
+ const tooBig = (activeData?.size ?? 0) > HIGHLIGHT_MAX_BYTES
161
+ const plain = language === undefined || tooBig || (language !== undefined && PLAIN_LANGUAGES.has(language))
162
+
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.
169
+ useEffect(() => {
170
+ const pre = highlightRef.current
171
+ const ta = textareaRef.current
172
+ if (pre === null || ta === null || plain) return undefined
173
+ let raf = 0
174
+ const tick = () => {
175
+ pre.scrollTop = ta.scrollTop
176
+ pre.scrollLeft = ta.scrollLeft
177
+ raf = requestAnimationFrame(tick)
178
+ }
179
+ raf = requestAnimationFrame(tick)
180
+ return () => cancelAnimationFrame(raf)
181
+ }, [isOpen, active, activeData?.status, plain])
182
+
134
183
  // Publish the rendered preview width so the skin's fixed top/bottom trim can
135
184
  // shift past this pane (covering only the chat).
136
185
  useEffect(() => {
@@ -236,8 +285,6 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
236
285
  setDragPath(undefined)
237
286
  }, [])
238
287
 
239
- const activeData = active !== undefined ? cacheRef.current.get(active) : undefined
240
-
241
288
  if (!isOpen) return null
242
289
 
243
290
  const renderBody = () => {
@@ -245,14 +292,19 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
245
292
  switch (activeData.status) {
246
293
  case 'loading':
247
294
  return <div className={styles.previewHint}>{t('preview.loading')}</div>
248
- case 'loaded': {
249
- const highlighted = active !== undefined ? highlightCode(activeData.draft ?? '', active) : ''
295
+ case 'loaded':
250
296
  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>
297
+ <div
298
+ className={`${styles.editor}${plain ? ` ${styles.editorPlain}` : ''}`}
299
+ data-wrap={wrap ? 'on' : 'off'}
300
+ >
301
+ {!plain && active !== undefined && (
302
+ <pre ref={highlightRef} className={styles.editorHighlight} aria-hidden="true">
303
+ <code dangerouslySetInnerHTML={{ __html: highlightCode(activeData.draft ?? '', active) }} />
304
+ </pre>
305
+ )}
255
306
  <textarea
307
+ ref={textareaRef}
256
308
  className={styles.editorTextarea}
257
309
  value={activeData.draft ?? ''}
258
310
  onChange={(e) => onTextareaChange(e.target.value)}
@@ -260,12 +312,11 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
260
312
  onKeyDown={onKeyDown}
261
313
  spellCheck={false}
262
314
  wrap={wrap ? 'soft' : 'off'}
263
- title={t('action.saveHint')}
264
315
  />
316
+ {language !== undefined && tooBig && <div className={styles.editorHint}>{t('preview.highlightOff')}</div>}
265
317
  {saveError !== undefined && <div className={styles.saveError}>{saveError}</div>}
266
318
  </div>
267
319
  )
268
- }
269
320
  case 'binary':
270
321
  return (
271
322
  <div className={styles.previewHint}>
@@ -420,6 +420,29 @@
420
420
  position: relative;
421
421
  height: 100%;
422
422
  min-height: 0;
423
+ /* Keep layout/paint churn local to the editor: huge wrapped content must
424
+ not invalidate the whole page on every edit or scroll. */
425
+ contain: layout paint;
426
+ }
427
+
428
+ /* Plain (no-overlay) editor: the textarea itself renders the visible text —
429
+ used for files without syntax highlighting (e.g. .txt novels) and for
430
+ files too large to re-highlight per keystroke without lag. */
431
+ .editorPlain .editorTextarea {
432
+ color: var(--fe-fg);
433
+ }
434
+
435
+ /* Large-file notice chip: syntax highlight was dropped to stay responsive. */
436
+ .editorHint {
437
+ position: absolute;
438
+ left: 8px;
439
+ bottom: 8px;
440
+ padding: 4px 10px;
441
+ border-radius: 4px;
442
+ background: var(--fe-hover);
443
+ color: var(--fe-muted);
444
+ font-size: 11px;
445
+ pointer-events: none;
423
446
  }
424
447
 
425
448
  .editorHighlight {
@@ -427,6 +450,12 @@
427
450
  inset: 0;
428
451
  margin: 0;
429
452
  padding: 12px;
453
+ /* The textarea reserves one extra line at the bottom (its caret reserve),
454
+ so it can scroll ~1 line further than this layer. Match that range so
455
+ the two layers never desync at the bottom of a long file — otherwise
456
+ drag-selection near the end would highlight text one line ABOVE the
457
+ visible text. */
458
+ padding-bottom: calc(12px + 1.5em);
430
459
  /* auto (not hidden): the highlight layer gets the same scrollbar gutter as
431
460
  the textarea, so both content boxes have identical widths and wrap at the
432
461
  same points. Its scrollbars stack invisibly beneath the textarea's. */
@@ -573,8 +602,13 @@
573
602
  font-style: italic;
574
603
  }
575
604
 
605
+ /* Strong emphasis is color-only on purpose: `font-weight: bold` widens
606
+ glyphs, so the highlight layer would wrap at different points than the
607
+ textarea beneath it — drag-selection would then highlight text that no
608
+ longer matches the visible lines. Italic above is metric-neutral. */
576
609
  .editorHighlight :global(.hljs-strong) {
577
- font-weight: bold;
610
+ color: var(--fe-accent);
611
+ font-weight: inherit;
578
612
  }
579
613
 
580
614
  /* ---- Skin chrome fix ----
@@ -19,6 +19,7 @@ export const zh = {
19
19
  'preview.tooLarge': '文件过大,仅支持预览不超过 {limit}',
20
20
  'preview.emptyDir': '空目录',
21
21
  'preview.error': '加载失败',
22
+ 'preview.highlightOff': '大文件:已关闭语法高亮(仍可编辑)',
22
23
  } as const
23
24
 
24
25
  export type FilesKey = keyof typeof zh
@@ -40,4 +41,5 @@ export const en: Record<FilesKey, string> = {
40
41
  'preview.tooLarge': 'File too large to preview (limit {limit})',
41
42
  'preview.emptyDir': 'Empty directory',
42
43
  'preview.error': 'Load failed',
44
+ 'preview.highlightOff': 'Large file: syntax highlighting off (still editable)',
43
45
  }