dsh-vscode-workbench 0.1.14 → 0.2.0

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.
Files changed (55) hide show
  1. package/README.en.md +4 -0
  2. package/README.md +8 -2
  3. package/lib/client.js +5014 -674
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +832 -80
  6. package/lib/index.js.map +1 -1
  7. package/lib/types/client/ActivityBar.d.ts +24 -0
  8. package/lib/types/client/DshConversationPanel.d.ts +1 -1
  9. package/lib/types/client/FileIcon.d.ts +8 -0
  10. package/lib/types/client/Icon.d.ts +1 -3
  11. package/lib/types/client/MonacoEditor.d.ts +36 -0
  12. package/lib/types/client/Workbench.d.ts +19 -7
  13. package/lib/types/client/WorkspaceSwitcher.d.ts +13 -0
  14. package/lib/types/client/api.d.ts +2 -0
  15. package/lib/types/client/documentPreview.d.ts +10 -2
  16. package/lib/types/client/dshComposer.d.ts +21 -0
  17. package/lib/types/client/dshNavigation.d.ts +46 -0
  18. package/lib/types/client/fileIconMap.d.ts +2 -0
  19. package/lib/types/client/gitError.d.ts +2 -0
  20. package/lib/types/client/lsp.d.ts +27 -0
  21. package/lib/types/client/searchHighlight.d.ts +5 -0
  22. package/lib/types/client/typescriptProject.d.ts +20 -0
  23. package/lib/types/context.d.ts +15 -0
  24. package/lib/types/host.d.ts +37 -0
  25. package/lib/types/lsp.d.ts +54 -0
  26. package/lib/types/office.d.ts +17 -0
  27. package/lib/types/types.d.ts +5 -0
  28. package/lib/types/watch-ignore.d.ts +4 -0
  29. package/package.json +5 -2
  30. package/src/client/ActivityBar.tsx +152 -0
  31. package/src/client/DshConversationPanel.tsx +15 -12
  32. package/src/client/FileIcon.tsx +6 -0
  33. package/src/client/Icon.tsx +1 -14
  34. package/src/client/MonacoEditor.tsx +187 -19
  35. package/src/client/TerminalPanel.tsx +6 -3
  36. package/src/client/Workbench.tsx +324 -81
  37. package/src/client/WorkspaceSwitcher.tsx +53 -0
  38. package/src/client/api.ts +27 -1
  39. package/src/client/documentPreview.tsx +92 -5
  40. package/src/client/dshComposer.ts +73 -0
  41. package/src/client/dshNavigation.ts +81 -0
  42. package/src/client/fileIconMap.ts +7 -0
  43. package/src/client/gitError.ts +5 -0
  44. package/src/client/index.tsx +28 -14
  45. package/src/client/lsp.ts +300 -0
  46. package/src/client/searchHighlight.ts +6 -0
  47. package/src/client/styles.ts +28 -31
  48. package/src/client/typescriptProject.ts +81 -0
  49. package/src/context.ts +8 -0
  50. package/src/host.ts +100 -15
  51. package/src/index.ts +121 -17
  52. package/src/lsp.ts +170 -0
  53. package/src/office.ts +95 -0
  54. package/src/types.ts +2 -0
  55. package/src/watch-ignore.ts +22 -1
