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/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.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}>{isDir ? '📁' : entry.kind === 'file' ? '📄' : '·'}</span>
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="off"
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
- overflow: hidden;
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;
@@ -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',
@@ -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
- let state: StoreState = { currentCwd: undefined, workspaces: {}, theme: 'dark' }
37
- let view: TabsStateView = { tabs: EMPTY_TABS, active: undefined, collapsed: false, theme: 'dark', cwd: undefined }
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
+ }