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/CHANGELOG.md +22 -0
- package/README.md +3 -2
- package/lib/client.js +187 -115
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/FileExplorer.tsx +126 -61
- package/src/client/FilePreview.tsx +84 -33
- package/src/client/files.module.css +35 -1
- package/src/client/locales.ts +2 -0
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
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 (!
|
|
244
|
-
}, [cwdKey,
|
|
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
|
-
<
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 () =>
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
610
|
+
color: var(--fe-accent);
|
|
611
|
+
font-weight: inherit;
|
|
578
612
|
}
|
|
579
613
|
|
|
580
614
|
/* ---- Skin chrome fix ----
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
}
|