@@ -0,0 +1,152 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2
+ import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react'
3
+
4
+ export interface ActivityView {
5
+ id: string
6
+ label: string
7
+ icon: ReactNode
8
+ active: boolean
9
+ disabled?: boolean
10
+ onActivate(): void
11
+ }
12
+
13
+ /** Keep known ids in saved order, appending newly added views at the end. */
14
+ export function normalizeActivityOrder(value: unknown, known: readonly string[]): string[] {
15
+ const saved = Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string' && known.includes(id)) : []
16
+ return [...new Set(saved), ...known.filter(id => !saved.includes(id))]
17
+ }
18
+
19
+ /** Move `id` before `before` (or to the end when absent), leaving unknown ids untouched. */
20
+ export function moveActivity(order: readonly string[], id: string, before: string | undefined): string[] {
21
+ if (!order.includes(id) || (before !== undefined && !order.includes(before))) return [...order]
22
+ const next = order.filter(value => value !== id)
23
+ const index = before === undefined ? next.length : next.indexOf(before)
24
+ next.splice(index, 0, id)
25
+ return next
26
+ }
27
+
28
+ export function toggleActivityHidden(hidden: readonly string[], id: string): string[] {
29
+ return hidden.includes(id) ? hidden.filter(value => value !== id) : [...hidden, id]
30
+ }
31
+
32
+ /** VS Code-style activity bar: pointer drag with a floating ghost and live reordering, right-click hide. */
33
+ export function ActivityBar({ views, fixed, bottom = [], order, hidden, onOrderChange, onHiddenChange }: {
34
+ views: ActivityView[]
35
+ fixed: ActivityView[]
36
+ bottom?: ActivityView[]
37
+ order: string[]
38
+ hidden: string[]
39
+ onOrderChange(order: string[]): void
40
+ onHiddenChange(hidden: string[]): void
41
+ }) {
42
+ const [menu, setMenu] = useState<{ x: number; y: number; target?: string }>()
43
+ const drag = useRef<{ id: string; x: number; y: number; active: boolean }>()
44
+ const nodes = useRef(new Map<string, HTMLButtonElement>())
45
+ const flip = useRef<Map<string, number>>()
46
+ const suppressClick = useRef(false)
47
+ const [draggingId, setDraggingId] = useState<string>()
48
+ const [ghost, setGhost] = useState<{ x: number; y: number }>()
49
+ const [preview, setPreview] = useState<string[]>()
50
+
51
+ useEffect(() => {
52
+ if (menu === undefined) return
53
+ const close = () => setMenu(undefined)
54
+ const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') close() }
55
+ window.addEventListener('pointerdown', close)
56
+ window.addEventListener('keydown', escape)
57
+ return () => { window.removeEventListener('pointerdown', close); window.removeEventListener('keydown', escape) }
58
+ }, [menu])
59
+
60
+ // Animate the neighbours into their new slots after a live reorder, like VS Code's activity bar.
61
+ useLayoutEffect(() => {
62
+ const before = flip.current
63
+ flip.current = undefined
64
+ if (before === undefined) return
65
+ for (const [id, element] of nodes.current) {
66
+ const previous = before.get(id)
67
+ if (previous === undefined) continue
68
+ const delta = previous - element.getBoundingClientRect().top
69
+ if (Math.abs(delta) < 1) continue
70
+ element.animate?.([{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }], { duration: 160, easing: 'ease' })
71
+ }
72
+ })
73
+
74
+ const visible = views.filter(view => !hidden.includes(view.id))
75
+ const currentOrder = preview ?? order
76
+ const ordered = currentOrder.flatMap(id => visible.filter(view => view.id === id))
77
+ const toggled = (id: string) => onHiddenChange(toggleActivityHidden(hidden, id))
78
+ const target = menu?.target === undefined ? undefined : views.find(view => view.id === menu.target)
79
+
80
+ const capturePositions = () => { flip.current = new Map([...nodes.current].map(([id, element]) => [id, element.getBoundingClientRect().top])) }
81
+ const beginDrag = (event: ReactPointerEvent<HTMLButtonElement>, id: string) => {
82
+ if (event.button !== 0) return
83
+ drag.current = { id, x: event.clientX, y: event.clientY, active: false }
84
+ event.currentTarget.setPointerCapture?.(event.pointerId)
85
+ }
86
+ const moveDrag = (event: ReactPointerEvent<HTMLButtonElement>, id: string) => {
87
+ const state = drag.current
88
+ if (state === undefined || state.id !== id) return
89
+ if (!state.active) {
90
+ if (Math.abs(event.clientX - state.x) + Math.abs(event.clientY - state.y) < 5) return
91
+ state.active = true; setDraggingId(id)
92
+ }
93
+ setGhost({ x: event.clientX, y: event.clientY })
94
+ const element = (document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null)?.closest<HTMLElement>('[data-act-id]')
95
+ if (element === null || element === undefined) return
96
+ const targetId = element.dataset.actId
97
+ if (targetId === undefined || targetId === id || !visible.some(view => view.id === targetId)) return
98
+ const rect = element.getBoundingClientRect()
99
+ const ids = currentOrder.filter(value => visible.some(view => view.id === value))
100
+ const index = ids.indexOf(targetId)
101
+ if (index < 0) return
102
+ const next = moveActivity(currentOrder, id, event.clientY > rect.top + rect.height / 2 ? ids[index + 1] : targetId)
103
+ if (next.join('|') === currentOrder.join('|')) return
104
+ capturePositions()
105
+ setPreview(next)
106
+ }
107
+ const endDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
108
+ const state = drag.current; drag.current = undefined
109
+ if (state?.active) {
110
+ suppressClick.current = true
111
+ window.setTimeout(() => { suppressClick.current = false }, 0)
112
+ if (preview !== undefined) onOrderChange(preview)
113
+ }
114
+ event.currentTarget.releasePointerCapture?.(event.pointerId)
115
+ setDraggingId(undefined); setGhost(undefined); setPreview(undefined)
116
+ }
117
+ const cancelDrag = () => { drag.current = undefined; setDraggingId(undefined); setGhost(undefined); setPreview(undefined) }
118
+ const renderView = (view: ActivityView) => <button
119
+ key={view.id}
120
+ ref={element => { if (element === null) nodes.current.delete(view.id); else nodes.current.set(view.id, element) }}
121
+ className="dvw-act"
122
+ data-act-id={view.id}
123
+ data-active={view.active}
124
+ data-dragging={draggingId === view.id || undefined}
125
+ disabled={view.disabled}
126
+ title={view.label}
127
+ onPointerDown={event => beginDrag(event, view.id)}
128
+ onPointerMove={event => moveDrag(event, view.id)}
129
+ onPointerUp={event => endDrag(event)}
130
+ onPointerCancel={cancelDrag}
131
+ onClick={() => { if (!suppressClick.current) view.onActivate() }}
132
+ onContextMenu={event => { event.preventDefault(); event.stopPropagation(); setMenu({ x: event.clientX, y: event.clientY, target: view.id }) }}
133
+ >{view.icon}</button>
134
+
135
+ return <>
136
+ <nav className="dvw-activity" onContextMenu={event => { event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY }) }}>
137
+ {ordered.length === 0 ? <span className="dvw-activity-empty" aria-hidden="true"/> : ordered.map(renderView)}
138
+ {fixed.map(view => <button key={view.id} className="dvw-act" data-act-id={view.id} data-active={view.active} disabled={view.disabled} title={view.label} onClick={view.onActivate}>{view.icon}</button>)}
139
+ <div className="dvw-activity-bottom">{bottom.map(view => <button key={view.id} className="dvw-act" data-act-id={view.id} data-active={view.active} disabled={view.disabled} title={view.label} onClick={view.onActivate}>{view.icon}</button>)}</div>
140
+ </nav>
141
+ {draggingId !== undefined && ghost !== undefined && <div className="dvw-activity-ghost" style={{ left: ghost.x, top: ghost.y }} aria-hidden="true">{views.find(view => view.id === draggingId)?.icon}</div>}
142
+ {menu !== undefined && <div className="dvw-context-menu dvw-activity-menu" role="menu" style={{ left: menu.x, top: menu.y }} onPointerDown={event => event.stopPropagation()}>
143
+ {target !== undefined && <button role="menuitem" onClick={() => { toggled(target.id); setMenu(undefined) }}>隐藏“{target.label}”</button>}
144
+ {target !== undefined && <div className="dvw-menu-separator"/>}
145
+ {views.map(view => <button role="menuitem" key={view.id} data-checked={!hidden.includes(view.id)} onClick={() => { toggled(view.id); setMenu(undefined) }}>
146
+ <span className="dvw-menu-check">{hidden.includes(view.id) ? '' : '✓'}</span>{view.label}
147
+ </button>)}
148
+ <div className="dvw-menu-separator"/>
149
+ <button role="menuitem" disabled={hidden.length === 0} onClick={() => { onHiddenChange([]); setMenu(undefined) }}>恢复所有视图</button>
150
+ </div>}
151
+ </>
152
+ }
@@ -25,10 +25,17 @@ export function DshConversationPanel({ visible, width, statusHeight }: { visible
25
25
  }
26
26
  attach()
27
27
  document.body.setAttribute('data-dvw-conversation-active', '')
28
- const observer = new MutationObserver(attach)
28
+ // Monaco rebuilds its line DOM on every scroll frame; skip workbench-only
29
+ // mutations and coalesce the rest so the document-wide query runs at most once a frame.
30
+ let frame = 0
31
+ const observer = new MutationObserver(records => {
32
+ if (frame !== 0 || records.every(record => record.target instanceof Element && record.target.closest('.dvw-overlay') !== null)) return
33
+ frame = requestAnimationFrame(() => { frame = 0; attach() })
34
+ })
29
35
  observer.observe(document.body, { childList: true, subtree: true })
30
36
  return () => {
31
37
  observer.disconnect()
38
+ cancelAnimationFrame(frame)
32
39
  document.body.removeAttribute('data-dvw-conversation-active')
33
40
  attached?.removeAttribute('data-dvw-conversation-panel')
34
41
  attached?.style.removeProperty('--dvw-chat-width')
@@ -38,21 +45,17 @@ export function DshConversationPanel({ visible, width, statusHeight }: { visible
38
45
  return null
39
46
  }
40
47
 
41
- /** Temporarily align the official DSH palette with a fixed workbench palette. */
48
+ /** Temporarily align the official DSH panel palette with a fixed workbench palette. */
42
49
  export function DshConversationTheme({ visible, colorScheme, followDsh }: { visible: boolean; colorScheme: 'dark' | 'light'; followDsh: boolean }) {
43
50
  useLayoutEffect(() => {
44
- if (!visible || followDsh) return
51
+ if (!visible) return
45
52
  const body = document.body
46
- const root = document.documentElement
47
- const wasDark = body.hasAttribute('data-ds-dark-theme')
48
- const previousColorScheme = root.style.colorScheme
49
- body.toggleAttribute('data-ds-dark-theme', colorScheme === 'dark')
50
- root.style.colorScheme = colorScheme
51
- return () => {
52
- body.toggleAttribute('data-ds-dark-theme', wasDark)
53
- if (previousColorScheme === '') root.style.removeProperty('color-scheme')
54
- else root.style.colorScheme = previousColorScheme
53
+ if (followDsh) {
54
+ body.removeAttribute('data-dvw-palette')
55
+ return
55
56
  }
57
+ body.setAttribute('data-dvw-palette', colorScheme)
58
+ return () => body.removeAttribute('data-dvw-palette')
56
59
  }, [visible, colorScheme, followDsh])
57
60
  return null
58
61
  }
@@ -0,0 +1,6 @@
1
+ import { fileIconName } from './fileIconMap.ts'
2
+
3
+ /** Colorful vscode-icons glyph for an explorer entry or editor tab. */
4
+ export function FileIcon({ name, directory = false, expanded = false, size = 16, className = 'dvw-file-icon' }: { name: string; directory?: boolean; expanded?: boolean; size?: number; className?: string }) {
5
+ return <img className={className} src={`/dsh-vscode/file-icons/${fileIconName(name, directory, expanded)}`} width={size} height={size} alt="" draggable={false}/>
6
+ }
@@ -1,5 +1,5 @@
1
1
  export type CodiconName =
2
- | 'add' | 'check' | 'check-all' | 'chevron-down' | 'chevron-right' | 'close' | 'close-all' | 'cloud-upload' | 'code' | 'color-mode' | 'edit'
2
+ | 'add' | 'arrow-right' | 'comment-discussion' | 'check' | 'check-all' | 'chevron-down' | 'chevron-right' | 'close' | 'close-all' | 'cloud-upload' | 'code' | 'color-mode' | 'edit'
3
3
  | 'diff' | 'diff-sidebyside' | 'diff-single' | 'discard' | 'file' | 'file-code' | 'file-media' | 'file-pdf' | 'file-zip'
4
4
  | 'files' | 'folder' | 'folder-opened' | 'git-branch' | 'git-commit' | 'graph' | 'home' | 'list-tree' | 'new-file'
5
5
  | 'layout-sidebar-right' | 'layout-sidebar-right-off' | 'new-folder' | 'preview' | 'refresh' | 'remove' | 'search' | 'settings' | 'source-control' | 'sparkle' | 'split-horizontal' | 'terminal' | 'trash'
@@ -8,16 +8,3 @@ export type CodiconName =
8
8
  export function Icon({ name, className = '' }: { name: CodiconName; className?: string }) {
9
9
  return <span aria-hidden="true" className={`codicon codicon-${name} ${className}`.trim()} />
10
10
  }
11
-
12
- const CODE_EXTENSIONS = new Set(['c','cc','cpp','cs','css','go','h','hpp','html','java','js','jsx','json','kt','lua','mjs','py','rb','rs','scss','sh','sql','swift','ts','tsx','vue','xml','yaml','yml'])
13
- const MEDIA_EXTENSIONS = new Set(['avif','bmp','gif','ico','jpeg','jpg','mp3','mp4','ogg','png','svg','webm','webp','wav'])
14
-
15
- /** Select a Codicon for a file name without depending on a file-icon theme. */
16
- export function fileIcon(name: string): CodiconName {
17
- const extension = name.includes('.') ? name.split('.').at(-1)?.toLowerCase() ?? '' : ''
18
- if (CODE_EXTENSIONS.has(extension)) return 'file-code'
19
- if (MEDIA_EXTENSIONS.has(extension)) return 'file-media'
20
- if (extension === 'pdf') return 'file-pdf'
21
- if (['zip','gz','tgz','tar','7z'].includes(extension)) return 'file-zip'
22
- return 'file'
23
- }
@@ -1,24 +1,103 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
+ import { searchHighlightRange } from './searchHighlight.ts'
3
+ import type { EditorSelection } from './dshComposer.ts'
4
+ import { isIndexedModel } from './typescriptProject.ts'
2
5
 
3
6
  type Monaco = typeof import('monaco-editor')
4
7
  declare global { interface Window { require?: { (ids: string[], done: () => void): void; config(value: unknown): void }; monaco?: Monaco } }
5
8
  let loading: Promise<Monaco> | undefined
6
9
  /** Monaco chrome used by both editors so only the narrow native scrollbar remains. */
7
10
  export const WORKBENCH_EDITOR_CHROME = {
11
+ // VS Code navigation: Ctrl+hover underlines definitions, multiple results open Peek, a single one jumps.
12
+ definitionLinkOpensInPeek: false,
13
+ gotoLocation: { multipleDefinitions: 'gotoAndPeek', multipleReferences: 'peek', multipleImplementations: 'peek', multipleTypeDefinitions: 'gotoAndPeek', multipleDeclarations: 'peek', alternativeDefinitionCommand: 'editor.action.goToReferences' },
14
+ parameterHints: { enabled: true, cycle: true },
15
+ quickSuggestions: { other: true, comments: false, strings: false },
16
+ suggestOnTriggerCharacters: true,
8
17
  minimap: { enabled: false },
9
18
  renderOverviewRuler: false,
10
19
  overviewRulerLanes: 0,
11
20
  hideCursorInOverviewRuler: true,
21
+ renderLineHighlightOnlyWhenFocus: true,
12
22
  scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 },
13
23
  } as const
14
24
 
25
+ /** Monaco theme aligned with the workbench dark palette. */
26
+ export const DARK_THEME = 'dvw-dark'
27
+ const DARK_RULES = [
28
+ { token: '', foreground: 'bfbfbf' },
29
+ { token: 'comment', foreground: '6a9955' },
30
+ { token: 'string', foreground: 'ce9178' },
31
+ { token: 'keyword', foreground: '569cd6' },
32
+ { token: 'number', foreground: 'b5cea8' },
33
+ { token: 'regexp', foreground: 'd16969' },
34
+ { token: 'type', foreground: '4ec9b0' },
35
+ { token: 'function', foreground: 'dcdcaa' },
36
+ { token: 'variable', foreground: '9cdcfe' },
37
+ { token: 'constant', foreground: '4fc1ff' },
38
+ { token: 'tag', foreground: '569cd6' },
39
+ { token: 'attribute.name', foreground: '9cdcfe' },
40
+ { token: 'attribute.value', foreground: 'ce9178' },
41
+ { token: 'delimiter', foreground: 'cccccc' },
42
+ ]
43
+ const DARK_COLORS = {
44
+ 'editor.background': '#121314',
45
+ 'editor.foreground': '#bfbfbf',
46
+ 'editorLineNumber.foreground': '#6e6e6e',
47
+ 'editorLineNumber.activeForeground': '#bfbfbf',
48
+ 'editorCursor.foreground': '#bfbfbf',
49
+ 'editor.selectionBackground': '#264f78',
50
+ 'editor.inactiveSelectionBackground': '#3a3d41',
51
+ 'editor.lineHighlightBackground': '#191a1b',
52
+ 'editorIndentGuide.background1': '#303132',
53
+ 'editorIndentGuide.activeBackground1': '#4a4b4c',
54
+ 'editorWhitespace.foreground': '#3b3b3b',
55
+ 'editorGutter.background': '#121314',
56
+ 'editorWidget.background': '#202122',
57
+ 'editorWidget.border': '#2a2b2c',
58
+ 'editorSuggestWidget.background': '#202122',
59
+ 'editorSuggestWidget.selectedBackground': '#2a2b2c',
60
+ 'editorHoverWidget.background': '#202122',
61
+ 'editorHoverWidget.border': '#2a2b2c',
62
+ 'editorBracketMatch.background': '#0d3a4d',
63
+ 'editorBracketMatch.border': '#307e9f',
64
+ 'diffEditor.insertedLineBackground': '#172319',
65
+ 'diffEditor.removedLineBackground': '#2d1919',
66
+ 'diffEditor.insertedTextBackground': '#2a4c2c',
67
+ 'diffEditor.removedTextBackground': '#5a2626',
68
+ 'diffEditor.border': '#2a2b2c',
69
+ 'diffEditor.diagonalFill': '#252526',
70
+ 'scrollbarSlider.background': '#79797966',
71
+ 'scrollbarSlider.hoverBackground': '#646464b3',
72
+ 'scrollbarSlider.activeBackground': '#bfbfbf66',
73
+ }
74
+ function defineWorkbenchThemes(monaco: Monaco): void {
75
+ monaco.editor.defineTheme(DARK_THEME, { base: 'vs-dark', inherit: true, rules: DARK_RULES, colors: DARK_COLORS })
76
+ }
77
+
78
+ /**
79
+ * Bootstrap source for Monaco's AMD web workers. Workers resolve module paths against their own
80
+ * blob URL, so they need an absolute `baseUrl`; without it the TypeScript language worker fails to load.
81
+ */
82
+ export function monacoWorkerBootstrap(base: string): string {
83
+ return `self.MonacoEnvironment={baseUrl:${JSON.stringify(base)}};importScripts(${JSON.stringify(`${base}vs/base/worker/workerMain.js`)});`
84
+ }
85
+
15
86
  export function loadMonaco(): Promise<Monaco> {
16
87
  loading ??= new Promise((resolve, reject) => {
17
88
  const start = () => {
18
89
  const loader = window.require
19
90
  if (loader === undefined) return reject(new Error('Monaco AMD loader unavailable'))
20
- loader.config({ paths: { vs: '/dsh-vscode/monaco/vs' } })
21
- loader(['vs/editor/editor.main'], () => window.monaco === undefined ? reject(new Error('Monaco failed to initialize')) : resolve(window.monaco))
91
+ const base = new URL('/dsh-vscode/monaco/', location.href).href
92
+ window.MonacoEnvironment = { getWorkerUrl: () => URL.createObjectURL(new Blob([monacoWorkerBootstrap(base)], { type: 'text/javascript' })) }
93
+ loader.config({ paths: { vs: `${base}vs` }, 'vs/nls': { availableLanguages: { '*': 'zh-cn' } } })
94
+ loader(['vs/nls.messages.zh-cn'], () => {
95
+ loader(['vs/editor/editor.main'], () => {
96
+ if (window.monaco === undefined) return reject(new Error('Monaco failed to initialize'))
97
+ defineWorkbenchThemes(window.monaco)
98
+ resolve(window.monaco)
99
+ })
100
+ })
22
101
  }
23
102
  if (window.require !== undefined) { start(); return }
24
103
  const script = document.createElement('script'); script.src = '/dsh-vscode/monaco/vs/loader.js'; script.onload = start; script.onerror = () => reject(new Error('failed to load Monaco')); document.head.append(script)
@@ -26,40 +105,129 @@ export function loadMonaco(): Promise<Monaco> {
26
105
  return loading
27
106
  }
28
107
 
29
- export function MonacoDiffEditor(props: { uri: string; original: string; modified: string; mode: 'inline' | 'split'; theme: 'dark' | 'light' }) {
108
+ export function MonacoDiffEditor(props: { uri: string; original: string; modified: string; mode: 'inline' | 'split'; theme: 'dark' | 'light'; fontSize?: number }) {
30
109
  const root=useRef<HTMLDivElement>(null);const editor=useRef<import('monaco-editor').editor.IStandaloneDiffEditor>();const models=useRef<{original:import('monaco-editor').editor.ITextModel;modified:import('monaco-editor').editor.ITextModel}>();const [error,setError]=useState<string>()
31
- useEffect(()=>{let disposed=false;void loadMonaco().then(monaco=>{if(disposed||root.current===null)return;const safe=props.uri.replace(/^\/+/, '');const original=monaco.editor.createModel(props.original,undefined,monaco.Uri.parse(`dvw-diff-original:///${safe}`));const modified=monaco.editor.createModel(props.modified,undefined,monaco.Uri.parse(`dvw-diff-modified:///${safe}`));models.current={original,modified};const instance=monaco.editor.createDiffEditor(root.current,{automaticLayout:true,readOnly:true,renderSideBySide:props.mode==='split',theme:props.theme==='dark'?'vs-dark':'vs',fontSize:14,...WORKBENCH_EDITOR_CHROME,originalEditable:false,ignoreTrimWhitespace:false});instance.setModel({original,modified});editor.current=instance}).catch(error=>setError(error instanceof Error?error.message:String(error)));return()=>{disposed=true;editor.current?.dispose();editor.current=undefined;models.current?.original.dispose();models.current?.modified.dispose();models.current=undefined}},[props.uri])
110
+ useEffect(()=>{let disposed=false;void loadMonaco().then(monaco=>{if(disposed||root.current===null)return;const safe=props.uri.replace(/^\/+/, '');const original=monaco.editor.createModel(props.original,undefined,monaco.Uri.parse(`dvw-diff-original:///${safe}`));const modified=monaco.editor.createModel(props.modified,undefined,monaco.Uri.parse(`dvw-diff-modified:///${safe}`));models.current={original,modified};const instance=monaco.editor.createDiffEditor(root.current,{automaticLayout:true,readOnly:true,renderSideBySide:props.mode==='split',theme:props.theme==='dark'?DARK_THEME:'vs',fontSize:props.fontSize??14,...WORKBENCH_EDITOR_CHROME,originalEditable:false,ignoreTrimWhitespace:false});instance.setModel({original,modified});editor.current=instance}).catch(error=>setError(error instanceof Error?error.message:String(error)));return()=>{disposed=true;editor.current?.dispose();editor.current=undefined;models.current?.original.dispose();models.current?.modified.dispose();models.current=undefined}},[props.uri])
32
111
  useEffect(()=>{const value=models.current;if(value===undefined)return;if(value.original.getValue()!==props.original)value.original.setValue(props.original);if(value.modified.getValue()!==props.modified)value.modified.setValue(props.modified)},[props.original,props.modified])
33
112
  useEffect(()=>{editor.current?.updateOptions({renderSideBySide:props.mode==='split'})},[props.mode])
34
- useEffect(()=>{if(window.monaco!==undefined)window.monaco.editor.setTheme(props.theme==='dark'?'vs-dark':'vs')},[props.theme])
113
+ useEffect(()=>{editor.current?.updateOptions({fontSize:props.fontSize??14})},[props.fontSize])
114
+ useEffect(()=>{if(window.monaco!==undefined)window.monaco.editor.setTheme(props.theme==='dark'?DARK_THEME:'vs')},[props.theme])
35
115
  return error===undefined?<div className="dvw-diff dvw-monaco-diff" data-mode={props.mode} data-render-side-by-side={props.mode==='split'} ref={root}/>:<div className="dvw-error">{error}</div>
36
116
  }
37
117
 
38
- export function MonacoEditor(props: { uri: string; value: string; theme: 'dark' | 'light'; line?: number; column?: number; onChange(value: string): void; onSave(): void }) {
118
+ /** Start downloading Monaco in the background so the first opened file does not wait for it. */
119
+ export function preloadMonaco(): void {
120
+ void loadMonaco().catch(() => { /* The editor reports the failure when it mounts. */ })
121
+ }
122
+
123
+ function modelUri(monaco: Monaco, uri: string): import('monaco-editor').Uri {
124
+ return monaco.Uri.parse(`file:///${uri.replace(/^\/+/, '')}`)
125
+ }
126
+
127
+ /** Scroll and cursor positions per document, kept while its tab stays open. */
128
+ const viewStates = new Map<string, import('monaco-editor').editor.ICodeEditorViewState>()
129
+ /** The search hit last revealed per document, so only a new hit overrides the saved position. */
130
+ const revealedHits = new Map<string, string>()
131
+ const hitKey = (props: { line?: number; column?: number; highlight?: string }) => props.line === undefined ? '' : `${props.line}:${props.column ?? 1}:${props.highlight ?? ''}`
132
+
133
+ /** Dispose the models of closed tabs so Monaco stops tracking their text. */
134
+ export function retainEditorModels(uris: string[]): void {
135
+ const monaco = window.monaco
136
+ if (monaco === undefined) return
137
+ const keep = new Set(uris.map(uri => modelUri(monaco, uri).toString()))
138
+ for (const model of monaco.editor.getModels()) {
139
+ const key = model.uri.toString()
140
+ if (model.uri.scheme !== 'file' || keep.has(key) || model.isAttachedToEditor()) continue
141
+ // Closing a tab returns its file to the language-service index instead of dropping it.
142
+ if (isIndexedModel(key)) { viewStates.delete(key); revealedHits.delete(key); continue }
143
+ viewStates.delete(key); revealedHits.delete(key); model.dispose()
144
+ }
145
+ }
146
+
147
+ /** The primary selection as text plus inclusive lines; a selection ending at column 1 does not claim that line. */
148
+ function editorSelection(instance: import('monaco-editor').editor.IStandaloneCodeEditor): EditorSelection | undefined {
149
+ const model = instance.getModel(); const range = instance.getSelection()
150
+ if (model === null || range === null || range.isEmpty()) return undefined
151
+ const endLine = range.endColumn === 1 && range.endLineNumber > range.startLineNumber ? range.endLineNumber - 1 : range.endLineNumber
152
+ return { text: model.getValueInRange(range), startLine: range.startLineNumber, endLine }
153
+ }
154
+
155
+ export function MonacoEditor(props: { uri: string; value: string; theme: 'dark' | 'light'; line?: number; column?: number; fontSize?: number; highlight?: string; onChange(value: string): void; onSave(): void; onAddToDsh?(selection?: EditorSelection): void }) {
39
156
  const root = useRef<HTMLDivElement>(null)
40
157
  const editor = useRef<import('monaco-editor').editor.IStandaloneCodeEditor>()
41
- const callbacks = useRef({ onChange: props.onChange, onSave: props.onSave })
42
- callbacks.current = { onChange: props.onChange, onSave: props.onSave }
158
+ const decorations = useRef<import('monaco-editor').editor.IEditorDecorationsCollection>()
159
+ const latest = useRef(props)
160
+ latest.current = props
43
161
  const [error, setError] = useState<string>()
162
+ /** Select the requested search hit in the current model. */
163
+ const reveal = (monaco: Monaco, instance: import('monaco-editor').editor.IStandaloneCodeEditor) => {
164
+ const { line: target, column, highlight } = latest.current
165
+ const model = instance.getModel()
166
+ decorations.current?.clear(); decorations.current = undefined
167
+ if (model === null) return
168
+ revealedHits.set(model.uri.toString(), hitKey(latest.current))
169
+ if (target === undefined) return
170
+ const line = Math.max(1, Math.min(Math.round(target), model.getLineCount()))
171
+ const text = model.getLineContent(line)
172
+ const needle = highlight ?? ''
173
+ const { startColumn, endColumn } = searchHighlightRange(text, needle, column ?? 1)
174
+ const range = new monaco.Range(line, startColumn, line, endColumn)
175
+ instance.setSelection(range)
176
+ instance.revealLineInCenter(line)
177
+ instance.focus()
178
+ decorations.current = needle === '' ? undefined : instance.createDecorationsCollection([{ range, options: { inlineClassName: 'dvw-search-hit' } }])
179
+ }
180
+ /** Show the current tab's model in the shared editor, restoring where the user left it. */
181
+ const attach = (monaco: Monaco, instance: import('monaco-editor').editor.IStandaloneCodeEditor) => {
182
+ const { uri, value } = latest.current
183
+ const next = modelUri(monaco, uri)
184
+ const previous = instance.getModel()
185
+ if (previous?.uri.toString() === next.toString()) return
186
+ const state = instance.saveViewState()
187
+ if (previous !== null && state !== null) viewStates.set(previous.uri.toString(), state)
188
+ const model = monaco.editor.getModel(next) ?? monaco.editor.createModel(value, undefined, next)
189
+ if (model.getValue() !== value) model.setValue(value)
190
+ instance.setModel(model)
191
+ const saved = viewStates.get(next.toString())
192
+ if (saved !== undefined) instance.restoreViewState(saved)
193
+ if (saved === undefined || revealedHits.get(next.toString()) !== hitKey(latest.current)) reveal(monaco, instance)
194
+ else { decorations.current?.clear(); decorations.current = undefined }
195
+ }
196
+ // One editor instance serves every text tab: switching tabs swaps its model
197
+ // instead of rebuilding Monaco's DOM, workers and services.
44
198
  useEffect(() => {
45
- let disposed = false; let model: import('monaco-editor').editor.ITextModel | undefined
199
+ let disposed = false
46
200
  void loadMonaco().then((monaco) => {
47
201
  if (disposed || root.current === null) return
48
- const uri = monaco.Uri.parse(`file:///${props.uri.replace(/^\/+/, '')}`)
49
- model = monaco.editor.getModel(uri) ?? monaco.editor.createModel(props.value, undefined, uri)
50
- if (model.getValue() !== props.value) model.setValue(props.value)
51
- const instance = monaco.editor.create(root.current, { model, automaticLayout: true, theme: props.theme === 'dark' ? 'vs-dark' : 'vs', fontSize: 14, ...WORKBENCH_EDITOR_CHROME, wordWrap: 'off' })
202
+ const props = latest.current
203
+ const instance = monaco.editor.create(root.current, { model: null, automaticLayout: true, theme: props.theme === 'dark' ? DARK_THEME : 'vs', fontSize: props.fontSize ?? 14, ...WORKBENCH_EDITOR_CHROME, wordWrap: 'off' })
52
204
  editor.current = instance
53
- const change = instance.onDidChangeModelContent(() => callbacks.current.onChange(instance.getValue()))
54
- instance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => callbacks.current.onSave())
55
- if (props.line !== undefined) { instance.setPosition({ lineNumber: props.line, column: props.column ?? 1 }); instance.revealLineInCenter(props.line); instance.focus() }
56
- return () => { change.dispose() }
205
+ instance.onDidChangeModelContent(() => latest.current.onChange(instance.getValue()))
206
+ instance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => latest.current.onSave())
207
+ instance.addAction({ id: 'dvw.addToDsh', label: '添加到 DSH 对话', contextMenuGroupId: 'navigation', contextMenuOrder: 0, keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt | monaco.KeyCode.KeyK], run: () => latest.current.onAddToDsh?.(editorSelection(instance)) })
208
+ attach(monaco, instance)
57
209
  }).catch(error => setError(error instanceof Error ? error.message : String(error)))
58
- return () => { disposed = true; editor.current?.dispose(); editor.current = undefined }
210
+ return () => {
211
+ disposed = true
212
+ const instance = editor.current
213
+ const model = instance?.getModel(); const state = instance?.saveViewState()
214
+ if (model && state) viewStates.set(model.uri.toString(), state)
215
+ instance?.dispose(); editor.current = undefined
216
+ }
217
+ }, [])
218
+ useEffect(() => {
219
+ const instance = editor.current; const monaco = window.monaco
220
+ if (instance !== undefined && monaco !== undefined) attach(monaco, instance)
59
221
  }, [props.uri])
60
222
  useEffect(() => {
61
223
  const model = editor.current?.getModel(); if (model !== null && model !== undefined && model.getValue() !== props.value) model.setValue(props.value)
62
224
  }, [props.value])
63
- useEffect(() => { if (window.monaco !== undefined) window.monaco.editor.setTheme(props.theme === 'dark' ? 'vs-dark' : 'vs') }, [props.theme])
225
+ useEffect(() => { if (window.monaco !== undefined) window.monaco.editor.setTheme(props.theme === 'dark' ? DARK_THEME : 'vs') }, [props.theme])
226
+ useEffect(() => { editor.current?.updateOptions({ fontSize: props.fontSize ?? 14 }) }, [props.fontSize])
227
+ useEffect(() => {
228
+ const instance = editor.current; const monaco = window.monaco; const model = instance?.getModel()
229
+ if (instance === undefined || monaco === undefined || model === null || model === undefined) return
230
+ if (revealedHits.get(model.uri.toString()) !== hitKey(props)) reveal(monaco, instance)
231
+ }, [props.uri, props.line, props.column, props.highlight])
64
232
  return error === undefined ? <div ref={root} style={{ position: 'absolute', inset: 0 }} /> : <div className="dvw-error">{error}</div>
65
233
  }
@@ -5,7 +5,9 @@ import { Icon } from './Icon.tsx'
5
5
  import type { BottomPanelProps } from './service.ts'
6
6
  import { isTerminalProtocolReply, TerminalWorkspaceController } from './terminal.ts'
7
7
 
8
- function terminalTheme(colorScheme: 'dark' | 'light') { return colorScheme === 'dark' ? { background: '#131417', foreground: '#f1f3f5', cursor: '#c2c6cc', selectionBackground: '#355d94' } : { background: '#ffffff', foreground: '#1d2025', cursor: '#4b515b', selectionBackground: '#add6ff' } }
8
+ // VS Code Light+ ANSI palette: the default xterm colors (e.g. yellow #e5e510) vanish on a light background.
9
+ const LIGHT_ANSI = { black: '#000000', red: '#cd3131', green: '#107c10', yellow: '#949800', blue: '#0451a5', magenta: '#bc05bc', cyan: '#0598bc', white: '#555555', brightBlack: '#666666', brightRed: '#cd3131', brightGreen: '#14ce14', brightYellow: '#b5ba00', brightBlue: '#0451a5', brightMagenta: '#bc05bc', brightCyan: '#0598bc', brightWhite: '#a5a5a5' }
10
+ function terminalTheme(colorScheme: 'dark' | 'light') { return colorScheme === 'dark' ? { background: '#191a1b', foreground: '#bfbfbf', cursor: '#bfbfbf', selectionBackground: '#264f78' } : { background: '#ffffff', foreground: '#1d2025', cursor: '#4b515b', selectionBackground: '#add6ff', ...LIGHT_ANSI } }
9
11
 
10
12
  function XtermView({ workspace, id, colorScheme }: { workspace: TerminalWorkspaceController; id: string; colorScheme: 'dark' | 'light' }) {
11
13
  const root = useRef<HTMLDivElement>(null)
@@ -15,7 +17,8 @@ function XtermView({ workspace, id, colorScheme }: { workspace: TerminalWorkspac
15
17
  const [error, setError] = useState<string>()
16
18
  useEffect(() => {
17
19
  if (root.current === null) return
18
- const terminal = new Terminal({ convertEol: false, cursorBlink: true, fontFamily: 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', fontSize: 13, theme: terminalTheme(colorScheme) })
20
+ const background = root.current === null ? undefined : getComputedStyle(root.current).backgroundColor
21
+ const terminal = new Terminal({ convertEol: false, cursorBlink: true, fontFamily: 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', fontSize: 13, minimumContrastRatio: 4.5, theme: { ...terminalTheme(colorScheme), ...(background === undefined ? {} : { background }) } })
19
22
  terminalRef.current = terminal
20
23
  const fit = new FitAddon()
21
24
  terminal.loadAddon(fit)
@@ -39,7 +42,7 @@ function XtermView({ workspace, id, colorScheme }: { workspace: TerminalWorkspac
39
42
  try { fit.fit(); terminal.focus() } catch (exception) { setError(exception instanceof Error ? exception.message : String(exception)) }
40
43
  return () => { terminalRef.current = undefined; clearTimeout(replayGuard.current); observer.disconnect(); connectionError(); input.dispose(); resize.dispose(); output(); connection.close(); terminal.dispose() }
41
44
  }, [workspace, id])
42
- useEffect(() => { if (terminalRef.current !== undefined) terminalRef.current.options.theme = terminalTheme(colorScheme) }, [colorScheme])
45
+ useEffect(() => { if (terminalRef.current === undefined) return; const background = root.current === null ? undefined : getComputedStyle(root.current).backgroundColor; terminalRef.current.options.theme = { ...terminalTheme(colorScheme), ...(background === undefined ? {} : { background }) } }, [colorScheme])
43
46
  return error === undefined ? <div ref={root} className="dvw-terminal-host" /> : <div className="dvw-terminal-error">{error}</div>
44
47
  }
45
48