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/CHANGELOG.md +32 -0
- package/README.md +5 -2
- package/lib/client.js +723 -154
- package/lib/client.js.map +1 -1
- package/package.json +3 -2
- package/scripts/verify-alignment.mjs +180 -0
- package/src/client/FileExplorer.tsx +127 -59
- package/src/client/FilePreview.tsx +97 -36
- package/src/client/fileIcons.tsx +144 -0
- package/src/client/files.module.css +96 -4
- package/src/client/locales.ts +6 -0
- package/src/client/store.ts +32 -2
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.
|
|
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
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
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 (!
|
|
243
|
-
}, [cwdKey,
|
|
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
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 () =>
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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=
|
|
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}
|