dsh-plugin-workbench 0.0.2 → 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.2",
4
+ "version": "0.0.4",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",
7
7
  "engines": {
@@ -26,7 +26,8 @@
26
26
  "build": "tsdown",
27
27
  "watch": "tsdown --watch",
28
28
  "typecheck": "tsc --noEmit",
29
- "prepare": "pnpm run build"
29
+ "prepare": "pnpm run build",
30
+ "postbuild": "node scripts/verify-alignment.mjs"
30
31
  },
31
32
  "license": "MIT",
32
33
  "author": "Pasumao <1830316810@qq.com>",
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * verify-alignment.mjs — keep the plugin's three identities in sync.
4
+ *
5
+ * The dsh web boot fails with
6
+ * failed to import loader entry ... (X): client-modules: bundle ... loaded
7
+ * without registering "X" via __ModuleLoader__.load
8
+ * whenever the loader-entry name (the name the plugin is INSTALLED under in the
9
+ * dsh profile, or the `name:` in the profile's cordis.patch.yml insert) differs
10
+ * from the id the built client bundle registers — and the bundle always
11
+ * registers the package.json `name` (baked in at build time).
12
+ *
13
+ * This script checks all three against package.json's `name`:
14
+ * 1. built bundle registration id (lib/client.js head)
15
+ * 2. profile package.json dependency key pointing at this repo
16
+ * 3. profile cordis.patch.yml insert `name:` / dsh.profile.bundles entries
17
+ * and exits non-zero (failing the build via the `postbuild` hook) when they
18
+ * drift, so a rebuild can never silently break the next dsh boot again.
19
+ *
20
+ * Usage:
21
+ * node scripts/verify-alignment.mjs # check (exit 1 on mismatch)
22
+ * node scripts/verify-alignment.mjs --fix # repair profile files, re-check
23
+ * node scripts/verify-alignment.mjs --profile <name> # default: web
24
+ * DSH_VERIFY_SKIP=1 node ... # bypass (CI / other machines)
25
+ */
26
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
27
+ import { dirname, join, resolve } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+ import { homedir } from 'node:os'
30
+
31
+ const REPO_ROOT = realpathSync(fileURLToPath(new URL('..', import.meta.url)))
32
+ /** Read a text file, stripping a UTF-8 BOM if present (JSON.parse / regexes reject it). */
33
+ function readUtf8(path) {
34
+ const raw = readFileSync(path, 'utf8')
35
+ return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw
36
+ }
37
+
38
+ const MANIFEST = JSON.parse(readUtf8(join(REPO_ROOT, 'package.json')))
39
+ const REAL_NAME = MANIFEST.name
40
+ /** The scoped alias this plugin was historically installed under (null when the name is already scoped). */
41
+ const ALIAS = REAL_NAME.startsWith('@') ? null : `@dsh-external/${REAL_NAME}`
42
+ const ALIAS_RE = ALIAS ? new RegExp(`name:\\s*['"]?${escapeRegExp(ALIAS)}['"]?`) : null
43
+
44
+ const args = process.argv.slice(2)
45
+ const profileName = args.includes('--profile') ? args[args.indexOf('--profile') + 1] : 'web'
46
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
47
+ const profileDir = join(dshHome, 'profiles', profileName)
48
+ const manifestPath = join(profileDir, 'package.json')
49
+ const patchPath = join(profileDir, 'cordis.patch.yml')
50
+
51
+ if (process.env.DSH_VERIFY_SKIP === '1') {
52
+ console.log('[verify-alignment] skipped (DSH_VERIFY_SKIP=1).')
53
+ process.exit(0)
54
+ }
55
+
56
+ const clientRel = (() => {
57
+ const c = MANIFEST.exports?.['./client']
58
+ if (typeof c === 'string') return c
59
+ if (c && typeof c.default === 'string') return c.default
60
+ return 'lib/client.js'
61
+ })()
62
+
63
+ /** One check pass; returns the list of mismatches (empty = aligned). */
64
+ function check() {
65
+ const failures = []
66
+ const clientPath = join(REPO_ROOT, clientRel)
67
+ if (existsSync(clientPath)) {
68
+ const head = readUtf8(clientPath).slice(0, 400)
69
+ const m = /id:\s*"([^"]+)"/.exec(head)
70
+ if (!m) {
71
+ failures.push(`lib/client.js does not open with a __ModuleLoader__.load({ id: ... }) registration — is the bundle built?`)
72
+ } else if (m[1] !== REAL_NAME) {
73
+ failures.push(`bundle registers "${m[1]}" but package.json name is "${REAL_NAME}" — rebuild produced a mismatched id (tsdown derives it from the package name).`)
74
+ } else {
75
+ console.log(`[verify] bundle registration id OK: ${m[1]}`)
76
+ }
77
+ } else {
78
+ failures.push(`client bundle not found at ${clientPath} — run pnpm run build first.`)
79
+ }
80
+
81
+ if (!existsSync(manifestPath)) {
82
+ console.log(`[verify] profile "${profileName}" not found at ${profileDir} — skipping profile checks (fine outside this machine).`)
83
+ return failures
84
+ }
85
+
86
+ const profile = JSON.parse(readUtf8(manifestPath))
87
+ const deps = profile.dependencies ?? {}
88
+ const here = Object.entries(deps)
89
+ .map(([key, spec]) => {
90
+ const target = String(spec).replace(/^(?:link|file|workspace):/i, '').replace(/^\.\//, '')
91
+ const abs = resolve(profileDir, target)
92
+ try {
93
+ return { key, resolved: existsSync(abs) ? realpathSync(abs) : null }
94
+ } catch {
95
+ return { key, resolved: null }
96
+ }
97
+ })
98
+ .filter((d) => d.resolved === REPO_ROOT)
99
+ .map((d) => d.key)
100
+
101
+ if (here.length === 0) {
102
+ console.log(`[verify] ${REAL_NAME} is not installed in profile "${profileName}" — skipping install-name checks.`)
103
+ } else if (here.includes(REAL_NAME)) {
104
+ console.log(`[verify] profile install name OK: ${REAL_NAME}`)
105
+ } else {
106
+ failures.push(`profile dependency is keyed "${here[0]}" but must be "${REAL_NAME}": the loader entry name follows the install key, while the bundle registers the package name.`)
107
+ }
108
+
109
+ const bundles = profile.dsh?.profile?.bundles ?? []
110
+ if (ALIAS && bundles.includes(ALIAS)) {
111
+ failures.push(`dsh.profile.bundles lists "${ALIAS}" but must be "${REAL_NAME}".`)
112
+ }
113
+
114
+ if (existsSync(patchPath)) {
115
+ if (ALIAS_RE && ALIAS_RE.test(readUtf8(patchPath))) {
116
+ failures.push(`cordis.patch.yml references "${ALIAS}" in an insert name — the name: must be "${REAL_NAME}" (matches the installed package and the bundle registration).`)
117
+ } else {
118
+ console.log('[verify] profile patch name OK (no mismatched insert name).')
119
+ }
120
+ }
121
+ return failures
122
+ }
123
+
124
+ /** Apply the three profile repairs (only when the drift is unambiguous). Returns descriptions of what changed. */
125
+ function fix() {
126
+ const fixed = []
127
+ if (!existsSync(manifestPath)) return fixed
128
+ const profile = JSON.parse(readUtf8(manifestPath))
129
+ const deps = profile.dependencies ?? {}
130
+
131
+ if (ALIAS && deps[ALIAS] !== undefined && deps[REAL_NAME] === undefined) {
132
+ profile.dependencies[REAL_NAME] = deps[ALIAS]
133
+ delete profile.dependencies[ALIAS]
134
+ fixed.push(`package.json dependency key ${ALIAS} → ${REAL_NAME}`)
135
+ }
136
+
137
+ const bundles = profile.dsh?.profile?.bundles ?? []
138
+ if (ALIAS && bundles.includes(ALIAS)) {
139
+ profile.dsh.profile.bundles = bundles.map((b) => (b === ALIAS ? REAL_NAME : b))
140
+ fixed.push(`dsh.profile.bundles entry ${ALIAS} → ${REAL_NAME}`)
141
+ }
142
+
143
+ if (fixed.length > 0) writeFileSync(manifestPath, `${JSON.stringify(profile, null, 2)}\n`, 'utf8')
144
+
145
+ if (ALIAS && existsSync(patchPath)) {
146
+ let text = readUtf8(patchPath)
147
+ const before = text
148
+ text = text.replace(ALIAS_RE, (full) => full.replace(ALIAS, REAL_NAME))
149
+ if (text !== before) {
150
+ writeFileSync(patchPath, text, 'utf8')
151
+ fixed.push(`cordis.patch.yml insert name ${ALIAS} → ${REAL_NAME}`)
152
+ }
153
+ }
154
+ return fixed
155
+ }
156
+
157
+ let failures = check()
158
+ if (failures.length > 0 && args.includes('--fix')) {
159
+ const fixed = fix()
160
+ if (fixed.length > 0) console.log(`[verify] fixed: ${fixed.join('; ')}`)
161
+ failures = check()
162
+ if (failures.length === 0) {
163
+ console.log(`[verify] now re-link in the profile and restart dsh web:`)
164
+ console.log(` cd "${profileDir}" && pnpm install`)
165
+ }
166
+ }
167
+
168
+ if (failures.length > 0) {
169
+ console.error(`[verify-alignment] FAILED — ${failures.length} mismatch(es):`)
170
+ for (const f of failures) console.error(` - ${f}`)
171
+ console.error('[verify-alignment] fix with: node scripts/verify-alignment.mjs --fix (then pnpm install + restart dsh web)')
172
+ process.exit(1)
173
+ }
174
+
175
+ console.log(`[verify-alignment] PASS — bundle id, install name, and patch name all agree on "${REAL_NAME}".`)
176
+ process.exit(0)
177
+
178
+ function escapeRegExp(s) {
179
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
180
+ }
@@ -3,8 +3,9 @@
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
+ import { FileIcon } from './fileIcons'
8
9
  import type { FilesKey } from './locales'
