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.
@@ -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;
@@ -378,6 +420,29 @@
378
420
  position: relative;
379
421
  height: 100%;
380
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;
381
446
  }
382
447
 
383
448
  .editorHighlight {
@@ -385,12 +450,22 @@
385
450
  inset: 0;
386
451
  margin: 0;
387
452
  padding: 12px;
388
- overflow: hidden;
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);
459
+ /* auto (not hidden): the highlight layer gets the same scrollbar gutter as
460
+ the textarea, so both content boxes have identical widths and wrap at the
461
+ same points. Its scrollbars stack invisibly beneath the textarea's. */
462
+ overflow: auto;
389
463
  pointer-events: none;
390
464
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
391
465
  font-size: 13px;
392
466
  line-height: 1.5;
393
467
  white-space: pre;
468
+ tab-size: 2;
394
469
  color: var(--fe-fg);
395
470
  background: transparent;
396
471
  }
@@ -422,6 +497,18 @@
422
497
  tab-size: 2;
423
498
  }
424
499
 
500
+ /* Display-only soft wrap: both layers wrap identically; the file content
501
+ itself is never modified (wrap="soft" on the textarea). */
502
+ .editor[data-wrap='on'] .editorHighlight,
503
+ .editor[data-wrap='on'] .editorTextarea {
504
+ white-space: pre-wrap;
505
+ overflow-wrap: anywhere;
506
+ }
507
+
508
+ .editor[data-wrap='on'] .editorTextarea {
509
+ overflow-x: hidden;
510
+ }
511
+
425
512
  .saveError {
426
513
  position: absolute;
427
514
  right: 8px;
@@ -515,8 +602,13 @@
515
602
  font-style: italic;
516
603
  }
517
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. */
518
609
  .editorHighlight :global(.hljs-strong) {
519
- font-weight: bold;
610
+ color: var(--fe-accent);
611
+ font-weight: inherit;
520
612
  }
521
613
 
522
614
  /* ---- Skin chrome fix ----
@@ -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': '弹出文件详情',
@@ -17,6 +19,7 @@ export const zh = {
17
19
  'preview.tooLarge': '文件过大,仅支持预览不超过 {limit}',
18
20
  'preview.emptyDir': '空目录',
19
21
  'preview.error': '加载失败',
22
+ 'preview.highlightOff': '大文件:已关闭语法高亮(仍可编辑)',
20
23
  } as const
21
24
 
22
25
  export type FilesKey = keyof typeof zh
@@ -28,6 +31,8 @@ export const en: Record<FilesKey, string> = {
28
31
  'action.open': 'Open in system',
29
32
  'action.theme': 'Toggle light/dark theme',
30
33
  'action.saveHint': 'Ctrl+S to save',
34
+ 'action.wrapOn': 'Enable word wrap (display only, file unchanged)',
35
+ 'action.wrapOff': 'Disable word wrap (long lines scroll horizontally)',
31
36
  'tab.close': 'Close tab',
32
37
  'tab.collapse': 'Collapse file details',
33
38
  'tab.expand': 'Expand file details',
@@ -36,4 +41,5 @@ export const en: Record<FilesKey, string> = {
36
41
  'preview.tooLarge': 'File too large to preview (limit {limit})',
37
42
  'preview.emptyDir': 'Empty directory',
38
43
  'preview.error': 'Load failed',
44
+ 'preview.highlightOff': 'Large file: syntax highlighting off (still editable)',
39
45
  }
@@ -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
+ }