dsh-vscode-workbench 0.1.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.
@@ -0,0 +1,16 @@
1
+ import { useLayoutEffect, useRef } from 'react'
2
+
3
+ /** Reuse the official DSH whale already rendered by the host brand plugin. */
4
+ export function DshMark({ size = 18 }: { size?: number }) {
5
+ const mount = useRef<HTMLSpanElement>(null)
6
+ useLayoutEffect(() => {
7
+ const source = [...document.querySelectorAll<SVGSVGElement>('svg')]
8
+ .find(svg => svg.getAttribute('viewBox') === '0 0 23.16 17.04' && !mount.current?.contains(svg))
9
+ if (source === undefined || mount.current === null) return
10
+ const logo = source.cloneNode(true) as SVGSVGElement
11
+ logo.setAttribute('width', String(size))
12
+ logo.setAttribute('height', String((size * 17.04) / 23.16))
13
+ mount.current.replaceChildren(logo)
14
+ }, [size])
15
+ return <span className="dvw-dsh-mark" ref={mount} aria-hidden="true">DSH</span>
16
+ }
@@ -0,0 +1,23 @@
1
+ export type CodiconName =
2
+ | 'add' | 'check' | 'check-all' | 'chevron-down' | 'chevron-right' | 'close' | 'close-all' | 'code' | 'color-mode'
3
+ | 'diff' | 'diff-sidebyside' | 'diff-single' | 'discard' | 'file' | 'file-code' | 'file-media' | 'file-pdf' | 'file-zip'
4
+ | 'files' | 'folder' | 'folder-opened' | 'git-branch' | 'git-commit' | 'graph' | 'home' | 'list-tree' | 'new-file'
5
+ | 'layout-sidebar-right' | 'layout-sidebar-right-off' | 'new-folder' | 'preview' | 'refresh' | 'remove' | 'search' | 'settings' | 'source-control' | 'sparkle' | 'trash'
6
+
7
+ /** Render one glyph from Microsoft's VS Code Codicon font. */
8
+ export function Icon({ name, className = '' }: { name: CodiconName; className?: string }) {
9
+ return <span aria-hidden="true" className={`codicon codicon-${name} ${className}`.trim()} />
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
+ }
@@ -0,0 +1,18 @@
1
+ import { useEffect, useMemo, useRef } from 'react'
2
+ import DOMPurify from 'dompurify'
3
+ import { marked } from 'marked'
4
+
5
+ export interface MarkdownHeading { depth:number; text:string; index:number }
6
+
7
+ /** Extract navigable ATX headings from Markdown source. */
8
+ export function markdownHeadings(source:string):MarkdownHeading[]{const headings:MarkdownHeading[]=[];for(const line of source.split('\n')){const match=/^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);if(match)headings.push({depth:match[1]!.length,text:match[2]!.replace(/\[([^\]]+)\]\([^)]*\)|[*_`]/g,'$1'),index:headings.length})}return headings}
9
+
10
+ export function markdownWorkspacePath(basePath:string,source:string):string|undefined{if(source===''||/^(?:[a-z]+:|\/|#)/i.test(source))return;const clean=source.split(/[?#]/,1)[0]??source;const parts=basePath.split('/').slice(0,-1);for(const part of clean.split('/')){if(part===''||part==='.')continue;if(part==='..')parts.pop();else parts.push(part)}const encoded=parts.join('/');try{return decodeURIComponent(encoded)}catch{return encoded}}
11
+
12
+ /** Render sanitized workspace Markdown with protected relative images and heading anchors. */
13
+ export function MarkdownPreview({ source, sessionId, path }: { source: string; sessionId:string; path:string }) {
14
+ const root=useRef<HTMLElement>(null)
15
+ const html=useMemo(()=>{const clean=DOMPurify.sanitize(marked.parse(source,{gfm:true,breaks:false}) as string,{USE_PROFILES:{html:true}});const template=document.createElement('template');template.innerHTML=clean;template.content.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((heading,index)=>{heading.id=`dvw-heading-${index}`;heading.setAttribute('data-dvw-heading',String(index))});template.content.querySelectorAll('img').forEach(image=>{const target=markdownWorkspacePath(path,image.getAttribute('src')??'');if(target!==undefined)image.src=`/dsh-vscode/file?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(target)}`});return template.innerHTML},[source,sessionId,path])
16
+ useEffect(()=>{const listener=(event:Event)=>{const index=(event as CustomEvent<number>).detail;root.current?.querySelector<HTMLElement>(`[data-dvw-heading="${index}"]`)?.scrollIntoView({behavior:'smooth',block:'start'})};window.addEventListener('dvw-markdown-heading',listener);return()=>window.removeEventListener('dvw-markdown-heading',listener)},[])
17
+ return <article ref={root} className="dvw-markdown" onClick={event=>{const anchor=(event.target as Element).closest('a');if(anchor===null)return;const target=markdownWorkspacePath(path,anchor.getAttribute('href')??'');if(target===undefined)return;event.preventDefault();window.dispatchEvent(new CustomEvent('dvw-open-workspace-path',{detail:target}))}} dangerouslySetInnerHTML={{__html:html}}/>
18
+ }
@@ -0,0 +1,56 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ type Monaco = typeof import('monaco-editor')
4
+ declare global { interface Window { require?: { (ids: string[], done: () => void): void; config(value: unknown): void }; monaco?: Monaco } }
5
+ let loading: Promise<Monaco> | undefined
6
+ export function loadMonaco(): Promise<Monaco> {
7
+ loading ??= new Promise((resolve, reject) => {
8
+ const start = () => {
9
+ const loader = window.require
10
+ if (loader === undefined) return reject(new Error('Monaco AMD loader unavailable'))
11
+ loader.config({ paths: { vs: '/dsh-vscode/monaco/vs' } })
12
+ loader(['vs/editor/editor.main'], () => window.monaco === undefined ? reject(new Error('Monaco failed to initialize')) : resolve(window.monaco))
13
+ }
14
+ if (window.require !== undefined) { start(); return }
15
+ 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)
16
+ })
17
+ return loading
18
+ }
19
+
20
+ export function MonacoDiffEditor(props: { uri: string; original: string; modified: string; mode: 'inline' | 'split'; theme: 'dark' | 'light' }) {
21
+ 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>()
22
+ 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,minimap:{enabled:false},originalEditable:false,renderOverviewRuler:true,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])
23
+ 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])
24
+ useEffect(()=>{editor.current?.updateOptions({renderSideBySide:props.mode==='split'})},[props.mode])
25
+ useEffect(()=>{if(window.monaco!==undefined)window.monaco.editor.setTheme(props.theme==='dark'?'vs-dark':'vs')},[props.theme])
26
+ 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>
27
+ }
28
+
29
+ export function MonacoEditor(props: { uri: string; value: string; theme: 'dark' | 'light'; line?: number; column?: number; onChange(value: string): void; onSave(): void }) {
30
+ const root = useRef<HTMLDivElement>(null)
31
+ const editor = useRef<import('monaco-editor').editor.IStandaloneCodeEditor>()
32
+ const callbacks = useRef({ onChange: props.onChange, onSave: props.onSave })
33
+ callbacks.current = { onChange: props.onChange, onSave: props.onSave }
34
+ const [error, setError] = useState<string>()
35
+ useEffect(() => {
36
+ let disposed = false; let model: import('monaco-editor').editor.ITextModel | undefined
37
+ void loadMonaco().then((monaco) => {
38
+ if (disposed || root.current === null) return
39
+ const uri = monaco.Uri.parse(`file:///${props.uri.replace(/^\/+/, '')}`)
40
+ model = monaco.editor.getModel(uri) ?? monaco.editor.createModel(props.value, undefined, uri)
41
+ if (model.getValue() !== props.value) model.setValue(props.value)
42
+ const instance = monaco.editor.create(root.current, { model, automaticLayout: true, theme: props.theme === 'dark' ? 'vs-dark' : 'vs', fontSize: 14, minimap: { enabled: true }, wordWrap: 'off' })
43
+ editor.current = instance
44
+ const change = instance.onDidChangeModelContent(() => callbacks.current.onChange(instance.getValue()))
45
+ instance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => callbacks.current.onSave())
46
+ if (props.line !== undefined) { instance.setPosition({ lineNumber: props.line, column: props.column ?? 1 }); instance.revealLineInCenter(props.line); instance.focus() }
47
+ return () => { change.dispose() }
48
+ }).catch(error => setError(error instanceof Error ? error.message : String(error)))
49
+ return () => { disposed = true; editor.current?.dispose(); editor.current = undefined }
50
+ }, [props.uri])
51
+ useEffect(() => {
52
+ const model = editor.current?.getModel(); if (model !== null && model !== undefined && model.getValue() !== props.value) model.setValue(props.value)
53
+ }, [props.value])
54
+ useEffect(() => { if (window.monaco !== undefined) window.monaco.editor.setTheme(props.theme === 'dark' ? 'vs-dark' : 'vs') }, [props.theme])
55
+ return error === undefined ? <div ref={root} style={{ position: 'absolute', inset: 0 }} /> : <div className="dvw-error">{error}</div>
56
+ }
@@ -0,0 +1,185 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { CSSProperties, ReactNode } from 'react'
3
+ import type { FileDocument, FileEntry, GitCommit, GitCommitFile, GitDiff, GitStatus, SearchMatch } from '../types.ts'
4
+ import { api } from './api.ts'
5
+ import type { VscodeWorkbench, WorkbenchView } from './service.ts'
6
+ import { fileIcon, Icon } from './Icon.tsx'
7
+ import { MarkdownPreview, markdownHeadings } from './MarkdownPreview.tsx'
8
+ import type { MarkdownHeading } from './MarkdownPreview.tsx'
9
+ import { MonacoDiffEditor, MonacoEditor } from './MonacoEditor.tsx'
10
+ import { DshConversationPanel, DshConversationTheme } from './DshConversationPanel.tsx'
11
+ import { DshMark } from './DshMark.tsx'
12
+
13
+ interface Tab extends FileDocument { dirty: boolean; draft: string; line?: number; column?: number; external?: boolean; preview?: boolean; title?: string; diff?: GitDiff; diffMode?: 'inline' | 'split'; diffSourcePath?:string; imageUrl?:string }
14
+ type WorkbenchTheme = 'dsh' | 'dark' | 'light'
15
+ interface TreeProps { sessionId: string; path: string; depth: number; expanded: Set<string>; selected?: string; onToggle(path: string): void; onOpen(path: string): void; onChange(): void }
16
+
17
+ /** Return the basename shown in a tab while preserving its full internal path. */
18
+ export function tabLabel(tab:Pick<Tab,'path'|'title'|'diffSourcePath'>):string{const path=tab.title??tab.diffSourcePath??tab.path.replace(/^diff:/,'');return path.split(/[\\/]/).at(-1)??path}
19
+
20
+ function useDshDarkTheme(): boolean {
21
+ const read = () => document.body.hasAttribute('data-ds-dark-theme')
22
+ const [dark, setDark] = useState(read)
23
+ useEffect(() => { const observer = new MutationObserver(() => setDark(read())); observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] }); return () => observer.disconnect() }, [])
24
+ return dark
25
+ }
26
+
27
+ function Tree({ sessionId, path, depth, expanded, selected, onToggle, onOpen, onChange }: TreeProps) {
28
+ const [rows, setRows] = useState<FileEntry[]>([]); const [error, setError] = useState<string>()
29
+ const load = useCallback(() => { void api<FileEntry[]>('fs.list', { sessionId, path }).then(setRows).catch(error => setError(String(error))) }, [sessionId, path])
30
+ useEffect(load, [load])
31
+ useEffect(() => {
32
+ const handler = () => load(); window.addEventListener('dvw-files-changed', handler); return () => window.removeEventListener('dvw-files-changed', handler)
33
+ }, [load])
34
+ const menu = async (entry: FileEntry) => {
35
+ const action = prompt(`操作 ${entry.name}: rename / delete / new-file / new-folder`)
36
+ try {
37
+ if (action === 'rename') { const name = prompt('新名称', entry.name); if (name) await api('fs.rename', { sessionId, path: entry.path, nextPath: [...entry.path.split('/').slice(0, -1), name].filter(Boolean).join('/') }) }
38
+ if (action === 'delete' && confirm(`确定删除 ${entry.path}?`)) await api('fs.delete', { sessionId, path: entry.path })
39
+ if (action === 'new-file' || action === 'new-folder') { const name = prompt('名称'); if (name) await api('fs.create', { sessionId, path: `${entry.kind === 'directory' ? entry.path : entry.path.split('/').slice(0, -1).join('/')}/${name}`.replace(/^\//, ''), kind: action === 'new-folder' ? 'directory' : 'file' }) }
40
+ load(); onChange()
41
+ } catch (error) { alert(error instanceof Error ? error.message : String(error)) }
42
+ }
43
+ return <>{error && <div className="dvw-error">{error}</div>}{rows.map(row => <div key={row.path}>
44
+ <div className="dvw-tree-row" data-selected={selected === row.path} style={{ paddingLeft: 6 + depth * 13 }} onClick={() => row.kind === 'directory' ? onToggle(row.path) : onOpen(row.path)} onContextMenu={(event) => { event.preventDefault(); void menu(row) }}>
45
+ <span className="dvw-chevron">{row.kind === 'directory' && <Icon name={expanded.has(row.path) ? 'chevron-down' : 'chevron-right'}/>}</span><Icon name={row.kind === 'directory' ? expanded.has(row.path) ? 'folder-opened' : 'folder' : fileIcon(row.name)} className={`dvw-file-icon ${row.kind === 'directory' ? 'folder' : ''}`}/><span>{row.name}</span>
46
+ </div>
47
+ {row.kind === 'directory' && expanded.has(row.path) && <Tree {...{ sessionId, path: row.path, depth: depth + 1, expanded, selected, onToggle, onOpen, onChange }} />}
48
+ </div>)}</>
49
+ }
50
+
51
+ function Explorer(props: { sessionId: string; cwd?: string; selected?: string; onOpen(path: string): void }) {
52
+ const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [revision, setRevision] = useState(0)
53
+ useEffect(() => { if (props.cwd) { try { setExpanded(new Set(JSON.parse(localStorage.getItem(`dvw:tree:${props.cwd}`) ?? '[]') as string[])) } catch { setExpanded(new Set()) } } }, [props.cwd])
54
+ useEffect(() => { if (props.cwd) localStorage.setItem(`dvw:tree:${props.cwd}`, JSON.stringify([...expanded])) }, [props.cwd, expanded])
55
+ const create = async (kind: 'file' | 'directory') => { const path = prompt(kind === 'file' ? '新文件路径' : '新目录路径'); if (!path) return; try { await api('fs.create', { sessionId: props.sessionId, path, kind }); setRevision(x => x + 1); window.dispatchEvent(new Event('dvw-files-changed')) } catch (error) { alert(String(error)) } }
56
+ return <><div className="dvw-side-head">资源管理器<div className="dvw-toolbar"><button className="dvw-icon" title="新建文件" onClick={() => void create('file')}><Icon name="new-file"/></button><button className="dvw-icon" title="新建目录" onClick={() => void create('directory')}><Icon name="new-folder"/></button><button className="dvw-icon" title="刷新" onClick={() => { setRevision(x => x + 1); window.dispatchEvent(new Event('dvw-files-changed')) }}><Icon name="refresh"/></button></div></div><div className="dvw-tree" key={revision}><Tree sessionId={props.sessionId} path="" depth={0} expanded={expanded} selected={props.selected} onToggle={path => setExpanded(current => { const next = new Set(current); next.has(path) ? next.delete(path) : next.add(path); return next })} onOpen={props.onOpen} onChange={() => setRevision(x => x + 1)} /></div></>
57
+ }
58
+
59
+ function MarkdownOutline({headings,onClose,onHeading}:{headings:MarkdownHeading[];onClose():void;onHeading(index:number):void}) {
60
+ return <aside className="dvw-outline dvw-editor-outline"><div className="dvw-outline-head"><span><Icon name="list-tree"/> 大纲</span><button className="dvw-icon" title="关闭大纲" onClick={onClose}><Icon name="close"/></button></div><div className="dvw-outline-list">{headings.map(heading=><button className="dvw-outline-item" key={heading.index} style={{paddingLeft:8+(heading.depth-1)*13}} onClick={()=>onHeading(heading.index)}>{heading.text}</button>)}</div></aside>
61
+ }
62
+
63
+ function Search(props: { sessionId: string; onOpen(path: string, line: number, column: number): void }) {
64
+ const [query, setQuery] = useState(''); const [include, setInclude] = useState(''); const [exclude, setExclude] = useState(''); const [flags, setFlags] = useState({ caseSensitive: false, wholeWord: false, regex: false }); const [matches, setMatches] = useState<SearchMatch[]>([]); const [limited, setLimited] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState<string>()
65
+ const pending = useRef<AbortController>()
66
+ useEffect(() => () => pending.current?.abort(), [])
67
+ const search = async () => { pending.current?.abort(); const controller = new AbortController(); pending.current = controller; setBusy(true); setError(undefined); try { const result = await api<{ matches: SearchMatch[]; limited: boolean }>('search', { sessionId: props.sessionId, query, include, exclude, ...flags }, controller.signal); setMatches(result.matches); setLimited(result.limited) } catch (error) { if (!controller.signal.aborted) setError(String(error)) } finally { if (pending.current === controller) setBusy(false) } }
68
+ const groups = useMemo(() => {
69
+ const grouped = new Map<string, SearchMatch[]>()
70
+ for (const match of matches) grouped.set(match.path, [...(grouped.get(match.path) ?? []), match])
71
+ return grouped
72
+ }, [matches])
73
+ return <><div className="dvw-side-head">搜索</div><form className="dvw-search-form" onSubmit={event => { event.preventDefault(); void search() }}><input className="dvw-input" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索" autoFocus/><div className="dvw-options">{([['caseSensitive','Aa'],['wholeWord','Ab'],['regex','.*']] as const).map(([key,label]) => <label key={key}><input type="checkbox" checked={flags[key]} onChange={e => setFlags({ ...flags, [key]: e.target.checked })}/>{label}</label>)}<button className="dvw-icon" title="搜索" disabled={busy}><Icon name="search"/></button></div><input className="dvw-input" value={include} onChange={e=>setInclude(e.target.value)} placeholder="包含文件,例如 src/**"/><input className="dvw-input" value={exclude} onChange={e=>setExclude(e.target.value)} placeholder="排除文件,例如 **/*.test.ts"/></form>{error&&<div className="dvw-error">{error}</div>}{limited&&<div className="dvw-message">结果已截断到 2000 条</div>}<div className="dvw-results">{[...groups].map(([path, rows]) => <div key={path}><div className="dvw-result-file"><Icon name="chevron-down"/> {path} <span className="dvw-badge">{rows.length}</span></div>{rows.map((row,index)=><div className="dvw-result" key={`${row.line}:${row.column}:${index}`} onClick={()=>props.onOpen(path,row.line,row.column)}><span className="dvw-line">{row.line}</span><span className="dvw-preview">{row.preview}</span></div>)}</div>)}</div></>
74
+ }
75
+
76
+ interface GraphRow { commit: GitCommit; lane: number; before: string[]; after: string[]; parentLanes: number[] }
77
+ export function graphRows(commits: GitCommit[]): GraphRow[] {
78
+ const visible=new Set(commits.map(commit=>commit.hash))
79
+ let lanes: string[] = commits[0] ? [commits[0].hash] : []
80
+ return commits.map(commit => {
81
+ let lane = lanes.indexOf(commit.hash)
82
+ if (lane < 0) { lanes=[commit.hash,...lanes]; lane = 0 }
83
+ const before=[...lanes];const after=before.filter(hash=>hash!==commit.hash)
84
+ const parents=commit.parents.filter(parent=>visible.has(parent));parents.forEach((parent,index)=>{if(!after.includes(parent))after.splice(Math.min(lane+index,after.length),0,parent)})
85
+ const parentLanes=parents.map(parent=>after.indexOf(parent)).filter(index=>index>=0)
86
+ lanes=after
87
+ return {commit,lane,before,after,parentLanes}
88
+ })
89
+ }
90
+
91
+ const GRAPH_COLORS = ['#4c8dff','#d94f9f','#4fc1b0','#d9a441','#9b7bea']
92
+ function graphColor(lane:number):string{return GRAPH_COLORS[lane%GRAPH_COLORS.length]!}
93
+ function graphWidth(row:GraphRow):number{return Math.max(row.before.length,row.after.length,row.lane+1,1)*14+8}
94
+
95
+ function GraphTracks({row}:{row:GraphRow}) {
96
+ const width=graphWidth(row);const x=row.lane*14+8
97
+ return <svg className="dvw-graph-svg" width={width} height="38" viewBox={`0 0 ${width} 38`} aria-hidden="true">
98
+ {row.before.map((hash,lane)=>{if(hash===row.commit.hash)return null;const target=row.after.indexOf(hash);const nextLane=target>=0?target:lane;const next=nextLane*14+8;const start=lane*14+8;return <path key={`continue-${hash}`} d={`M ${start} 0 C ${start} 14 ${next} 24 ${next} 38`} fill="none" stroke={graphColor(nextLane)} strokeWidth="2"/>})}
99
+ <path d={`M ${x} 0 V 19`} fill="none" stroke={graphColor(row.lane)} strokeWidth="2"/>
100
+ {row.parentLanes.map((parent,index)=>{const target=parent*14+8;return <path key={`parent-${parent}-${index}`} d={`M ${x} 19 C ${x} 27 ${target} 30 ${target} 38`} fill="none" stroke={graphColor(parent)} strokeWidth="2"/>})}
101
+ <circle cx={x} cy="19" r="5" fill="var(--dvw-surface-1)" stroke={graphColor(row.lane)} strokeWidth="2"/>
102
+ </svg>
103
+ }
104
+
105
+ function GraphContinuation({row}:{row:GraphRow}) {
106
+ const width=graphWidth(row)
107
+ return <svg className="dvw-graph-continuation" width={width} height="100%" viewBox={`0 0 ${width} 100`} preserveAspectRatio="none" aria-hidden="true">{row.after.map((hash,lane)=><line key={hash} x1={lane*14+8} y1="0" x2={lane*14+8} y2="100" stroke={graphColor(lane)} strokeWidth="2" vectorEffect="non-scaling-stroke"/>)}</svg>
108
+ }
109
+
110
+ function CommitGraph({ commits, expanded, files, onToggle, onOpenFile }: { commits: GitCommit[]; expanded?: string; files: Map<string,GitCommitFile[]>; onToggle(commit:GitCommit):void; onOpenFile(commit:GitCommit,file:GitCommitFile):void }) {
111
+ return <div className="dvw-graph-list">{graphRows(commits).map(row => {
112
+ const opened=expanded===row.commit.hash
113
+ return <div className="dvw-graph-entry" data-expanded={opened} key={row.commit.hash}>
114
+ <button className="dvw-graph-row" aria-expanded={opened} onClick={()=>onToggle(row.commit)} title={`${row.commit.hash}\n${row.commit.author}`}><GraphTracks row={row}/><span className="dvw-graph-copy"><span className="dvw-graph-subject">{row.commit.subject}</span><span className="dvw-graph-meta">{row.commit.author} · {new Date(row.commit.date).toLocaleDateString()}</span></span>{row.commit.refs.slice(0,2).map(ref=><span className="dvw-ref" key={ref}>{ref}</span>)}<Icon name={opened?'chevron-down':'chevron-right'}/></button>
115
+ {opened&&<div className="dvw-commit-files" style={{paddingLeft:graphWidth(row)+7}}><GraphContinuation row={row}/><div className="dvw-commit-file-list">{(files.get(row.commit.hash)??[]).map(file=><button className="dvw-commit-file" key={`${file.status}:${file.path}`} onClick={()=>onOpenFile(row.commit,file)}><Icon name={fileIcon(file.path)} className="dvw-file-icon"/><span className="dvw-commit-file-name">{file.path.split('/').at(-1)}</span><span className="dvw-commit-file-dir">{file.path.split('/').slice(0,-1).join('/')}</span><span className="dvw-badge">{file.status}</span></button>)}</div></div>}
116
+ </div>
117
+ })}</div>
118
+ }
119
+
120
+ function GitGroupHeader({ title, count, expanded, onToggle, children }: { title: string; count: number; expanded: boolean; onToggle(): void; children?: ReactNode }) {
121
+ return <div className="dvw-group-head"><button className="dvw-group-toggle" aria-expanded={expanded} onClick={onToggle}><Icon name={expanded?'chevron-down':'chevron-right'}/><span>{title}</span><span className="dvw-count">{count}</span></button>{children}</div>
122
+ }
123
+
124
+ function Git(props: { sessionId: string; onDiff(key:string, diff:GitDiff, title?:string, sourcePath?:string): void }) {
125
+ const [status, setStatus] = useState<GitStatus>(); const [commits,setCommits]=useState<GitCommit[]>([]); const [message, setMessage] = useState(''); const [error, setError] = useState<string>(); const [busy,setBusy]=useState(false); const [expanded,setExpanded]=useState({staged:true,changes:true,graph:true});const [expandedCommit,setExpandedCommit]=useState<string>();const [commitFileMap,setCommitFileMap]=useState<Map<string,GitCommitFile[]>>(new Map())
126
+ const refresh = useCallback(() => { void Promise.all([api<GitStatus>('git.status',{sessionId:props.sessionId}),api<GitCommit[]>('git.action',{sessionId:props.sessionId,action:'log'})]).then(([nextStatus,nextCommits])=>{setStatus(nextStatus);setCommits(nextCommits);setError(undefined)}).catch(error=>setError(String(error))) }, [props.sessionId])
127
+ useEffect(refresh,[refresh]); useEffect(()=>{const fn=()=>refresh();window.addEventListener('dvw-files-changed',fn);return()=>window.removeEventListener('dvw-files-changed',fn)},[refresh])
128
+ const action = async (name: string, path?: string, value?: string) => { setBusy(true);try { const result = await api<GitStatus | GitDiff>('git.action',{sessionId:props.sessionId,action:name,path,value}); if(name==='diff'&&'diff'in result){const key=path??'';props.onDiff(key,result,key.split('/').at(-1)??key,key)} else {setStatus(result as GitStatus);if(name==='commit')refresh()} } catch(error){setError(String(error))}finally{setBusy(false)} }
129
+ const branch = async () => { const branches=await api<string[]>('git.action',{sessionId:props.sessionId,action:'branches'}); const choice=prompt(`切换分支,或输入 +新分支\n${branches.join('\n')}`); if(!choice)return; await action(choice.startsWith('+')?'branch.create':'branch.switch',undefined,choice.replace(/^\+/,'')) }
130
+ const toggleCommit=async(commit:GitCommit)=>{if(expandedCommit===commit.hash){setExpandedCommit(undefined);return}setExpandedCommit(commit.hash);if(commitFileMap.has(commit.hash))return;setBusy(true);try{const files=await api<GitCommitFile[]>('git.action',{sessionId:props.sessionId,action:'commit.files',value:commit.hash});setCommitFileMap(current=>new Map(current).set(commit.hash,files))}catch(error){setError(String(error))}finally{setBusy(false)}}
131
+ const openCommitFile=async(commit:GitCommit,file:GitCommitFile)=>{setBusy(true);try{const result=await api<GitDiff>('git.action',{sessionId:props.sessionId,action:'commit.file.diff',path:file.path,value:commit.hash});props.onDiff(`commit:${commit.hash}:${file.path}`,result,file.path.split('/').at(-1)??file.path,file.path)}catch(error){setError(String(error))}finally{setBusy(false)}}
132
+ const staged=status?.changes.filter(change=>change.index!=='.'&&change.index!=='?')??[]
133
+ const changed=status?.changes.filter(change=>change.worktree!=='.'||change.untracked)??[]
134
+ const renderChange=(change:GitStatus['changes'][number],group:'staged'|'worktree')=><div className="dvw-change" key={`${group}:${change.path}`} onClick={()=>void action('diff',change.path,change.untracked?'untracked':group)}><Icon name={change.untracked?'new-file':'diff'} className="dvw-file-icon"/><span className="dvw-change-path">{change.path}</span><span className="dvw-badge">{change.untracked?'U':group==='staged'?change.index:change.worktree}</span><button className="dvw-icon" title={group==='staged'?'取消暂存':'暂存更改'} onClick={event=>{event.stopPropagation();void action(group==='staged'?'unstage':'stage',change.path)}}><Icon name={group==='staged'?'remove':'add'}/></button>{group==='worktree'&&<button className="dvw-icon" title="放弃更改" onClick={event=>{event.stopPropagation();if(confirm(`放弃 ${change.path} 的更改?此操作无法撤销。`))void action('discard',change.path,change.untracked?'untracked':undefined)}}><Icon name="discard"/></button>}</div>
135
+ return <><div className="dvw-side-head">源代码管理<div className="dvw-toolbar"><button className="dvw-icon" title="刷新" disabled={busy} onClick={refresh}><Icon name="refresh"/></button></div></div>{error&&<div className="dvw-error">{error}</div>}{status&&<><div className="dvw-git-head"><button className="dvw-branch" onClick={()=>void branch()}><Icon name="git-branch"/> {status.branch}</button><span>↑{status.ahead} ↓{status.behind}</span></div><div className="dvw-commit"><input className="dvw-input" value={message} onChange={event=>setMessage(event.target.value)} onKeyDown={event=>{if(event.ctrlKey&&event.key==='Enter'&&message.trim())void action('commit',undefined,message).then(()=>setMessage(''))}} placeholder={`消息 (Ctrl+Enter 在“${status.branch}”提交)`}/><button className="dvw-commit-button" disabled={busy||!message.trim()} onClick={()=>void action('commit',undefined,message).then(()=>setMessage(''))}><Icon name="check"/> 提交</button></div><div className="dvw-git">{staged.length>0&&<section><GitGroupHeader title="暂存的更改" count={staged.length} expanded={expanded.staged} onToggle={()=>setExpanded(value=>({...value,staged:!value.staged}))}><button className="dvw-icon" title="取消暂存所有更改" onClick={()=>void action('unstage.all')}><Icon name="remove"/></button></GitGroupHeader>{expanded.staged&&staged.map(change=>renderChange(change,'staged'))}</section>}<section><GitGroupHeader title="更改" count={changed.length} expanded={expanded.changes} onToggle={()=>setExpanded(value=>({...value,changes:!value.changes}))}><button className="dvw-icon" title="放弃所有更改" onClick={()=>{if(confirm('放弃所有已跟踪和未跟踪的更改?此操作无法撤销。'))void action('discard.all')}}><Icon name="discard"/></button><button className="dvw-icon" title="暂存所有更改" onClick={()=>void action('stage.all')}><Icon name="add"/></button></GitGroupHeader>{expanded.changes&&changed.map(change=>renderChange(change,'worktree'))}</section><section className="dvw-graph"><GitGroupHeader title="图表" count={commits.length} expanded={expanded.graph} onToggle={()=>setExpanded(value=>({...value,graph:!value.graph}))}><button className="dvw-icon" title="刷新提交图表" onClick={refresh}><Icon name="refresh"/></button></GitGroupHeader>{expanded.graph&&<CommitGraph commits={commits} expanded={expandedCommit} files={commitFileMap} onToggle={commit=>void toggleCommit(commit)} onOpenFile={(commit,file)=>void openCommitFile(commit,file)}/>}</section></div></>}</>
136
+ }
137
+
138
+ export function Launcher({ wide, service }: { wide: boolean; service: VscodeWorkbench }) { return <button className="dvw-launch" title="打开 VS Code 工作台" onClick={()=>service.show()}><Icon name="files" className="dvw-launch-icon"/>{wide&&<span>VS Code 工作台</span>}</button> }
139
+
140
+ export function Workbench({ service, useSessions }: { service: VscodeWorkbench; useSessions: (selector: (state: any) => any) => any }) {
141
+ const [snapshot, setSnapshot] = useState(() => service.getSnapshot()); const [panels, setPanels] = useState(() => service.getPanels())
142
+ useEffect(() => service.subscribe(() => { setSnapshot(service.getSnapshot()); setPanels(service.getPanels()) }), [service])
143
+ const sessionId = useSessions(state=>state.current) as string|undefined; const cwd = useSessions(state=>sessionId===undefined?undefined:state.byId[sessionId]?.cwd) as string|undefined
144
+ const [workspace,setWorkspace]=useState<{cwd:string;name:string}>(); const [tabs,setTabs]=useState<Tab[]>([]); const [active,setActive]=useState<string>(); const [error,setError]=useState<string>(); const [panelHeight,setPanelHeight]=useState(260); const [sideWidth,setSideWidth]=useState(210); const [primarySideVisible,setPrimarySideVisible]=useState(()=>localStorage.getItem('dvw:primary-side-visible')!=='closed');const [statusVisible,setStatusVisible]=useState(()=>localStorage.getItem('dvw:status-visible')!=='closed');const [settingsOpen,setSettingsOpen]=useState(false);const [chatWidth,setChatWidth]=useState(()=>Math.max(400,Math.min(720,Math.round(Number(localStorage.getItem('dvw:dsh-chat-width'))||420))));const [chatVisible,setChatVisible]=useState(()=>localStorage.getItem('dvw:dsh-chat-visible')!=='closed');const [outlineVisible,setOutlineVisible]=useState(()=>localStorage.getItem('dvw:outline')!=='closed')
145
+ const dshDark = useDshDarkTheme()
146
+ const [theme,setTheme]=useState<WorkbenchTheme>(()=>{const saved=localStorage.getItem('dvw:theme');return saved==='dark'||saved==='light'?saved:'dsh'})
147
+ const colorScheme: 'dark' | 'light' = theme === 'dsh' ? dshDark ? 'dark' : 'light' : theme
148
+ useEffect(()=>localStorage.setItem('dvw:theme',theme),[theme])
149
+ useEffect(()=>localStorage.setItem('dvw:outline',outlineVisible?'open':'closed'),[outlineVisible])
150
+ useEffect(()=>localStorage.setItem('dvw:dsh-chat-visible',chatVisible?'open':'closed'),[chatVisible])
151
+ useEffect(()=>localStorage.setItem('dvw:dsh-chat-width',String(Math.round(chatWidth))),[chatWidth])
152
+ useEffect(()=>localStorage.setItem('dvw:primary-side-visible',primarySideVisible?'open':'closed'),[primarySideVisible])
153
+ useEffect(()=>localStorage.setItem('dvw:status-visible',statusVisible?'open':'closed'),[statusVisible])
154
+ useEffect(()=>{if(!snapshot.visible)return;const onKey=(event:KeyboardEvent)=>{if(event.isComposing||!event.ctrlKey||!event.altKey||event.shiftKey||event.code!=='KeyB')return;event.preventDefault();setChatVisible(value=>!value)};window.addEventListener('keydown',onKey,true);return()=>window.removeEventListener('keydown',onKey,true)},[snapshot.visible])
155
+ useEffect(()=>{if(!snapshot.visible||sessionId===undefined)return;void api<{cwd:string;name:string}>('workspace',{sessionId}).then(value=>{setWorkspace(value);const saved=localStorage.getItem(`dvw:${value.cwd}`);if(saved){try{const parsed=JSON.parse(saved) as {view?:WorkbenchView;paths?:string[];panelHeight?:number;sideWidth?:number};if(parsed.view)service.show(parsed.view);if(typeof parsed.panelHeight==='number')setPanelHeight(parsed.panelHeight);if(typeof parsed.sideWidth==='number')setSideWidth(Math.max(210,Math.min(640,Math.round(parsed.sideWidth))));for(const path of parsed.paths??[])void open(path)}catch{}}}).catch(error=>setError(String(error)))},[snapshot.visible,sessionId])
156
+ useEffect(()=>{if(workspace) localStorage.setItem(`dvw:${workspace.cwd}`,JSON.stringify({view:snapshot.view,paths:tabs.filter(t=>!t.path.startsWith('diff:')).map(t=>t.path),panelHeight,sideWidth}))},[workspace,snapshot.view,tabs,panelHeight,sideWidth])
157
+ useEffect(()=>{if(!snapshot.visible||sessionId===undefined)return;const protocol=location.protocol==='https:'?'wss':'ws';const ws=new WebSocket(`${protocol}://${location.host}/dsh-vscode/ws/files?sessionId=${encodeURIComponent(sessionId)}`);ws.onmessage=()=>{window.dispatchEvent(new Event('dvw-files-changed'));setTabs(current=>current.map(tab=>tab.dirty?{...tab,external:true}:tab))};return()=>ws.close()},[snapshot.visible,sessionId])
158
+ const open=async(path:string,line?:number,column?:number)=>{if(sessionId===undefined)return;const existing=tabs.find(t=>t.path===path);if(existing){setActive(path);setTabs(rows=>rows.map(t=>t.path===path?{...t,line,column}:t));return}if(/\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i.test(path)){const imageUrl=`/dsh-vscode/file?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`;setTabs(rows=>[...rows,{path,content:'',draft:'',revision:'',size:0,binary:false,tooLarge:false,dirty:false,imageUrl}]);setActive(path);return}try{const doc=await api<FileDocument>('fs.read',{sessionId,path});if(doc.binary||doc.tooLarge){setError(doc.binary?'二进制文件不能在文本编辑器中打开':`文件超过 5 MiB:${path}`);return}setTabs(rows=>[...rows,{...doc,draft:doc.content,dirty:false,line,column,preview:/\.md$/i.test(path)}]);setActive(path)}catch(error){setError(String(error))}}
159
+ useEffect(()=>{const listener=(event:Event)=>void open((event as CustomEvent<string>).detail);window.addEventListener('dvw-open-workspace-path',listener);return()=>window.removeEventListener('dvw-open-workspace-path',listener)},[sessionId,tabs])
160
+ const save=async(tab:Tab)=>{if(sessionId===undefined||!tab.dirty)return;try{const doc=await api<FileDocument>('fs.save',{sessionId,path:tab.path,content:tab.draft,revision:tab.revision});setTabs(rows=>rows.map(row=>row.path===tab.path?{...row,...doc,draft:doc.content,dirty:false,external:false}:row))}catch(error){setError(String(error))}}
161
+ const close=(tab:Tab)=>{if(tab.dirty&&!confirm(`${tab.path} 有未保存更改,确定关闭?`))return;setTabs(rows=>rows.filter(row=>row.path!==tab.path));if(active===tab.path)setActive(tabs.filter(row=>row.path!==tab.path).at(-1)?.path)}
162
+ const closeAll=()=>{const dirty=tabs.filter(tab=>tab.dirty);if(dirty.length>0&&!confirm(`有 ${dirty.length} 个文件包含未保存更改,确定全部关闭?`))return;setTabs([]);setActive(undefined)}
163
+ const activeTab=tabs.find(tab=>tab.path===active); const Panel=panels.find(panel=>panel.id===snapshot.bottomPanel)?.component
164
+ const selectView=(view:WorkbenchView)=>{if(snapshot.view===view&&primarySideVisible){setPrimarySideVisible(false);return}setPrimarySideVisible(true);service.show(view)}
165
+ const selectHeading=(index:number)=>{if(activeTab===undefined)return;setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:true}:tab));window.setTimeout(()=>window.dispatchEvent(new CustomEvent('dvw-markdown-heading',{detail:index})),50)}
166
+ if(!snapshot.visible)return null
167
+ if(sessionId===undefined)return <div className="dvw-overlay" data-dvw-theme={theme==='dsh'?undefined:theme}><div className="dvw-empty" style={{gridColumn:'1/4'}}><div>请先选择或创建一个 DSH 会话。<br/><button onClick={()=>service.hide()}>返回 DSH</button></div></div></div>
168
+ const markdown=activeTab!==undefined&&!activeTab.path.startsWith('diff:')&&/\.md$/i.test(activeTab.path)
169
+ const diffMarkdown=activeTab?.diff!==undefined&&/\.md$/i.test(activeTab.diffSourcePath??'')
170
+ const markdownPreview=markdown||(diffMarkdown&&activeTab?.preview===true)
171
+ const headings=markdownPreview&&activeTab?markdownHeadings(activeTab.diff?.modified??activeTab.draft):[]
172
+ return <div className="dvw-overlay" data-dvw-theme={theme==='dsh'?undefined:theme} data-dvw-chat={chatVisible||undefined} data-dvw-primary-side={primarySideVisible||undefined} data-dvw-status={statusVisible||undefined} style={{'--dvw-side-width':`${sideWidth}px`,'--dvw-chat-width':`${chatWidth}px`} as CSSProperties} role="dialog" aria-label="VS Code 工作台">
173
+ <DshConversationPanel visible={chatVisible&&!settingsOpen} width={chatWidth} statusHeight={statusVisible?24:0}/>
174
+ <DshConversationTheme visible={chatVisible} colorScheme={colorScheme} followDsh={theme==='dsh'}/>
175
+ {!chatVisible&&<button className="dvw-chat-toggle dvw-icon" title="显示 DSH 对话栏 (Ctrl+Alt+B)" onClick={()=>setChatVisible(true)}><DshMark/></button>}
176
+ {chatVisible&&<aside className="dvw-chat"><div className="dvw-chat-resizer" title="拖动调整 DSH 对话栏宽度,双击恢复默认" onDoubleClick={()=>setChatWidth(420)} onPointerDown={event=>{event.preventDefault();const startX=event.clientX,startWidth=chatWidth;const move=(next:PointerEvent)=>setChatWidth(Math.max(400,Math.min(720,Math.round(startWidth+startX-next.clientX))));const up=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up)};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up)}}/><header className="dvw-chat-head"><span><DshMark/> DSH</span><button className="dvw-icon" title="关闭 DSH 对话栏" onClick={()=>setChatVisible(false)}><Icon name="close"/></button></header><div className="dvw-chat-body"/></aside>}
177
+ {settingsOpen&&<div className="dvw-settings-backdrop" role="presentation" onMouseDown={()=>setSettingsOpen(false)}><section className="dvw-settings" role="dialog" aria-modal="true" aria-label="工作台设置" onMouseDown={event=>event.stopPropagation()}><header><span><Icon name="settings"/> 工作台设置</span><button className="dvw-icon" title="关闭设置" onClick={()=>setSettingsOpen(false)}><Icon name="close"/></button></header><div className="dvw-settings-body"><section><h3>外观</h3><label>颜色主题</label><div className="dvw-settings-themes">{([['dsh','跟随 DSH'],['dark','深色'],['light','浅色']] as const).map(([value,label])=><button key={value} data-active={theme===value} onClick={()=>setTheme(value)}><span className={`dvw-theme-swatch ${value}`}/><span>{label}</span>{theme===value&&<Icon name="check"/>}</button>)}</div></section><section><h3>布局</h3><label className="dvw-setting-check"><input type="checkbox" checked={primarySideVisible} onChange={event=>setPrimarySideVisible(event.target.checked)}/><span>显示左侧视图</span></label><label className="dvw-setting-check"><input type="checkbox" checked={chatVisible} onChange={event=>setChatVisible(event.target.checked)}/><span>显示 DSH 对话栏</span></label><label className="dvw-setting-check"><input type="checkbox" checked={statusVisible} onChange={event=>setStatusVisible(event.target.checked)}/><span>显示底部状态栏</span></label><label className="dvw-setting-range"><span>左侧视图宽度</span><input type="range" min="210" max="640" step="1" value={Math.round(sideWidth)} onChange={event=>setSideWidth(Math.round(Number(event.target.value)))}/><output>{sideWidth}px</output></label><label className="dvw-setting-range"><span>DSH 对话栏宽度</span><input type="range" min="400" max="720" step="1" value={Math.round(chatWidth)} onChange={event=>setChatWidth(Math.max(400,Math.round(Number(event.target.value))))}/><output>{chatWidth}px</output></label></section></div></section></div>}
178
+ <nav className="dvw-activity"><button className="dvw-act" data-active={primarySideVisible&&snapshot.view==='explorer'} title="资源管理器" onClick={()=>selectView('explorer')}><Icon name="files"/></button><button className="dvw-act" data-active={primarySideVisible&&snapshot.view==='search'} title="搜索" onClick={()=>selectView('search')}><Icon name="search"/></button><button className="dvw-act" data-active={primarySideVisible&&snapshot.view==='git'} title="源代码管理" onClick={()=>selectView('git')}><Icon name="source-control"/></button><button className="dvw-act dsh" title="返回 DSH" onClick={()=>service.hide()}><Icon name="home"/></button><button className="dvw-act dvw-settings-button" data-active={settingsOpen} title="管理工作台设置" onClick={()=>setSettingsOpen(true)}><Icon name="settings"/></button></nav>
179
+ <aside className="dvw-side" data-side-width={sideWidth}>{snapshot.view==='explorer'&&<Explorer sessionId={sessionId} cwd={workspace?.cwd} selected={active} onOpen={path=>void open(path)}/>} {snapshot.view==='search'&&<Search sessionId={sessionId} onOpen={(path,line,column)=>void open(path,line,column)}/>} {snapshot.view==='git'&&<Git sessionId={sessionId} onDiff={(path,diff,title,diffSourcePath)=>{const key=`diff:${path}`;setTabs(rows=>[...rows.filter(row=>row.path!==key),{path:key,title,content:diff.diff,draft:diff.diff,revision:'',size:diff.diff.length,binary:false,tooLarge:false,dirty:false,diff,diffMode:'inline',diffSourcePath}]);setActive(key)}}/>}</aside>
180
+ <div className="dvw-side-resizer" title="拖动调整侧边栏宽度,双击恢复默认" onDoubleClick={()=>setSideWidth(210)} onPointerDown={event=>{event.preventDefault();const startX=event.clientX,startWidth=sideWidth;const move=(next:PointerEvent)=>setSideWidth(Math.max(210,Math.min(640,Math.round(startWidth+next.clientX-startX))));const up=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up)};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up)}}/>
181
+ <main className="dvw-main"><div className="dvw-tabs"><div className="dvw-tab-list">{tabs.map(tab=><button className="dvw-tab" data-active={tab.path===active} key={tab.path} title={tab.diffSourcePath??tab.path} onClick={()=>setActive(tab.path)}><span className="dvw-tab-name">{tab.dirty?'● ':''}{tabLabel(tab)}</span><span onClick={event=>{event.stopPropagation();close(tab)}}><Icon name="close"/></span></button>)}</div>{tabs.length>0&&<div className="dvw-tab-actions"><button className="dvw-icon dvw-close-all" title="全部关闭" onClick={closeAll}><Icon name="close-all"/></button></div>}</div><div className="dvw-editor" data-outline={markdownPreview&&outlineVisible&&headings.length>0}>{error&&<div className="dvw-error"><button className="dvw-icon" onClick={()=>setError(undefined)}><Icon name="close"/></button>{error}</div>}{markdownPreview&&outlineVisible&&headings.length>0&&<MarkdownOutline headings={headings} onClose={()=>setOutlineVisible(false)} onHeading={selectHeading}/>} {markdown&&activeTab&&<div className="dvw-editor-toolbar"><button className="dvw-icon dvw-outline-toggle" data-active={outlineVisible} title={outlineVisible?'关闭 Markdown 大纲':'显示 Markdown 大纲'} onClick={()=>setOutlineVisible(value=>!value)}><Icon name="list-tree"/></button><button className="dvw-mode-button" data-active={!activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:false}:tab))}><Icon name="code"/> 源码</button><button className="dvw-mode-button" data-active={activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:true}:tab))}><Icon name="preview"/> 预览</button></div>}{activeTab?.diff&&<div className="dvw-editor-toolbar dvw-diff-toolbar">{diffMarkdown&&<div className="dvw-diff-preview-switch"><button className="dvw-mode-button" data-diff-view="diff" data-active={!activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:false}:tab))}><Icon name="diff"/> 差异</button><button className="dvw-mode-button" data-diff-view="preview" data-active={activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:true}:tab))}><Icon name="preview"/> 预览</button></div>}{!activeTab.preview&&<><button className="dvw-mode-button dvw-diff-mode-button" data-mode="inline" data-active={activeTab.diffMode!=='split'} title="内联视图" onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,diffMode:'inline'}:tab))}><Icon name="diff-single"/> 内联</button><button className="dvw-mode-button dvw-diff-mode-button" data-mode="split" data-active={activeTab.diffMode==='split'} title="并排视图" onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,diffMode:'split'}:tab))}><Icon name="diff-sidebyside"/> 并排</button></>}</div>}<div className="dvw-editor-body" data-toolbar={markdown||activeTab?.diff!==undefined}>{activeTab===undefined?<div className="dvw-empty"><div><strong>DSH Workbench</strong><br/>从资源管理器或搜索结果打开文件</div></div>:activeTab.diff?diffMarkdown&&activeTab.preview?<MarkdownPreview source={activeTab.diff.modified} sessionId={sessionId} path={activeTab.diffSourcePath??''}/>:<MonacoDiffEditor uri={`${workspace?.cwd??''}/${activeTab.path}`} original={activeTab.diff.original} modified={activeTab.diff.modified} mode={activeTab.diffMode??'inline'} theme={colorScheme}/>:activeTab.imageUrl?<div className="dvw-image-preview"><img src={activeTab.imageUrl} alt={activeTab.path}/><span>{activeTab.path}</span></div>:markdown&&activeTab.preview?<MarkdownPreview source={activeTab.draft} sessionId={sessionId} path={activeTab.path}/>:<MonacoEditor uri={`${workspace?.cwd??''}/${activeTab.path}`} value={activeTab.draft} theme={colorScheme} line={activeTab.line} column={activeTab.column} onChange={draft=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,draft,dirty:draft!==tab.content}:tab))} onSave={()=>void save(activeTab)}/>}</div></div></main>
182
+ {snapshot.bottomPanel&&<section className="dvw-bottom" style={{height:panelHeight}}><div style={{height:4,cursor:'ns-resize'}} onPointerDown={event=>{const startY=event.clientY,startHeight=panelHeight;const move=(e:PointerEvent)=>setPanelHeight(Math.max(120,Math.min(window.innerHeight-160,startHeight+startY-e.clientY)));const up=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up)};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up)}}/><div className="dvw-bottom-head"><button className="dvw-bottom-title">{panels.find(p=>p.id===snapshot.bottomPanel)?.title??snapshot.bottomPanel}</button><button className="dvw-icon" style={{marginLeft:'auto'}} onClick={()=>service.toggleBottomPanel(snapshot.bottomPanel!)}><Icon name="close"/></button></div><div className="dvw-bottom-body">{Panel&&cwd&&<Panel sessionId={sessionId} cwd={cwd} visible colorScheme={colorScheme}/>}</div></section>}
183
+ <footer className="dvw-status"><span><Icon name="git-branch"/> 工作区</span><span>{workspace?.cwd??cwd??''}</span>{activeTab?.external&&<span>磁盘文件已变化</span>}<button className="dvw-icon" style={{color:'white',marginLeft:'auto'}} onClick={()=>service.hide()}>返回 DSH</button></footer>
184
+ </div>
185
+ }
@@ -0,0 +1,8 @@
1
+ export async function api<T>(method: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
2
+ const response = await fetch(`/dsh-vscode/api/${method}`, {
3
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal,
4
+ })
5
+ const answer = await response.json() as { ok: boolean; value?: T; error?: { message?: string } }
6
+ if (!response.ok || !answer.ok) throw new Error(answer.error?.message ?? `request failed (${response.status})`)
7
+ return answer.value as T
8
+ }
@@ -0,0 +1,39 @@
1
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
2
+ import { useLayoutEffect, useRef, useState } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
5
+ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
6
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
7
+ import type { VscodeWorkbench } from './service.ts'
8
+ import { WorkbenchController } from './service.ts'
9
+ import { Launcher, Workbench } from './Workbench.tsx'
10
+ import { installStyles } from './styles.ts'
11
+
12
+ export type { BottomPanelContribution, BottomPanelProps, VscodeWorkbench, WorkbenchCommand, WorkbenchSnapshot, WorkbenchView } from './service.ts'
13
+
14
+ declare module '@deepseek-ai/cordis' { interface Context { vscodeWorkbench: VscodeWorkbench } }
15
+ declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotInjectMap { 'dsh-vscode-workbench.overlay': { service: VscodeWorkbench }; 'dsh-vscode-workbench.launcher': { service: VscodeWorkbench } } }
16
+
17
+ const Overlay = ({ service, useSessions }: PropsRuntime<'shell.overlay'> & { service: VscodeWorkbench }) => <Workbench service={service} useSessions={useSessions as never}/>
18
+ const Footer = ({ service, wide }: PropsRuntime<'sidebar.footer.action'> & { service: VscodeWorkbench }) => {
19
+ const marker=useRef<HTMLSpanElement>(null);const [target,setTarget]=useState<HTMLElement|null>()
20
+ useLayoutEffect(()=>{let container=marker.current?.parentElement;while(container?.previousElementSibling===null)container=container.parentElement;const region=container?.previousElementSibling;if(!(region instanceof HTMLElement)){setTarget(null);return}const mount=document.createElement('div');mount.className='dvw-sidebar-launcher-mount';region.prepend(mount);setTarget(mount);return()=>mount.remove()},[])
21
+ return <><span ref={marker} hidden/>{target===undefined?null:target===null?<Launcher service={service} wide={wide}/>:createPortal(<Launcher service={service} wide={wide}/>,target)}</>
22
+ }
23
+
24
+ export const inject = ['slots']
25
+ export function apply(ctx: ClientContext): void {
26
+ const service = new WorkbenchController()
27
+ ctx.effect(() => installStyles(), 'dsh-vscode-workbench: styles')
28
+ ctx.effect(() => { const dispose = ctx.reflect.provide('vscodeWorkbench', service); return () => { void dispose() } }, 'dsh-vscode-workbench: service')
29
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'dsh-vscode-workbench-launcher', inject: () => ({ service }) }, Footer))
30
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-vscode-workbench-overlay', inject: () => ({ service }) }, Overlay))
31
+ ctx.effect(() => {
32
+ const onKey = (event: KeyboardEvent) => {
33
+ if (event.isComposing || !event.ctrlKey || event.altKey) return
34
+ const view = event.shiftKey && event.code === 'KeyE' ? 'explorer' : event.shiftKey && event.code === 'KeyF' ? 'search' : event.shiftKey && event.code === 'KeyG' ? 'git' : undefined
35
+ if (view !== undefined) { event.preventDefault(); service.show(view) }
36
+ }
37
+ window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true)
38
+ }, 'dsh-vscode-workbench: navigation shortcuts')
39
+ }
@@ -0,0 +1,58 @@
1
+ import type { ComponentType } from 'react'
2
+
3
+ export type WorkbenchView = 'explorer' | 'search' | 'git'
4
+ export interface BottomPanelProps { sessionId: string; cwd: string; visible: boolean; colorScheme: 'dark' | 'light' }
5
+ export interface BottomPanelContribution { id: string; title: string; component: ComponentType<BottomPanelProps> }
6
+ export interface WorkbenchCommand { id: string; run(): void }
7
+ export interface WorkbenchSnapshot { visible: boolean; view: WorkbenchView; bottomPanel?: string }
8
+ interface WorkbenchStorage { getItem(key:string):string|null; setItem(key:string,value:string):unknown }
9
+
10
+ const SNAPSHOT_KEY='dvw:workbench:snapshot:v1'
11
+ const DEFAULT_SNAPSHOT:WorkbenchSnapshot={visible:false,view:'explorer'}
12
+ function browserStorage():WorkbenchStorage|undefined{try{return globalThis.localStorage}catch{return undefined}}
13
+ function restoreSnapshot(storage:WorkbenchStorage|undefined):WorkbenchSnapshot{if(storage===undefined)return {...DEFAULT_SNAPSHOT};try{const raw=storage.getItem(SNAPSHOT_KEY);if(raw===null)return {...DEFAULT_SNAPSHOT};const value=JSON.parse(raw) as Partial<WorkbenchSnapshot>;const view=value.view==='explorer'||value.view==='search'||value.view==='git'?value.view:'explorer';return {visible:value.visible===true,view,...(typeof value.bottomPanel==='string'?{bottomPanel:value.bottomPanel}:{})}}catch{return {...DEFAULT_SNAPSHOT}}}
14
+
15
+ /** Client service exposed by the workbench to small feature adapters. */
16
+ export interface VscodeWorkbench {
17
+ show(view?: WorkbenchView): void
18
+ hide(): void
19
+ registerBottomPanel(contribution: BottomPanelContribution): () => void
20
+ registerCommand(command: WorkbenchCommand): () => void
21
+ toggleBottomPanel(id: string): void
22
+ runCommand(id: string): void
23
+ getSnapshot(): WorkbenchSnapshot
24
+ getPanels(): readonly BottomPanelContribution[]
25
+ subscribe(listener: () => void): () => void
26
+ }
27
+
28
+ export class WorkbenchController implements VscodeWorkbench {
29
+ private snapshot: WorkbenchSnapshot
30
+ private readonly storage:WorkbenchStorage|undefined
31
+ private readonly listeners = new Set<() => void>()
32
+ private readonly panels = new Map<string, BottomPanelContribution>()
33
+ private panelSnapshot: readonly BottomPanelContribution[] = []
34
+ private readonly commands = new Map<string, WorkbenchCommand>()
35
+ constructor(storage:WorkbenchStorage|undefined=browserStorage()){this.storage=storage;this.snapshot=restoreSnapshot(storage)}
36
+ show(view?: WorkbenchView): void { this.update({ ...this.snapshot, visible: true, ...(view === undefined ? {} : { view }) }) }
37
+ hide(): void { this.update({ ...this.snapshot, visible: false }) }
38
+ toggleBottomPanel(id: string): void {
39
+ this.update({ ...this.snapshot, visible: true, bottomPanel: this.snapshot.bottomPanel === id ? undefined : id })
40
+ }
41
+ registerBottomPanel(value: BottomPanelContribution): () => void {
42
+ if (this.panels.has(value.id)) throw new Error(`workbench panel already registered: ${value.id}`)
43
+ this.panels.set(value.id, value); this.refreshPanels()
44
+ return () => { this.panels.delete(value.id); this.refreshPanels() }
45
+ }
46
+ registerCommand(value: WorkbenchCommand): () => void {
47
+ if (this.commands.has(value.id)) throw new Error(`workbench command already registered: ${value.id}`)
48
+ this.commands.set(value.id, value)
49
+ return () => { this.commands.delete(value.id) }
50
+ }
51
+ runCommand(id: string): void { this.commands.get(id)?.run() }
52
+ getSnapshot(): WorkbenchSnapshot { return this.snapshot }
53
+ getPanels(): readonly BottomPanelContribution[] { return this.panelSnapshot }
54
+ subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener) } }
55
+ private update(snapshot: WorkbenchSnapshot): void { this.snapshot = snapshot;try{this.storage?.setItem(SNAPSHOT_KEY,JSON.stringify(snapshot))}catch{/* Browser privacy settings may deny storage while the in-memory workbench remains usable. */}this.emit() }
56
+ private refreshPanels(): void { this.panelSnapshot = [...this.panels.values()]; this.emit() }
57
+ private emit(): void { for (const listener of this.listeners) listener() }
58
+ }
@@ -0,0 +1,49 @@
1
+ const CSS = String.raw`
2
+ @font-face{font-family:codicon;font-display:block;src:url('/dsh-vscode/codicons/codicon.ttf') format('truetype')}
3
+ .codicon{display:inline-block;font:normal normal normal 16px/1 codicon;text-align:center;text-decoration:none;text-rendering:auto;-webkit-font-smoothing:antialiased;user-select:none}
4
+ .codicon-add:before{content:"\ea60"}.codicon-git-branch:before,.codicon-source-control:before{content:"\ea68"}.codicon-search:before{content:"\ea6d"}.codicon-close:before{content:"\ea76"}.codicon-close-all:before{content:"\eac1"}.codicon-file:before{content:"\ea7b"}.codicon-new-file:before{content:"\ea7f"}.codicon-new-folder:before{content:"\ea80"}.codicon-folder:before{content:"\ea83"}.codicon-trash:before{content:"\ea81"}.codicon-code:before{content:"\eac4"}.codicon-check:before{content:"\eab2"}.codicon-chevron-down:before{content:"\eab4"}.codicon-chevron-right:before{content:"\eab6"}.codicon-color-mode:before{content:"\eac6"}.codicon-diff:before,.codicon-diff-sidebyside:before{content:"\eae1"}.codicon-diff-single:before{content:"\ec22"}.codicon-discard:before{content:"\eae2"}.codicon-file-code:before{content:"\eae9"}.codicon-file-media:before{content:"\eaea"}.codicon-file-pdf:before{content:"\eaeb"}.codicon-file-zip:before{content:"\eaef"}.codicon-files:before{content:"\eaf0"}.codicon-folder-opened:before{content:"\eaf7"}.codicon-git-commit:before{content:"\eafc"}.codicon-graph:before{content:"\eb03"}.codicon-home:before{content:"\eb06"}.codicon-list-tree:before{content:"\eb86"}.codicon-preview:before{content:"\eb2f"}.codicon-refresh:before{content:"\eb37"}.codicon-remove:before{content:"\eb3b"}.codicon-check-all:before{content:"\ebb1"}
5
+ .codicon-layout-sidebar-right:before{content:"\ebf4"}.codicon-layout-sidebar-right-off:before{content:"\ec00"}.codicon-settings:before{content:"\eb51"}.codicon-sparkle:before{content:"\ec10"}
6
+ .dvw-launch{display:flex;align-items:center;gap:10px;width:100%;height:36px;padding:0 9px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#aaa);cursor:pointer;font:inherit}.dvw-launch:hover{background:var(--dsw-alias-interactive-bg-hover,#292929);color:var(--dsw-alias-label-primary,#fff)}.dvw-launch-icon{width:22px;font-size:19px}
7
+ .dvw-overlay{--dvw-side-width:210px;--dvw-base:var(--dsw-alias-bg-base,#181818);--dvw-surface-1:var(--dsw-alias-bg-layer-1,#1e1e1e);--dvw-surface-2:var(--dsw-alias-bg-layer-2,#252526);--dvw-surface-3:var(--dsw-alias-bg-layer-3,#2d2d30);--dvw-text:var(--dsw-alias-label-primary,#ccc);--dvw-text-2:var(--dsw-alias-label-secondary,#aaa);--dvw-text-3:var(--dsw-alias-label-tertiary,#858585);--dvw-border:var(--dsw-alias-border-l2,#333);--dvw-border-soft:var(--dsw-alias-border-l3,#292929);--dvw-hover:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));--dvw-active:var(--dsw-alias-interactive-bg-active,rgba(255,255,255,.14));--dvw-accent:var(--dsw-alias-state-business-primary,#3b82f6);--dvw-error:var(--dsw-alias-state-error-primary,#f48771);--dvw-success:var(--dsw-alias-state-success-primary,#73c991);--dvw-warn:var(--dsw-alias-state-warn-primary,#d7ba7d);position:absolute;inset:0;z-index:100;display:grid;grid-template-columns:48px var(--dvw-side-width) minmax(0,1fr);grid-template-rows:minmax(0,1fr) auto 24px;background:var(--dvw-base);color:var(--dvw-text);pointer-events:auto;font:13px/1.4 var(--dsw-font-family,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif);overflow:hidden;color-scheme:light}
8
+ body[data-ds-dark-theme] .dvw-overlay,.dvw-overlay[data-dvw-theme=dark]{color-scheme:dark}
9
+ .dvw-overlay[data-dvw-theme=dark]{--dvw-base:#151517;--dvw-surface-1:#232324;--dvw-surface-2:#2c2c2e;--dvw-surface-3:#353638;--dvw-text:#f9fafb;--dvw-text-2:#cfd3d6;--dvw-text-3:#adb2b8;--dvw-border:#ffffff1f;--dvw-border-soft:#ffffff29;--dvw-hover:#ffffff14;--dvw-active:#ffffff24;--dvw-accent:#679efe;--dvw-error:#f25a5a;--dvw-success:#22c55e;--dvw-warn:#f59e0b}
10
+ .dvw-overlay[data-dvw-theme=light]{--dvw-base:#fff;--dvw-surface-1:#fff;--dvw-surface-2:#fff;--dvw-surface-3:#fff;--dvw-text:#0f1115;--dvw-text-2:#61666b;--dvw-text-3:#81858c;--dvw-border:#0000001a;--dvw-border-soft:#0000001f;--dvw-hover:#2631480f;--dvw-active:#2631481a;--dvw-accent:#4176e6;--dvw-error:#ec1313;--dvw-success:#22c55e;--dvw-warn:#f59e0b;color-scheme:light}
11
+ .dvw-overlay *{box-sizing:border-box}.dvw-activity{grid-row:1/3;background:var(--dvw-base);border-right:1px solid var(--dvw-border-soft);display:flex;flex-direction:column;align-items:center}.dvw-act{width:48px;height:48px;border:0;border-left:2px solid transparent;background:transparent;color:var(--dvw-text-3);font-size:22px;cursor:pointer}.dvw-act:hover,.dvw-act[data-active=true]{color:var(--dvw-text)}.dvw-act[data-active=true]{border-left-color:var(--dvw-accent)}.dvw-act.dsh{margin-top:auto}.dvw-side{grid-column:2;grid-row:1/3;min-width:0;width:100%;background:var(--dvw-surface-1);border-right:1px solid var(--dvw-border);display:flex;flex-direction:column}.dvw-side-resizer{grid-column:2;grid-row:1/3;justify-self:end;width:7px;height:100%;z-index:4;cursor:ew-resize;transform:translateX(3px);touch-action:none}.dvw-side-resizer:hover,.dvw-side-resizer:active{background:var(--dvw-accent)}.dvw-side-head{height:36px;display:flex;align-items:center;padding:0 12px;text-transform:uppercase;font-size:11px;letter-spacing:.4px}.dvw-toolbar{margin-left:auto;display:flex;gap:2px}.dvw-icon{border:0;background:transparent;color:var(--dvw-text-2);border-radius:4px;min-width:24px;height:24px;display:inline-grid;place-items:center;cursor:pointer}.dvw-icon:hover{background:var(--dvw-hover);color:var(--dvw-text)}.dvw-icon:disabled{opacity:.45;cursor:default}.dvw-tree,.dvw-results,.dvw-git{overflow:auto;flex:1}.dvw-tree-row{display:flex;align-items:center;height:24px;padding-right:6px;cursor:pointer;white-space:nowrap}.dvw-tree-row:hover{background:var(--dvw-hover)}.dvw-tree-row[data-selected=true]{background:var(--dvw-active)}.dvw-chevron{width:16px;text-align:center;color:var(--dvw-text-3)}.dvw-file-icon{width:18px;color:var(--dvw-text-2)}.dvw-file-icon.folder{color:var(--dvw-accent)}
12
+ .dvw-main{grid-column:3;grid-row:1;display:flex;min-width:0;min-height:0;flex-direction:column;background:var(--dvw-surface-1)}.dvw-tabs{height:36px;background:var(--dvw-base);display:flex;overflow-x:auto}.dvw-tab{height:36px;min-width:110px;max-width:260px;border:0;border-right:1px solid var(--dvw-border-soft);background:var(--dvw-base);color:var(--dvw-text-2);padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;cursor:pointer}.dvw-tab[data-active=true]{background:var(--dvw-surface-1);color:var(--dvw-text);border-top:1px solid var(--dvw-accent)}.dvw-tab-name{overflow:hidden;text-overflow:ellipsis;flex:1;text-align:left}.dvw-editor{position:relative;flex:1;min-height:0}.dvw-editor-toolbar{position:absolute;z-index:3;top:0;right:0;left:0;height:36px;display:flex;align-items:center;justify-content:flex-end;gap:4px;padding:0 10px;border-bottom:1px solid var(--dvw-border-soft);background:var(--dvw-surface-1)}.dvw-editor-body{position:absolute;inset:0;min-height:0}.dvw-editor-body[data-toolbar=true]{top:36px}.dvw-mode-button{height:27px;padding:0 10px;display:flex;align-items:center;gap:6px;border:1px solid transparent;border-radius:5px;background:transparent;color:var(--dvw-text-2);cursor:pointer}.dvw-mode-button:hover{background:var(--dvw-hover)}.dvw-mode-button[data-active=true]{border-color:var(--dvw-border);background:var(--dvw-active);color:var(--dvw-text)}.dvw-empty{height:100%;display:grid;place-items:center;color:var(--dvw-text-3);text-align:center}.dvw-empty strong{font-size:24px;color:var(--dvw-text-2)}.dvw-message{padding:12px;color:var(--dvw-warn)}
13
+ .dvw-bottom{grid-column:3;grid-row:2;height:260px;min-height:120px;border-top:1px solid var(--dvw-border);background:var(--dvw-base);display:flex;flex-direction:column}.dvw-bottom-head{height:35px;display:flex;align-items:center;gap:14px;padding:0 12px}.dvw-bottom-title{border:0;border-bottom:1px solid var(--dvw-text);background:transparent;color:var(--dvw-text);height:35px}.dvw-bottom-body{flex:1;min-height:0}.dvw-status{grid-column:1/4;grid-row:3;background:var(--dvw-accent);color:#fff;display:flex;align-items:center;padding:0 8px;gap:18px;font-size:12px}.dvw-theme-control{position:relative;margin-left:auto}.dvw-theme-trigger{height:22px;min-width:112px;display:flex;align-items:center;gap:7px;padding:0 8px;border:0;border-radius:5px;background:color-mix(in srgb,#000 17%,transparent);color:#fff;cursor:pointer}.dvw-theme-trigger:hover{background:color-mix(in srgb,#000 27%,transparent)}.dvw-theme-orb{width:13px;height:13px;border:2px solid #fff;border-radius:50%;background:linear-gradient(90deg,#17181c 50%,#fff 50%)}.dvw-theme-orb.dark{background:#17181c}.dvw-theme-orb.light{background:#fff}.dvw-theme-menu{position:absolute;right:0;bottom:29px;width:224px;padding:6px;border:1px solid var(--dvw-border);border-radius:10px;background:var(--dvw-surface-3);box-shadow:0 10px 35px rgba(0,0,0,.28);color:var(--dvw-text)}.dvw-theme-menu button{width:100%;min-height:48px;border:0;border-radius:7px;background:transparent;color:inherit;display:grid;grid-template-columns:30px 1fr 20px;align-items:center;text-align:left;padding:6px;cursor:pointer}.dvw-theme-menu button:hover,.dvw-theme-menu button[aria-checked=true]{background:var(--dvw-hover)}.dvw-theme-menu strong,.dvw-theme-menu small{display:block}.dvw-theme-menu small{color:var(--dvw-text-3);margin-top:2px}.dvw-theme-swatch{width:22px;height:22px;border-radius:6px;border:1px solid var(--dvw-border);background:linear-gradient(135deg,#17181c 0 50%,#fff 50%)}.dvw-theme-swatch.dark{background:#17181c}.dvw-theme-swatch.light{background:#fff}
14
+ .dvw-search-form{padding:8px;display:grid;gap:6px}.dvw-input,.dvw-textarea,.dvw-select{width:100%;border:1px solid var(--dvw-border);background:var(--dvw-surface-2);color:var(--dvw-text);padding:5px 7px;outline:none}.dvw-input:focus,.dvw-textarea:focus,.dvw-select:focus{border-color:var(--dvw-accent)}.dvw-options{display:flex;align-items:center;gap:8px;font-size:11px}.dvw-result-file{padding:5px 8px;font-weight:600}.dvw-result{padding:4px 8px 4px 20px;cursor:pointer;display:flex;gap:8px}.dvw-result:hover{background:var(--dvw-hover)}.dvw-line{color:var(--dvw-text-3);min-width:30px;text-align:right}.dvw-preview{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
15
+ .dvw-git-head{padding:7px 10px;border-bottom:1px solid var(--dvw-border);display:flex;align-items:center;gap:8px}.dvw-branch{min-width:0;border:0;background:transparent;color:var(--dvw-text);font:inherit;font-weight:600;display:flex;align-items:center;gap:5px;cursor:pointer}.dvw-commit{display:grid;grid-template-columns:1fr auto;gap:6px;padding:9px}.dvw-commit .dvw-input{grid-column:1/3}.dvw-commit-button{grid-column:1/3;height:31px;border:0;border-radius:6px;background:color-mix(in srgb,var(--dvw-accent) 24%,var(--dvw-surface-2));color:var(--dvw-text);display:flex;align-items:center;justify-content:center;gap:7px;cursor:pointer}.dvw-commit-button:hover{background:color-mix(in srgb,var(--dvw-accent) 34%,var(--dvw-surface-2))}.dvw-commit-button:disabled{opacity:.45;cursor:default}.dvw-group-head{height:30px;display:flex;align-items:center;gap:5px;padding:0 6px 0 9px;border-radius:5px;font-weight:600;position:sticky;top:0;background:var(--dvw-surface-1);z-index:1}.dvw-group-head:hover{background:var(--dvw-hover)}.dvw-group-head .dvw-count{margin-left:auto;min-width:20px;height:20px;border-radius:10px;display:grid;place-items:center;background:var(--dvw-surface-3);color:var(--dvw-text-2);font-size:11px}.dvw-change{display:flex;align-items:center;min-height:28px;padding:3px 5px 3px 19px;gap:5px;cursor:pointer}.dvw-change:hover{background:var(--dvw-hover)}.dvw-change .dvw-icon{opacity:0}.dvw-change:hover .dvw-icon,.dvw-change:focus-within .dvw-icon{opacity:1}.dvw-change-path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}.dvw-badge{font-size:11px;color:var(--dvw-warn)}.dvw-graph{border-top:1px solid var(--dvw-border-soft);margin-top:8px;padding-top:3px}.dvw-graph-list{padding-bottom:8px}.dvw-graph-row{width:100%;height:38px;border:0;background:transparent;color:var(--dvw-text);display:flex;align-items:center;padding:0 7px;text-align:left;cursor:pointer;overflow:hidden}.dvw-graph-row:hover{background:var(--dvw-hover)}.dvw-graph-svg{flex:none;align-self:stretch}.dvw-graph-copy{display:flex;min-width:0;flex:1;flex-direction:column;justify-content:center}.dvw-graph-subject{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dvw-graph-meta{font-size:11px;color:var(--dvw-text-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dvw-ref{max-width:82px;margin-left:4px;padding:1px 5px;border:1px solid color-mix(in srgb,var(--dvw-accent) 50%,transparent);border-radius:9px;color:var(--dvw-accent);font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dvw-error{padding:8px;color:var(--dvw-error);white-space:pre-wrap}.dvw-diff{margin:0;height:100%;overflow:auto;padding:8px 0;background:var(--dvw-surface-1);font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre}.dvw-diff-line{display:block;min-height:18px;padding:0 14px}.dvw-diff-line.add{background:color-mix(in srgb,var(--dvw-success) 15%,transparent);color:var(--dvw-success)}.dvw-diff-line.remove{background:color-mix(in srgb,var(--dvw-error) 13%,transparent);color:var(--dvw-error)}.dvw-diff-line.hunk{background:color-mix(in srgb,var(--dvw-accent) 12%,transparent);color:var(--dvw-accent)}.dvw-diff-line.meta{color:var(--dvw-text-3)}.dvw-diff-empty{padding:24px;color:var(--dvw-text-3)}
16
+ .dvw-tabs{overflow:hidden}.dvw-tab-list{display:flex;min-width:0;flex:1;overflow-x:auto}.dvw-tab-actions{flex:none;width:42px;display:grid;place-items:center;padding:0 6px;background:var(--dvw-base)}.dvw-close-all{border:1px solid var(--dvw-border);background:var(--dvw-base)}.dvw-monaco-diff{position:absolute;inset:0}.dvw-diff-toolbar{justify-content:flex-end}.dvw-group-head{gap:2px;padding-left:2px}.dvw-group-toggle{min-width:0;height:30px;flex:1;border:0;border-radius:5px;background:transparent;color:var(--dvw-text);font:inherit;font-weight:600;display:flex;align-items:center;gap:5px;padding:0 7px;cursor:pointer;text-align:left}.dvw-group-toggle:hover{background:var(--dvw-hover)}.dvw-graph-entry[data-expanded=true]{background:color-mix(in srgb,var(--dvw-accent) 5%,transparent)}.dvw-commit-files{padding:1px 0 5px 26px}.dvw-commit-file{width:100%;height:27px;border:0;background:transparent;color:var(--dvw-text);display:flex;align-items:center;gap:6px;padding:0 7px;text-align:left;cursor:pointer}.dvw-commit-file:hover{background:var(--dvw-hover)}.dvw-commit-file-name{white-space:nowrap}.dvw-commit-file-dir{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dvw-text-3);font-size:11px}
17
+ .dvw-sidebar-launcher-mount{flex:none;padding:4px 8px 6px;border-bottom:1px solid var(--dvw-border-soft)}.dvw-sidebar-launcher-mount .dvw-launch{color:var(--dsw-alias-label-primary,#333)}body[data-ds-dark-theme] .dvw-sidebar-launcher-mount .dvw-launch{color:var(--dsw-alias-label-primary,#ddd)}.dvw-outline{flex:none;max-height:38%;display:flex;flex-direction:column;border-bottom:1px solid var(--dvw-border)}.dvw-outline-head{height:30px;display:flex;align-items:center;justify-content:space-between;padding:0 5px 0 10px;font-weight:600}.dvw-outline-head>span{display:flex;align-items:center;gap:6px}.dvw-outline-list{overflow:auto;padding-bottom:5px}.dvw-outline-item{display:block;width:100%;height:25px;border:0;background:transparent;color:var(--dvw-text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;cursor:pointer}.dvw-outline-item:hover{background:var(--dvw-hover);color:var(--dvw-text)}.dvw-image-preview{position:absolute;inset:0;overflow:auto;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:24px;background:repeating-conic-gradient(var(--dvw-surface-2) 0 25%,var(--dvw-surface-1) 0 50%) 0/20px 20px}.dvw-image-preview img{max-width:100%;max-height:calc(100% - 32px);object-fit:contain;box-shadow:0 4px 24px rgba(0,0,0,.22)}.dvw-image-preview span{max-width:100%;padding:3px 8px;border-radius:5px;background:var(--dvw-surface-1);color:var(--dvw-text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
18
+ .dvw-markdown{height:100%;overflow:auto;padding:28px clamp(24px,6vw,80px) 60px;color:var(--dvw-text);font-size:15px;line-height:1.65}.dvw-markdown>*:first-child{margin-top:0}.dvw-markdown h1,.dvw-markdown h2{padding-bottom:.3em;border-bottom:1px solid var(--dvw-border)}.dvw-markdown h1{font-size:2em}.dvw-markdown h2{font-size:1.5em}.dvw-markdown h3{font-size:1.25em}.dvw-markdown a{color:var(--dvw-accent)}.dvw-markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--dvw-surface-2);border-radius:4px;padding:.15em .35em}.dvw-markdown pre{overflow:auto;padding:14px;border:1px solid var(--dvw-border-soft);border-radius:7px;background:var(--dvw-base)}.dvw-markdown pre code{padding:0;background:transparent}.dvw-markdown blockquote{margin-left:0;padding-left:14px;border-left:3px solid var(--dvw-border);color:var(--dvw-text-2)}.dvw-markdown table{border-collapse:collapse;display:block;overflow:auto}.dvw-markdown th,.dvw-markdown td{padding:6px 12px;border:1px solid var(--dvw-border)}.dvw-markdown tr:nth-child(2n){background:var(--dvw-hover)}.dvw-markdown img{max-width:100%}.dvw-markdown hr{border:0;border-top:1px solid var(--dvw-border)}
19
+ .dvw-sidebar-launcher-mount{border-bottom:1px solid var(--dsw-alias-border-l3,#e5e7eb)}.dvw-outline-toggle{margin-right:auto}.dvw-outline-toggle[data-active=true]{background:var(--dvw-active);color:var(--dvw-text)}.dvw-editor-outline{position:absolute;z-index:2;top:36px;bottom:0;left:0;width:240px;max-height:none;background:var(--dvw-surface-1);border-right:1px solid var(--dvw-border);border-bottom:0}.dvw-editor[data-outline=true] .dvw-editor-body{left:240px}.dvw-diff-preview-switch{margin-right:auto;display:flex;gap:4px}.dvw-commit-files{position:relative;padding-top:1px;padding-right:0;padding-bottom:5px}.dvw-graph-continuation{position:absolute;top:0;bottom:0;left:7px;height:100%;pointer-events:none}.dvw-commit-file-list{position:relative;min-width:0}.dvw-overlay button:focus-visible,.dvw-overlay input:focus-visible,.dvw-overlay select:focus-visible{outline:1px solid var(--dvw-accent);outline-offset:-1px}@media(max-width:800px){.dvw-overlay{grid-template-columns:44px var(--dvw-side-width) minmax(0,1fr)}.dvw-editor-outline{width:210px}.dvw-editor[data-outline=true] .dvw-editor-body{left:210px}}
20
+ .dvw-overlay[data-dvw-chat=true]{grid-template-columns:48px var(--dvw-side-width) minmax(0,1fr) var(--dvw-chat-width)}
21
+ .dvw-overlay:not([data-dvw-primary-side=true]){grid-template-columns:48px 0 minmax(0,1fr)}
22
+ .dvw-overlay[data-dvw-chat=true]:not([data-dvw-primary-side=true]){grid-template-columns:48px 0 minmax(0,1fr) var(--dvw-chat-width)}
23
+ .dvw-overlay:not([data-dvw-primary-side=true]) .dvw-side{visibility:hidden;border-right:0}.dvw-overlay:not([data-dvw-primary-side=true]) .dvw-side-resizer{display:none}
24
+ .dvw-overlay:not([data-dvw-status=true]){grid-template-rows:minmax(0,1fr) auto 0}.dvw-overlay:not([data-dvw-status=true]) .dvw-status{display:none}
25
+ .dvw-overlay[data-dvw-chat=true] .dvw-status{grid-column:1/-1;padding-right:calc(var(--dvw-chat-width) + 8px)}
26
+ .dvw-chat{grid-column:4;grid-row:1/3;min-width:0;background:var(--dvw-base);border-left:1px solid var(--dvw-border);position:relative}
27
+ .dvw-chat-head{height:36px;display:flex;align-items:center;justify-content:space-between;padding:0 6px 0 12px;border-bottom:1px solid var(--dvw-border);background:var(--dvw-base);font-weight:600}
28
+ .dvw-chat-head>span{display:flex;align-items:center;gap:7px}.dvw-chat-body{height:calc(100% - 36px);background:var(--dvw-base)}
29
+ .dvw-dsh-mark{min-width:18px;height:18px;display:inline-grid;place-items:center;color:currentColor;font-size:0}.dvw-dsh-mark svg{display:block}
30
+ .dvw-chat-resizer{position:fixed;top:0;right:calc(var(--dvw-chat-width) - 4px);bottom:0;width:8px;z-index:4;cursor:ew-resize;touch-action:none}
31
+ .dvw-chat-resizer:hover,.dvw-chat-resizer:active{background:var(--dvw-accent)}
32
+ .dvw-chat-toggle{position:absolute;z-index:5;top:6px;right:6px;border:1px solid var(--dvw-border);background:var(--dvw-base)}
33
+ .dvw-overlay:not([data-dvw-chat=true]) .dvw-tabs{padding-right:36px}
34
+ [data-dvw-conversation-panel]{position:fixed!important;top:36px!important;right:0!important;bottom:var(--dvw-status-height,24px)!important;left:auto!important;width:var(--dvw-chat-width)!important;height:auto!important;min-width:0!important;z-index:21!important;border-left:1px solid var(--dsw-alias-border-l2)!important;box-shadow:none!important}
35
+ .dvw-settings-backdrop{position:absolute;inset:0;z-index:30;display:grid;place-items:center;padding:24px;background:rgba(0,0,0,.34);backdrop-filter:blur(2px)}
36
+ .dvw-settings{width:min(720px,calc(100vw - 48px));max-height:min(720px,calc(100vh - 48px));display:flex;flex-direction:column;border:1px solid var(--dvw-border);border-radius:10px;background:var(--dvw-surface-1);box-shadow:0 16px 50px rgba(0,0,0,.32);overflow:hidden}
37
+ .dvw-settings>header{height:44px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 10px 0 14px;border-bottom:1px solid var(--dvw-border);font-size:14px;font-weight:600}.dvw-settings>header>span{display:flex;align-items:center;gap:8px}
38
+ .dvw-settings-body{overflow:auto;padding:8px 20px 20px}.dvw-settings-body section+section{margin-top:20px;padding-top:12px;border-top:1px solid var(--dvw-border-soft)}.dvw-settings-body h3{margin:8px 0 12px;font-size:13px}.dvw-settings-body>section>label:not(.dvw-setting-check):not(.dvw-setting-range){display:block;margin-bottom:7px;color:var(--dvw-text-2);font-size:12px}
39
+ .dvw-settings-themes{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.dvw-settings-themes button{height:56px;display:grid;grid-template-columns:28px 1fr 18px;align-items:center;gap:7px;padding:0 9px;border:1px solid var(--dvw-border);border-radius:7px;background:var(--dvw-base);color:var(--dvw-text);text-align:left;cursor:pointer}.dvw-settings-themes button:hover{background:var(--dvw-hover)}.dvw-settings-themes button[data-active=true]{border-color:var(--dvw-accent);background:color-mix(in srgb,var(--dvw-accent) 10%,var(--dvw-base))}
40
+ .dvw-setting-check{height:36px;display:flex;align-items:center;gap:9px;border-bottom:1px solid var(--dvw-border-soft);cursor:pointer}.dvw-setting-check input{accent-color:var(--dvw-accent)}.dvw-setting-range{display:grid;grid-template-columns:145px 1fr 52px;align-items:center;gap:10px;min-height:42px}.dvw-setting-range input{accent-color:var(--dvw-accent)}.dvw-setting-range output{text-align:right;color:var(--dvw-text-3);font-variant-numeric:tabular-nums}
41
+ @media(max-width:900px){.dvw-overlay[data-dvw-chat=true]{grid-template-columns:44px min(210px,var(--dvw-side-width)) minmax(0,1fr) min(400px,var(--dvw-chat-width))}[data-dvw-conversation-panel]{width:min(400px,var(--dvw-chat-width))!important}}
42
+ `
43
+
44
+ export function installStyles(): () => void {
45
+ const id = 'dsh-vscode-workbench/styles'
46
+ let tag = document.querySelector<HTMLStyleElement>(`style[data-plugin-css="${id}"]`)
47
+ if (tag === null) { tag = document.createElement('style'); tag.dataset.pluginCss = id; tag.textContent = CSS; document.head.append(tag) }
48
+ return () => { tag?.remove() }
49
+ }
package/src/context.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import type { Duplex } from 'node:stream'
3
+ import type { Context as CordisContext } from '@deepseek-ai/cordis'
4
+
5
+ export interface WorkbenchWebServer {
6
+ register(route: { kind: 'exact' | 'prefix'; path: string; handler(req: IncomingMessage, res: ServerResponse): void | Promise<void> }): () => void
7
+ registerUpgrade(route: { path: string; handler(req: IncomingMessage, socket: Duplex, head: Buffer): void | Promise<void> }): () => void
8
+ }
9
+ export interface WorkbenchSessions { get(id: string): { header: { cwd?: string } } | undefined }
10
+ export interface WorkbenchWebRuntime { trustedHosts: readonly string[] }
11
+ export type HostContext = CordisContext & {
12
+ webServer: WorkbenchWebServer
13
+ sessions: WorkbenchSessions
14
+ webRuntime: WorkbenchWebRuntime
15
+ effect(callback: () => void | (() => void), label?: string): void
16
+ }