9
10
  import { expandPreview, openFile, setCwd, toggleTheme, useTabsState } from './store'
10
11
 
@@ -63,6 +64,12 @@ function formatSize(bytes: number): string {
63
64
  /** How often the visible tree is re-listed to pick up disk changes. */
64
65
  const REFRESH_MS = 2000
65
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
+
66
73
  const EMPTY_EXPANDED = new Set<string>()
67
74
 
68
75
  /** True when two directory listings are identical (name/kind/size). */
@@ -74,6 +81,95 @@ function sameEntries(a: FsListEntry[], b: FsListEntry[]): boolean {
74
81
  return true
75
82
  }
76
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
+
77
173
  export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileExplorerProps) {
78
174
  const sessionList = useSessions((s) => s)
79
175
  const currentId = sessionList.current
@@ -97,6 +193,10 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
97
193
  // Latest tree snapshot for the polling tick (avoids stale closures).
98
194
  const treeRef = useRef({ root, children, expanded, rootLoading })
99
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
100
200
 
101
201
  // Expose the explorer width so the maid-atelier fixed chrome (top/bottom
102
202
  // trim) can shift past this column instead of covering it.
@@ -128,15 +228,22 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
128
228
  const controller = new AbortController()
129
229
  rootAbortRef.current = controller
130
230
  listDir(cwd, controller.signal)
131
- .then((result) => {
231
+ .then(async (result) => {
132
232
  if (controller.signal.aborted) return
133
233
  setRoot(result.root)
134
234
  setChildren({ [result.root]: result.entries })
135
235
  setRootError(undefined)
136
- // Reload the contents of this workspace's previously-expanded folders
137
- // right away instead of waiting for the auto-refresh tick.
138
- for (const dirPath of expanded) {
139
- 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
+ }
140
247
  }
141
248
  })
142
249
  .catch((error) => {
@@ -239,8 +346,8 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
239
346
  else next.add(dirPath)
240
347
  return { ...prev, [cwdKey]: next }
241
348
  })
242
- if (!children[dirPath]) void loadDir(dirPath)
243
- }, [cwdKey, children, loadDir])
349
+ if (!childrenRef.current[dirPath]) void loadDir(dirPath)
350
+ }, [cwdKey, loadDir])
244
351
 
