dsh-plugin-workbench 0.0.2 → 0.0.3
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 +10 -0
- package/README.md +2 -0
- package/lib/client.js +540 -43
- package/lib/client.js.map +1 -1
- package/package.json +3 -2
- package/scripts/verify-alignment.mjs +180 -0
- package/src/client/FileExplorer.tsx +4 -1
- package/src/client/FilePreview.tsx +14 -4
- package/src/client/fileIcons.tsx +144 -0
- package/src/client/files.module.css +61 -3
- package/src/client/locales.ts +4 -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.3",
|
|
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
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { 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
|
|
|
@@ -303,7 +304,9 @@ export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileE
|
|
|
303
304
|
>
|
|
304
305
|
{isDir ? (isExpanded ? '▾' : '▸') : ''}
|
|
305
306
|
</span>
|
|
306
|
-
<span className={styles.icon}>
|
|
307
|
+
<span className={styles.icon}>
|
|
308
|
+
{isDir ? (isExpanded ? '📂' : '📁') : entry.kind === 'file' ? <FileIcon name={entry.name} /> : '·'}
|
|
309
|
+
</span>
|
|
307
310
|
<span className={styles.name}>{entry.name}</span>
|
|
308
311
|
{entry.kind === 'file' && entry.size !== undefined && (
|
|
309
312
|
<span className={styles.size}>{formatSize(entry.size)}</span>
|
|
@@ -8,8 +8,9 @@ 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
10
|
import { 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 {
|
|
@@ -62,7 +63,7 @@ function previewDataOf(result: FsReadResult): TabData {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
65
|
-
const { tabs, active, theme, collapsed } = useTabsState()
|
|
66
|
+
const { tabs, active, theme, collapsed, wrap } = useTabsState()
|
|
66
67
|
|
|
67
68
|
const [previewWidth, setPreviewWidth] = useState<number | null>(null)
|
|
68
69
|
const [closing, setClosing] = useState(false)
|
|
@@ -247,7 +248,7 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
247
248
|
case 'loaded': {
|
|
248
249
|
const highlighted = active !== undefined ? highlightCode(activeData.draft ?? '', active) : ''
|
|
249
250
|
return (
|
|
250
|
-
<div className={styles.editor}>
|
|
251
|
+
<div className={styles.editor} data-wrap={wrap ? 'on' : 'off'}>
|
|
251
252
|
<pre ref={highlightRef} className={styles.editorHighlight} aria-hidden="true">
|
|
252
253
|
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
|
253
254
|
</pre>
|
|
@@ -258,7 +259,7 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
258
259
|
onScroll={onTextareaScroll}
|
|
259
260
|
onKeyDown={onKeyDown}
|
|
260
261
|
spellCheck={false}
|
|
261
|
-
wrap=
|
|
262
|
+
wrap={wrap ? 'soft' : 'off'}
|
|
262
263
|
title={t('action.saveHint')}
|
|
263
264
|
/>
|
|
264
265
|
{saveError !== undefined && <div className={styles.saveError}>{saveError}</div>}
|
|
@@ -318,6 +319,7 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
318
319
|
onDragEnd={onTabDragEnd}
|
|
319
320
|
title={path}
|
|
320
321
|
>
|
|
322
|
+
<span className={styles.tabIcon}><FileIcon name={basenameOf(path)} /></span>
|
|
321
323
|
<span className={styles.tabName}>{basenameOf(path)}</span>
|
|
322
324
|
{data?.dirty === true && <span className={styles.tabDot} />}
|
|
323
325
|
<button
|
|
@@ -335,6 +337,14 @@ export function FilePreview({ t, readFile, writeFile }: FilePreviewProps) {
|
|
|
335
337
|
)
|
|
336
338
|
})}
|
|
337
339
|
</div>
|
|
340
|
+
<button
|
|
341
|
+
type="button"
|
|
342
|
+
className={`${styles.tabAction}${wrap ? ` ${styles.tabActionActive}` : ''}`}
|
|
343
|
+
title={wrap ? t('action.wrapOff') : t('action.wrapOn')}
|
|
344
|
+
onClick={toggleWrap}
|
|
345
|
+
>
|
|
346
|
+
{'⤶'}
|
|
347
|
+
</button>
|
|
338
348
|
<button
|
|
339
349
|
type="button"
|
|
340
350
|
className={styles.collapse}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-type icons for the explorer tree rows and the preview tabs.
|
|
3
|
+
*
|
|
4
|
+
* Code/config files render as small colored badges with an extension label
|
|
5
|
+
* (VS Code "Minimal"-theme style); media, archives and installer files use
|
|
6
|
+
* emoji; anything unknown falls back to a generic document glyph. No icon
|
|
7
|
+
* assets are shipped — badges are pure CSS (per-language colors) and emoji
|
|
8
|
+
* come from the platform font.
|
|
9
|
+
*/
|
|
10
|
+
import styles from './files.module.css'
|
|
11
|
+
|
|
12
|
+
interface BadgeSpec {
|
|
13
|
+
label: string
|
|
14
|
+
bg: string
|
|
15
|
+
fg: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** lowercase extension (no dot) → badge colors. */
|
|
19
|
+
const BADGES: Record<string, BadgeSpec> = {
|
|
20
|
+
// code
|
|
21
|
+
ts: { label: 'TS', bg: '#3178c6', fg: '#ffffff' },
|
|
22
|
+
mts: { label: 'TS', bg: '#3178c6', fg: '#ffffff' },
|
|
23
|
+
cts: { label: 'TS', bg: '#3178c6', fg: '#ffffff' },
|
|
24
|
+
tsx: { label: 'TSX', bg: '#61dafb', fg: '#06283d' },
|
|
25
|
+
js: { label: 'JS', bg: '#f7df1e', fg: '#1e1e1e' },
|
|
26
|
+
mjs: { label: 'JS', bg: '#f7df1e', fg: '#1e1e1e' },
|
|
27
|
+
cjs: { label: 'JS', bg: '#f7df1e', fg: '#1e1e1e' },
|
|
28
|
+
jsx: { label: 'JSX', bg: '#61dafb', fg: '#06283d' },
|
|
29
|
+
json: { label: '{}', bg: '#cbcb41', fg: '#1e1e1e' },
|
|
30
|
+
jsonc: { label: '{}', bg: '#cbcb41', fg: '#1e1e1e' },
|
|
31
|
+
html: { label: 'HTML', bg: '#e44d26', fg: '#ffffff' },
|
|
32
|
+
htm: { label: 'HTML', bg: '#e44d26', fg: '#ffffff' },
|
|
33
|
+
css: { label: 'CSS', bg: '#2965f1', fg: '#ffffff' },
|
|
34
|
+
scss: { label: 'SCSS', bg: '#cc6699', fg: '#ffffff' },
|
|
35
|
+
less: { label: 'LESS', bg: '#2a4d80', fg: '#ffffff' },
|
|
36
|
+
md: { label: 'MD', bg: '#519aba', fg: '#ffffff' },
|
|
37
|
+
markdown: { label: 'MD', bg: '#519aba', fg: '#ffffff' },
|
|
38
|
+
py: { label: 'PY', bg: '#3572a5', fg: '#ffffff' },
|
|
39
|
+
pyw: { label: 'PY', bg: '#3572a5', fg: '#ffffff' },
|
|
40
|
+
go: { label: 'GO', bg: '#00add8', fg: '#06283d' },
|
|
41
|
+
rs: { label: 'RS', bg: '#dea584', fg: '#1e1e1e' },
|
|
42
|
+
java: { label: 'JAVA', bg: '#b07219', fg: '#ffffff' },
|
|
43
|
+
c: { label: 'C', bg: '#6e6e6e', fg: '#ffffff' },
|
|
44
|
+
h: { label: 'H', bg: '#6e6e6e', fg: '#ffffff' },
|
|
45
|
+
cpp: { label: 'CPP', bg: '#f34b7d', fg: '#ffffff' },
|
|
46
|
+
cc: { label: 'CPP', bg: '#f34b7d', fg: '#ffffff' },
|
|
47
|
+
cxx: { label: 'CPP', bg: '#f34b7d', fg: '#ffffff' },
|
|
48
|
+
hpp: { label: 'HPP', bg: '#f34b7d', fg: '#ffffff' },
|
|
49
|
+
hxx: { label: 'HPP', bg: '#f34b7d', fg: '#ffffff' },
|
|
50
|
+
cs: { label: 'CS', bg: '#178600', fg: '#ffffff' },
|
|
51
|
+
sh: { label: 'SH', bg: '#89e051', fg: '#1e1e1e' },
|
|
52
|
+
bash: { label: 'SH', bg: '#89e051', fg: '#1e1e1e' },
|
|
53
|
+
zsh: { label: 'SH', bg: '#89e051', fg: '#1e1e1e' },
|
|
54
|
+
ps1: { label: 'PS1', bg: '#012456', fg: '#ffffff' },
|
|
55
|
+
psm1: { label: 'PS1', bg: '#012456', fg: '#ffffff' },
|
|
56
|
+
psd1: { label: 'PS1', bg: '#012456', fg: '#ffffff' },
|
|
57
|
+
bat: { label: 'BAT', bg: '#6e6e6e', fg: '#ffffff' },
|
|
58
|
+
cmd: { label: 'BAT', bg: '#6e6e6e', fg: '#ffffff' },
|
|
59
|
+
sql: { label: 'SQL', bg: '#e38c00', fg: '#ffffff' },
|
|
60
|
+
yml: { label: 'YML', bg: '#cb171e', fg: '#ffffff' },
|
|
61
|
+
yaml: { label: 'YML', bg: '#cb171e', fg: '#ffffff' },
|
|
62
|
+
toml: { label: 'TOML', bg: '#8b8b8b', fg: '#ffffff' },
|
|
63
|
+
ini: { label: 'INI', bg: '#8b8b8b', fg: '#ffffff' },
|
|
64
|
+
conf: { label: 'INI', bg: '#8b8b8b', fg: '#ffffff' },
|
|
65
|
+
cfg: { label: 'INI', bg: '#8b8b8b', fg: '#ffffff' },
|
|
66
|
+
xml: { label: 'XML', bg: '#a074c4', fg: '#ffffff' },
|
|
67
|
+
xsl: { label: 'XML', bg: '#a074c4', fg: '#ffffff' },
|
|
68
|
+
svg: { label: 'SVG', bg: '#e37933', fg: '#ffffff' },
|
|
69
|
+
vue: { label: 'VUE', bg: '#41b883', fg: '#06283d' },
|
|
70
|
+
lock: { label: 'LOCK', bg: '#9d9d9d', fg: '#1e1e1e' },
|
|
71
|
+
env: { label: 'ENV', bg: '#f05033', fg: '#ffffff' },
|
|
72
|
+
gitignore: { label: 'GIT', bg: '#f05033', fg: '#ffffff' },
|
|
73
|
+
gitattributes: { label: 'GIT', bg: '#f05033', fg: '#ffffff' },
|
|
74
|
+
gitmodules: { label: 'GIT', bg: '#f05033', fg: '#ffffff' },
|
|
75
|
+
editorconfig: { label: 'CFG', bg: '#8b8b8b', fg: '#ffffff' },
|
|
76
|
+
npmrc: { label: 'CFG', bg: '#8b8b8b', fg: '#ffffff' },
|
|
77
|
+
pnpmrc: { label: 'CFG', bg: '#8b8b8b', fg: '#ffffff' },
|
|
78
|
+
yarnrc: { label: 'CFG', bg: '#8b8b8b', fg: '#ffffff' },
|
|
79
|
+
mk: { label: 'MK', bg: '#6d8086', fg: '#ffffff' },
|
|
80
|
+
makefile: { label: 'MK', bg: '#6d8086', fg: '#ffffff' },
|
|
81
|
+
txt: { label: 'TXT', bg: '#9d9d9d', fg: '#ffffff' },
|
|
82
|
+
log: { label: 'TXT', bg: '#9d9d9d', fg: '#ffffff' },
|
|
83
|
+
pdf: { label: 'PDF', bg: '#e74c3c', fg: '#ffffff' },
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** lowercase extension (no dot) → emoji glyph. */
|
|
87
|
+
const EMOJI: Record<string, string> = {
|
|
88
|
+
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', webp: '🖼️', bmp: '🖼️', ico: '🖼️', avif: '🖼️', jfif: '🖼️', tif: '🖼️', tiff: '🖼️',
|
|
89
|
+
mp3: '🎵', wav: '🎵', flac: '🎵', ogg: '🎵', oga: '🎵', m4a: '🎵', aac: '🎵', wma: '🎵', opus: '🎵', mid: '🎵',
|
|
90
|
+
mp4: '🎬', mkv: '🎬', avi: '🎬', mov: '🎬', webm: '🎬', flv: '🎬', wmv: '🎬', m4v: '🎬', mpg: '🎬', mpeg: '🎬',
|
|
91
|
+
zip: '🗜️', rar: '🗜️', '7z': '🗜️', tar: '🗜️', gz: '🗜️', bz2: '🗜️', xz: '🗜️', tgz: '🗜️', tbz2: '🗜️', zst: '🗜️',
|
|
92
|
+
doc: '📘', docx: '📘', odt: '📘', rtf: '📘',
|
|
93
|
+
xls: '📗', xlsx: '📗', ods: '📗', csv: '📗',
|
|
94
|
+
ppt: '📙', pptx: '📙', odp: '📙',
|
|
95
|
+
epub: '📖', mobi: '📖',
|
|
96
|
+
exe: '📦', msi: '📦', dmg: '📦', app: '📦', deb: '📦', rpm: '📦', apk: '📦', iso: '📦',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Well-known extension-less names (lowercase) → emoji. */
|
|
100
|
+
const NAMED_EMOJI: Record<string, string> = {
|
|
101
|
+
dockerfile: '🐳',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type FileIconSpec =
|
|
105
|
+
| { kind: 'badge'; label: string; bg: string; fg: string }
|
|
106
|
+
| { kind: 'emoji'; char: string }
|
|
107
|
+
|
|
108
|
+
/** Resolve a file name to its icon spec (badge colors or an emoji glyph). */
|
|
109
|
+
export function fileIconFor(name: string): FileIconSpec {
|
|
110
|
+
const lower = name.trim().toLowerCase()
|
|
111
|
+
if (lower === '') return { kind: 'emoji', char: '📄' }
|
|
112
|
+
|
|
113
|
+
const namedEmoji = NAMED_EMOJI[lower]
|
|
114
|
+
if (namedEmoji !== undefined) return { kind: 'emoji', char: namedEmoji }
|
|
115
|
+
|
|
116
|
+
// Dotfiles use the dotted remainder as the key (`.gitignore` → gitignore,
|
|
117
|
+
// `.env.local` → env), regular files use the last extension segment.
|
|
118
|
+
let key: string
|
|
119
|
+
if (lower.startsWith('.')) {
|
|
120
|
+
const rest = lower.slice(1)
|
|
121
|
+
key = rest.startsWith('env.') ? 'env' : rest
|
|
122
|
+
} else {
|
|
123
|
+
const dot = lower.lastIndexOf('.')
|
|
124
|
+
key = dot > 0 ? lower.slice(dot + 1) : lower
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const badge = BADGES[key]
|
|
128
|
+
if (badge !== undefined) return { kind: 'badge', label: badge.label, bg: badge.bg, fg: badge.fg }
|
|
129
|
+
const emoji = EMOJI[key]
|
|
130
|
+
if (emoji !== undefined) return { kind: 'emoji', char: emoji }
|
|
131
|
+
return { kind: 'emoji', char: '📄' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Render a file's icon: a colored extension badge or an emoji glyph. */
|
|
135
|
+
export function FileIcon({ name }: { name: string }) {
|
|
136
|
+
const icon = fileIconFor(name)
|
|
137
|
+
if (icon.kind === 'emoji') return <>{icon.char}</>
|
|
138
|
+
const className = icon.label.length >= 4 ? `${styles.fileBadge} ${styles.fileBadgeLong}` : styles.fileBadge
|
|
139
|
+
return (
|
|
140
|
+
<span className={className} style={{ background: icon.bg, color: icon.fg }}>
|
|
141
|
+
{icon.label}
|
|
142
|
+
</span>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -170,6 +170,30 @@
|
|
|
170
170
|
line-height: 22px;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/* Colored extension badge (VS Code "Minimal"-style file icon). */
|
|
174
|
+
.fileBadge {
|
|
175
|
+
display: inline-flex;
|
|
176
|
+
align-items: center;
|
|
177
|
+
justify-content: center;
|
|
178
|
+
width: 16px;
|
|
179
|
+
height: 15px;
|
|
180
|
+
border-radius: 3px;
|
|
181
|
+
font-size: 8px;
|
|
182
|
+
font-weight: 700;
|
|
183
|
+
line-height: 1;
|
|
184
|
+
letter-spacing: -0.3px;
|
|
185
|
+
padding: 0 1px;
|
|
186
|
+
box-sizing: border-box;
|
|
187
|
+
vertical-align: middle;
|
|
188
|
+
user-select: none;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* Four-character labels (HTML/JAVA/SCSS/LESS/TOML/…) need a smaller size. */
|
|
192
|
+
.fileBadgeLong {
|
|
193
|
+
font-size: 6.5px;
|
|
194
|
+
letter-spacing: -0.4px;
|
|
195
|
+
}
|
|
196
|
+
|
|
173
197
|
.name {
|
|
174
198
|
flex: 1;
|
|
175
199
|
min-width: 0;
|
|
@@ -281,7 +305,8 @@
|
|
|
281
305
|
scrollbar-width: thin;
|
|
282
306
|
}
|
|
283
307
|
|
|
284
|
-
.collapse
|
|
308
|
+
.collapse,
|
|
309
|
+
.tabAction {
|
|
285
310
|
flex: none;
|
|
286
311
|
appearance: none;
|
|
287
312
|
border: none;
|
|
@@ -300,11 +325,18 @@
|
|
|
300
325
|
padding: 0;
|
|
301
326
|
}
|
|
302
327
|
|
|
303
|
-
.collapse:hover
|
|
328
|
+
.collapse:hover,
|
|
329
|
+
.tabAction:hover {
|
|
304
330
|
background: var(--fe-hover);
|
|
305
331
|
color: var(--fe-fg);
|
|
306
332
|
}
|
|
307
333
|
|
|
334
|
+
/* Wrap toggle active state: soft wrap is on. */
|
|
335
|
+
.tabActionActive {
|
|
336
|
+
background: var(--fe-hover);
|
|
337
|
+
color: var(--fe-accent);
|
|
338
|
+
}
|
|
339
|
+
|
|
308
340
|
.tab {
|
|
309
341
|
flex: none;
|
|
310
342
|
display: flex;
|
|
@@ -337,6 +369,16 @@
|
|
|
337
369
|
text-overflow: ellipsis;
|
|
338
370
|
}
|
|
339
371
|
|
|
372
|
+
.tabIcon {
|
|
373
|
+
flex: none;
|
|
374
|
+
width: 16px;
|
|
375
|
+
display: inline-flex;
|
|
376
|
+
align-items: center;
|
|
377
|
+
justify-content: center;
|
|
378
|
+
font-size: 13px;
|
|
379
|
+
line-height: 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
340
382
|
.tabClose {
|
|
341
383
|
appearance: none;
|
|
342
384
|
border: none;
|
|
@@ -385,12 +427,16 @@
|
|
|
385
427
|
inset: 0;
|
|
386
428
|
margin: 0;
|
|
387
429
|
padding: 12px;
|
|
388
|
-
|
|
430
|
+
/* auto (not hidden): the highlight layer gets the same scrollbar gutter as
|
|
431
|
+
the textarea, so both content boxes have identical widths and wrap at the
|
|
432
|
+
same points. Its scrollbars stack invisibly beneath the textarea's. */
|
|
433
|
+
overflow: auto;
|
|
389
434
|
pointer-events: none;
|
|
390
435
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
391
436
|
font-size: 13px;
|
|
392
437
|
line-height: 1.5;
|
|
393
438
|
white-space: pre;
|
|
439
|
+
tab-size: 2;
|
|
394
440
|
color: var(--fe-fg);
|
|
395
441
|
background: transparent;
|
|
396
442
|
}
|
|
@@ -422,6 +468,18 @@
|
|
|
422
468
|
tab-size: 2;
|
|
423
469
|
}
|
|
424
470
|
|
|
471
|
+
/* Display-only soft wrap: both layers wrap identically; the file content
|
|
472
|
+
itself is never modified (wrap="soft" on the textarea). */
|
|
473
|
+
.editor[data-wrap='on'] .editorHighlight,
|
|
474
|
+
.editor[data-wrap='on'] .editorTextarea {
|
|
475
|
+
white-space: pre-wrap;
|
|
476
|
+
overflow-wrap: anywhere;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
.editor[data-wrap='on'] .editorTextarea {
|
|
480
|
+
overflow-x: hidden;
|
|
481
|
+
}
|
|
482
|
+
|
|
425
483
|
.saveError {
|
|
426
484
|
position: absolute;
|
|
427
485
|
right: 8px;
|
package/src/client/locales.ts
CHANGED
|
@@ -9,6 +9,8 @@ export const zh = {
|
|
|
9
9
|
'action.open': '在系统中打开',
|
|
10
10
|
'action.theme': '切换浅色/暗色主题',
|
|
11
11
|
'action.saveHint': 'Ctrl+S 保存',
|
|
12
|
+
'action.wrapOn': '开启自动换行(显示层换行,不改文件)',
|
|
13
|
+
'action.wrapOff': '关闭自动换行(长行横向滚动)',
|
|
12
14
|
'tab.close': '关闭标签页',
|
|
13
15
|
'tab.collapse': '收起文件详情',
|
|
14
16
|
'tab.expand': '弹出文件详情',
|
|
@@ -28,6 +30,8 @@ export const en: Record<FilesKey, string> = {
|
|
|
28
30
|
'action.open': 'Open in system',
|
|
29
31
|
'action.theme': 'Toggle light/dark theme',
|
|
30
32
|
'action.saveHint': 'Ctrl+S to save',
|
|
33
|
+
'action.wrapOn': 'Enable word wrap (display only, file unchanged)',
|
|
34
|
+
'action.wrapOff': 'Disable word wrap (long lines scroll horizontally)',
|
|
31
35
|
'tab.close': 'Close tab',
|
|
32
36
|
'tab.collapse': 'Collapse file details',
|
|
33
37
|
'tab.expand': 'Expand file details',
|
package/src/client/store.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface TabsStateView {
|
|
|
20
20
|
tabs: string[]
|
|
21
21
|
active: string | undefined
|
|
22
22
|
collapsed: boolean
|
|
23
|
+
/** Display-only soft wrap for the editor (never touches the file content). */
|
|
24
|
+
wrap: boolean
|
|
23
25
|
theme: FeTheme
|
|
24
26
|
cwd: string | undefined
|
|
25
27
|
}
|
|
@@ -27,14 +29,29 @@ export interface TabsStateView {
|
|
|
27
29
|
const EMPTY_TABS: string[] = []
|
|
28
30
|
const EMPTY_WORKSPACE: PerWorkspace = { tabs: EMPTY_TABS, active: undefined, collapsed: false }
|
|
29
31
|
|
|
32
|
+
const WRAP_KEY = 'dsh-plugin-workbench:wrap'
|
|
33
|
+
|
|
34
|
+
/** Read the persisted wrap preference; defaults to soft wrap ON. */
|
|
35
|
+
function storedWrap(): boolean {
|
|
36
|
+
try {
|
|
37
|
+
if (typeof window === 'undefined') return true
|
|
38
|
+
const stored = window.localStorage.getItem(WRAP_KEY)
|
|
39
|
+
return stored === null ? true : stored === '1'
|
|
40
|
+
} catch {
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
interface StoreState {
|
|
31
46
|
currentCwd: string | undefined
|
|
32
47
|
workspaces: Record<string, PerWorkspace>
|
|
33
48
|
theme: FeTheme
|
|
49
|
+
wrap: boolean
|
|
34
50
|
}
|
|
35
51
|
|
|
36
|
-
|
|
37
|
-
let
|
|
52
|
+
const initialWrap = storedWrap()
|
|
53
|
+
let state: StoreState = { currentCwd: undefined, workspaces: {}, theme: 'dark', wrap: initialWrap }
|
|
54
|
+
let view: TabsStateView = { tabs: EMPTY_TABS, active: undefined, collapsed: false, wrap: initialWrap, theme: 'dark', cwd: undefined }
|
|
38
55
|
const listeners = new Set<() => void>()
|
|
39
56
|
|
|
40
57
|
function workspaceOf(cwd: string | undefined): PerWorkspace {
|
|
@@ -63,6 +80,7 @@ function commit(next: StoreState): void {
|
|
|
63
80
|
tabs: ws.tabs,
|
|
64
81
|
active: ws.active,
|
|
65
82
|
collapsed: ws.collapsed,
|
|
83
|
+
wrap: state.wrap,
|
|
66
84
|
theme: state.theme,
|
|
67
85
|
cwd: state.currentCwd,
|
|
68
86
|
}
|
|
@@ -70,6 +88,7 @@ function commit(next: StoreState): void {
|
|
|
70
88
|
nextView.tabs === view.tabs
|
|
71
89
|
&& nextView.active === view.active
|
|
72
90
|
&& nextView.collapsed === view.collapsed
|
|
91
|
+
&& nextView.wrap === view.wrap
|
|
73
92
|
&& nextView.theme === view.theme
|
|
74
93
|
&& nextView.cwd === view.cwd
|
|
75
94
|
) return
|
|
@@ -149,3 +168,14 @@ export function expandPreview(): void {
|
|
|
149
168
|
export function toggleTheme(): void {
|
|
150
169
|
commit({ ...state, theme: state.theme === 'dark' ? 'light' : 'dark' })
|
|
151
170
|
}
|
|
171
|
+
|
|
172
|
+
/** Toggle display-only soft wrap for the editor (global, persisted). */
|
|
173
|
+
export function toggleWrap(): void {
|
|
174
|
+
const wrap = !state.wrap
|
|
175
|
+
try {
|
|
176
|
+
window.localStorage.setItem(WRAP_KEY, wrap ? '1' : '0')
|
|
177
|
+
} catch {
|
|
178
|
+
// storage unavailable — the preference just won't persist across reloads
|
|
179
|
+
}
|
|
180
|
+
commit({ ...state, wrap })
|
|
181
|
+
}
|