dsh-vscode-workbench 0.1.12 → 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.
- package/README.en.md +15 -6
- package/README.md +17 -6
- package/lib/client.js +5118 -728
- package/lib/client.js.map +1 -1
- package/lib/index.js +899 -57
- package/lib/index.js.map +1 -1
- package/lib/types/client/ActivityBar.d.ts +24 -0
- package/lib/types/client/DshConversationPanel.d.ts +1 -1
- package/lib/types/client/FileIcon.d.ts +8 -0
- package/lib/types/client/Icon.d.ts +1 -3
- package/lib/types/client/MonacoEditor.d.ts +36 -0
- package/lib/types/client/Workbench.d.ts +27 -7
- package/lib/types/client/WorkspaceSwitcher.d.ts +13 -0
- package/lib/types/client/api.d.ts +2 -0
- package/lib/types/client/documentPreview.d.ts +10 -2
- package/lib/types/client/dshComposer.d.ts +21 -0
- package/lib/types/client/dshNavigation.d.ts +46 -0
- package/lib/types/client/fileIconMap.d.ts +2 -0
- package/lib/types/client/gitError.d.ts +2 -0
- package/lib/types/client/lsp.d.ts +27 -0
- package/lib/types/client/searchHighlight.d.ts +5 -0
- package/lib/types/client/typescriptProject.d.ts +20 -0
- package/lib/types/context.d.ts +15 -0
- package/lib/types/host.d.ts +37 -0
- package/lib/types/lsp.d.ts +54 -0
- package/lib/types/office.d.ts +17 -0
- package/lib/types/types.d.ts +5 -0
- package/lib/types/watch-ignore.d.ts +12 -0
- package/package.json +21 -17
- package/src/client/ActivityBar.tsx +152 -0
- package/src/client/DshConversationPanel.tsx +15 -12
- package/src/client/FileIcon.tsx +6 -0
- package/src/client/Icon.tsx +1 -14
- package/src/client/MonacoEditor.tsx +187 -19
- package/src/client/TerminalPanel.tsx +6 -3
- package/src/client/Workbench.tsx +342 -78
- package/src/client/WorkspaceSwitcher.tsx +53 -0
- package/src/client/api.ts +27 -1
- package/src/client/documentPreview.tsx +92 -5
- package/src/client/dshComposer.ts +73 -0
- package/src/client/dshNavigation.ts +81 -0
- package/src/client/fileIconMap.ts +7 -0
- package/src/client/gitError.ts +5 -0
- package/src/client/index.tsx +28 -14
- package/src/client/lsp.ts +300 -0
- package/src/client/searchHighlight.ts +6 -0
- package/src/client/styles.ts +31 -30
- package/src/client/typescriptProject.ts +81 -0
- package/src/context.ts +8 -0
- package/src/host.ts +100 -15
- package/src/index.ts +123 -12
- package/src/lsp.ts +170 -0
- package/src/office.ts +95 -0
- package/src/types.ts +2 -0
- package/src/watch-ignore.ts +84 -0
|
@@ -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
|
-
|
|
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
|
|
51
|
+
if (!visible) return
|
|
45
52
|
const body = document.body
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
}
|
package/src/client/Icon.tsx
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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'?
|
|
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(()=>{
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
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
|
|
199
|
+
let disposed = false
|
|
46
200
|
void loadMonaco().then((monaco) => {
|
|
47
201
|
if (disposed || root.current === null) return
|
|
48
|
-
const
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
instance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () =>
|
|
55
|
-
|
|
56
|
-
|
|
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 () => {
|
|
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' ?
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|