245
352
  const refreshDir = useCallback((dirPath: string) => {
246
353
  setChildren((prev) => {
@@ -284,57 +391,18 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
284
391
 
285
392
  return (
286
393
  <div key={entry.path}>
287
- <div
288
- className={`${styles.row} ${entry.kind === 'file' && activePath === entry.path ? styles.rowSelected : ''}`}
289
- style={{ paddingLeft: 8 + depth * 14 }}
290
- onClick={() => onRowClick(entry)}
291
- onDoubleClick={() => onRowDoubleClick(entry)}
292
- role="treeitem"
293
- aria-selected={entry.kind === 'file' && activePath === entry.path}
294
- aria-expanded={isDir ? isExpanded : undefined}
295
- title={entry.name}
296
- >
297
- <span
298
- className={styles.chevron}
299
- onClick={(e) => {
300
- e.stopPropagation()
301
- if (isDir) toggleDir(entry.path)
302
- }}
303
- >
304
- {isDir ? (isExpanded ? '▾' : '▸') : ''}
305
- </span>
306
- <span className={styles.icon}>{isDir ? '📁' : entry.kind === 'file' ? '📄' : '·'}</span>
307
- <span className={styles.name}>{entry.name}</span>
308
- {entry.kind === 'file' && entry.size !== undefined && (
309
- <span className={styles.size}>{formatSize(entry.size)}</span>
310
- )}
311
- <span className={styles.actions}>
312
- <button
313
- type="button"
314
- className={styles.action}
315
- title={t('action.open')}
316
- onClick={(e) => {
317
- e.stopPropagation()
318
- void openPath(entry.path)
319
- }}
320
- >
321
-
322
- </button>
323
- {isDir && (
324
- <button
325
- type="button"
326
- className={styles.action}
327
- title={t('action.refresh')}
328
- onClick={(e) => {
329
- e.stopPropagation()
330
- refreshDir(entry.path)
331
- }}
332
- >
333
-
334
- </button>
335
- )}
336
- </span>
337
- </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
+ />
338
406
  {isDir && isExpanded && (
339
407
  <div>
340
408
  {isLoading && <div className={styles.rowHint} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{t('preview.loading')}</div>}
@@ -7,9 +7,10 @@
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
+ import { FileIcon } from './fileIcons'
11
12
  import type { FilesKey } from './locales'
12
- import { activateFile, closeFile, collapsePreview, moveTab, useTabsState } from './store'
13
+ import { activateFile, closeFile, collapsePreview, moveTab, toggleWrap, useTabsState } from './store'
13
14
  import type { FsReadResult } from './FileExplorer'
14
15
 
15
16
  interface TabData {
@@ -36,6 +37,22 @@ const PREVIEW_MIN = 240
36
37
  const CHAT_MIN = 240
37
38
  const PREVIEW_TOO_LARGE_LABEL = '512KB'
38
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
+
39
56
  function basenameOf(path: string): string {
40
57
  const trimmed = path.replace(/[\\/]+$/, '')
41
58
  const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
@@ -62,7 +79,7 @@ function previewDataOf(result: FsReadResult): TabData {
62
79
  }
63
80
 
64
81
  export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
65
- const { tabs, active, theme, collapsed } = useTabsState()
82
+ const { tabs, active, theme, collapsed, wrap } = useTabsState()
66
83
 
67
84
  const [previewWidth, setPreviewWidth] = useState<number | null>(null)
68
85
  const [closing, setClosing] = useState(false)
@@ -76,38 +93,46 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
76
93
  const previewRef = useRef<HTMLDivElement>(null)
77
94
  const handleRef = useRef<HTMLDivElement>(null)
78
95
  const highlightRef = useRef<HTMLPreElement>(null)
96
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
79
97
 
80
98
  const refresh = useCallback(() => bump((v) => v + 1), [])
81
99
 
82
- // 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).
83
103
  useEffect(() => {
84
104
  const cache = cacheRef.current
85
105
  for (const [path] of cache) {
86
106
  if (!tabs.includes(path)) cache.delete(path)
87
107
  }
88
- const controllers: AbortController[] = []
89
- for (const path of tabs) {
90
- if (cache.has(path)) continue
91
- cache.set(path, { status: 'loading' })
92
- const controller = new AbortController()
93
- controllers.push(controller)
94
- readFile(path, controller.signal)
95
- .then((result) => {
96
- if (!controller.signal.aborted) {
97
- cache.set(path, previewDataOf(result))
98
- refresh()
99
- }
100
- })
101
- .catch((error) => {
102
- if (!controller.signal.aborted) {
103
- cache.set(path, { status: 'error', message: error instanceof Error ? error.message : String(error) })
104
- refresh()
105
- }
106
- })
108
+ const target = active ?? tabs[0]
109
+ if (target === undefined || cache.has(target)) {
110
+ refresh()
111
+ return undefined
107
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
+ })
108
133
  refresh()
109
- return () => controllers.forEach((c) => c.abort())
110
- }, [tabs, readFile, refresh])
134
+ return () => controller.abort()
135
+ }, [tabs, active, readFile, refresh])
111
136
 
112
137
  // Animate the last tab closing without a mount/unmount bounce: keep the pane
113
138
  // mounted while `hasOpenedRef` is set, then drop it after the transition.
@@ -130,6 +155,31 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
130
155
 
131
156
  const isOpen = !collapsed && (tabs.length > 0 || closing || hasOpenedRef.current)
132
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
+
133
183
  // Publish the rendered preview width so the skin's fixed top/bottom trim can
134
184
  // shift past this pane (covering only the chat).
135
185
  useEffect(() => {
@@ -235,8 +285,6 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
235
285
  setDragPath(undefined)
236
286
  }, [])
237
287
 
238
- const activeData = active !== undefined ? cacheRef.current.get(active) : undefined
239
-
240
288
  if (!isOpen) return null
241
289
 
242
290
  const renderBody = () => {
@@ -244,27 +292,31 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
244
292
  switch (activeData.status) {
245
293
  case 'loading':
246
294
  return <div className={styles.previewHint}>{t('preview.loading')}</div>
247
- case 'loaded': {
248
- const highlighted = active !== undefined ? highlightCode(activeData.draft ?? '', active) : ''
295
+ case 'loaded':
249
296
  return (
250
- <div className={styles.editor}>
251
- <pre ref={highlightRef} className={styles.editorHighlight} aria-hidden="true">
252
- <code dangerouslySetInnerHTML={{ __html: highlighted }} />
253
- </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
+ )}
254
306
  <textarea
307
+ ref={textareaRef}
255
308
  className={styles.editorTextarea}
256
309
  value={activeData.draft ?? ''}
257
310
  onChange={(e) => onTextareaChange(e.target.value)}
258
311
  onScroll={onTextareaScroll}
259
312
  onKeyDown={onKeyDown}
260
313
  spellCheck={false}
261
- wrap="off"
262
- title={t('action.saveHint')}
314
+ wrap={wrap ? 'soft' : 'off'}
263
315
  />
316
+ {language !== undefined && tooBig && <div className={styles.editorHint}>{t('preview.highlightOff')}</div>}
264
317
  {saveError !== undefined && <div className={styles.saveError}>{saveError}</div>}
265
318
  </div>
266
319
  )
267
- }
268
320
  case 'binary':
269
321
  return (
270
322
  <div className={styles.previewHint}>
@@ -318,6 +370,7 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
318
370
  onDragEnd={onTabDragEnd}
319
371
  title={path}
320
372
  >
373
+ <span className={styles.tabIcon}><FileIcon name={basenameOf(path)} /></span>
321
374
  <span className={styles.tabName}>{basenameOf(path)}</span>
322
375
  {data?.dirty === true && <span className={styles.tabDot} />}
323
376
  <button
@@ -335,6 +388,14 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
335
388
  )
336
389
  })}
337
390
  </div>
391
+ <button
392
+ type="button"
393
+ className={`${styles.tabAction}${wrap ? ` ${styles.tabActionActive}` : ''}`}
394
+ title={wrap ? t('action.wrapOff') : t('action.wrapOn')}
395
+ onClick={toggleWrap}
396
+ >
397
+ {'⤶'}
398
+ </button>
338
399
  <button
339
400
  type="button"
340
401
  className={styles.collapse}