dsh-vscode-workbench 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.en.md +4 -0
  2. package/README.md +8 -2
  3. package/lib/client.js +5014 -674
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +832 -80
  6. package/lib/index.js.map +1 -1
  7. package/lib/types/client/ActivityBar.d.ts +24 -0
  8. package/lib/types/client/DshConversationPanel.d.ts +1 -1
  9. package/lib/types/client/FileIcon.d.ts +8 -0
  10. package/lib/types/client/Icon.d.ts +1 -3
  11. package/lib/types/client/MonacoEditor.d.ts +36 -0
  12. package/lib/types/client/Workbench.d.ts +19 -7
  13. package/lib/types/client/WorkspaceSwitcher.d.ts +13 -0
  14. package/lib/types/client/api.d.ts +2 -0
  15. package/lib/types/client/documentPreview.d.ts +10 -2
  16. package/lib/types/client/dshComposer.d.ts +21 -0
  17. package/lib/types/client/dshNavigation.d.ts +46 -0
  18. package/lib/types/client/fileIconMap.d.ts +2 -0
  19. package/lib/types/client/gitError.d.ts +2 -0
  20. package/lib/types/client/lsp.d.ts +27 -0
  21. package/lib/types/client/searchHighlight.d.ts +5 -0
  22. package/lib/types/client/typescriptProject.d.ts +20 -0
  23. package/lib/types/context.d.ts +15 -0
  24. package/lib/types/host.d.ts +37 -0
  25. package/lib/types/lsp.d.ts +54 -0
  26. package/lib/types/office.d.ts +17 -0
  27. package/lib/types/types.d.ts +5 -0
  28. package/lib/types/watch-ignore.d.ts +4 -0
  29. package/package.json +5 -2
  30. package/src/client/ActivityBar.tsx +152 -0
  31. package/src/client/DshConversationPanel.tsx +15 -12
  32. package/src/client/FileIcon.tsx +6 -0
  33. package/src/client/Icon.tsx +1 -14
  34. package/src/client/MonacoEditor.tsx +187 -19
  35. package/src/client/TerminalPanel.tsx +6 -3
  36. package/src/client/Workbench.tsx +324 -81
  37. package/src/client/WorkspaceSwitcher.tsx +53 -0
  38. package/src/client/api.ts +27 -1
  39. package/src/client/documentPreview.tsx +92 -5
  40. package/src/client/dshComposer.ts +73 -0
  41. package/src/client/dshNavigation.ts +81 -0
  42. package/src/client/fileIconMap.ts +7 -0
  43. package/src/client/gitError.ts +5 -0
  44. package/src/client/index.tsx +28 -14
  45. package/src/client/lsp.ts +300 -0
  46. package/src/client/searchHighlight.ts +6 -0
  47. package/src/client/styles.ts +28 -31
  48. package/src/client/typescriptProject.ts +81 -0
  49. package/src/context.ts +8 -0
  50. package/src/host.ts +100 -15
  51. package/src/index.ts +121 -17
  52. package/src/lsp.ts +170 -0
  53. package/src/office.ts +95 -0
  54. package/src/types.ts +2 -0
  55. package/src/watch-ignore.ts +22 -1
@@ -0,0 +1,53 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Icon } from './Icon.tsx'
3
+ import type { WorkspaceChoice } from './dshNavigation.ts'
4
+
5
+ /** Centered switcher: existing workspaces, their sessions, and a new-workspace action. */
6
+ export function WorkspaceSwitcher({ choices, activeSessionId, unavailable, busy, error, onCreate, onSwitch, onOpenWorkspace, onClose }: {
7
+ choices: WorkspaceChoice[]
8
+ activeSessionId?: string
9
+ unavailable: boolean
10
+ busy: boolean
11
+ error?: string
12
+ onCreate(): void
13
+ onSwitch(sessionId: string): void
14
+ onOpenWorkspace(workspaceId: string): void
15
+ onClose(): void
16
+ }) {
17
+ const current = choices.find(workspace => workspace.sessions.some(session => session.id === activeSessionId))?.id
18
+ const [expanded, setExpanded] = useState<string | undefined>(current ?? choices[0]?.id)
19
+ useEffect(() => {
20
+ const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() }
21
+ window.addEventListener('keydown', escape)
22
+ return () => window.removeEventListener('keydown', escape)
23
+ }, [onClose])
24
+ return <div className="dvw-confirm-backdrop" role="presentation" onMouseDown={onClose}>
25
+ <section className="dvw-workspace-switcher" role="dialog" aria-modal="true" aria-label="打开工作区" onMouseDown={event => event.stopPropagation()}>
26
+ <header><h2>打开工作区</h2><button className="dvw-icon" title="关闭" onClick={onClose}><Icon name="close"/></button></header>
27
+ <div className="dvw-workspace-list">
28
+ {unavailable ? <p className="dvw-workspace-empty">无法读取 DSH 的工作区列表,请重启 DSH 后再试。</p>
29
+ : choices.length === 0 ? <p className="dvw-workspace-empty">还没有工作区,先新建一个。</p>
30
+ : choices.map(workspace => <div className="dvw-workspace-group" key={workspace.id}>
31
+ <div className="dvw-workspace-row" data-expanded={expanded === workspace.id}>
32
+ <button className="dvw-workspace-toggle" aria-expanded={expanded === workspace.id} onClick={() => setExpanded(value => value === workspace.id ? undefined : workspace.id)}>
33
+ <Icon name="folder-opened"/>
34
+ <span className="dvw-workspace-title">{workspace.title}</span>
35
+ <span className="dvw-workspace-path">{workspace.path}</span>
36
+ <Icon name={expanded === workspace.id ? 'chevron-down' : 'chevron-right'}/>
37
+ </button>
38
+ <button className="dvw-icon dvw-workspace-enter" title="进入此工作区" onClick={() => onOpenWorkspace(workspace.id)}><Icon name="arrow-right"/></button>
39
+ </div>
40
+ {expanded === workspace.id && <div className="dvw-workspace-sessions">
41
+ {workspace.sessions.length === 0 ? <p className="dvw-workspace-empty">此工作区还没有会话</p> : workspace.sessions.map(session => <button key={session.id} className="dvw-workspace-session" data-active={session.id === activeSessionId} onClick={() => onSwitch(session.id)}>
42
+ <Icon name="comment-discussion"/>
43
+ <span className="dvw-workspace-session-title">{session.title}</span>
44
+ {session.id === activeSessionId && <span className="dvw-workspace-current">当前</span>}
45
+ </button>)}
46
+ </div>}
47
+ </div>)}
48
+ </div>
49
+ {error !== undefined && <p className="dvw-workspace-error">{error}</p>}
50
+ <footer><button className="dvw-primary-button" disabled={busy} onClick={onCreate}>{busy ? '正在打开…' : '新建工作区…'}</button><button onClick={onClose}>取消</button></footer>
51
+ </section>
52
+ </div>
53
+ }
package/src/client/api.ts CHANGED
@@ -2,7 +2,22 @@ export class ApiError extends Error {
2
2
  constructor(public code: string, message: string) { super(message) }
3
3
  }
4
4
 
5
- export async function api<T>(method: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
5
+ /** Back off while DSH publishes the current session after startup; every view shares this retry. */
6
+ export function sessionRetryDelay(error: unknown, attempt: number): number | undefined {
7
+ if (!(error instanceof ApiError) || error.code !== 'session-not-found' || attempt >= 10) return undefined
8
+ return Math.min(250 * (attempt + 1), 1000)
9
+ }
10
+
11
+ function wait(ms: number, signal?: AbortSignal): Promise<void> {
12
+ return new Promise((resolve, reject) => {
13
+ const abort = () => { clearTimeout(timer); reject(signal?.reason ?? new Error('aborted')) }
14
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve() }, ms)
15
+ if (signal?.aborted === true) { abort(); return }
16
+ signal?.addEventListener('abort', abort, { once: true })
17
+ })
18
+ }
19
+
20
+ async function request<T>(method: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
6
21
  const response = await fetch(`/dsh-vscode/api/${method}`, {
7
22
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal,
8
23
  })
@@ -10,3 +25,14 @@ export async function api<T>(method: string, body: Record<string, unknown>, sign
10
25
  if (!response.ok || !answer.ok) throw new ApiError(answer.error?.code ?? 'request-failed', answer.error?.message ?? `request failed (${response.status})`)
11
26
  return answer.value as T
12
27
  }
28
+
29
+ export async function api<T>(method: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
30
+ for (let attempt = 0; ; attempt++) {
31
+ try { return await request<T>(method, body, signal) }
32
+ catch (error) {
33
+ const delay = sessionRetryDelay(error, attempt)
34
+ if (delay === undefined) throw error
35
+ await wait(delay, signal)
36
+ }
37
+ }
38
+ }
@@ -1,6 +1,30 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
+ import { api } from './api.ts'
2
3
 
3
- export type DocumentPreviewKind = 'pdf' | 'docx' | 'download'
4
+ type XlsxModule = typeof import('xlsx')
5
+ let xlsxLoading: Promise<XlsxModule> | undefined
6
+
7
+ /** Load SheetJS from the host-hosted browser build; bundling it drags Node builtins into the client. */
8
+ export function loadXlsx(): Promise<XlsxModule> {
9
+ xlsxLoading ??= new Promise<XlsxModule>((resolve, reject) => {
10
+ const existing = (window as Window & { XLSX?: XlsxModule }).XLSX
11
+ if (existing !== undefined) { resolve(existing); return }
12
+ const script = document.createElement('script')
13
+ script.src = '/dsh-vscode/xlsx.js'
14
+ script.onload = () => {
15
+ const value = (window as Window & { XLSX?: XlsxModule }).XLSX
16
+ if (value === undefined) reject(new Error('SheetJS 加载失败')); else resolve(value)
17
+ }
18
+ script.onerror = () => reject(new Error('SheetJS 加载失败'))
19
+ document.head.append(script)
20
+ })
21
+ return xlsxLoading
22
+ }
23
+
24
+ export type DocumentPreviewKind = 'pdf' | 'office' | 'sheet' | 'download'
25
+
26
+ const SHEET_EXTENSIONS = new Set(['xlsx', 'xlsm', 'xls', 'ods'])
27
+ const OFFICE_EXTENSIONS = new Set(['doc', 'docx', 'ppt', 'pptx', 'odt', 'odp', 'rtf'])
4
28
 
5
29
  const MIN_PDF_SCALE = 0.5
6
30
  const MAX_PDF_SCALE = 3
@@ -14,11 +38,51 @@ export function clampPdfScale(scale: number): number {
14
38
  export function documentPreviewKind(path: string): DocumentPreviewKind | undefined {
15
39
  const extension = path.split('.').at(-1)?.toLowerCase()
16
40
  if (extension === 'pdf') return 'pdf'
17
- if (extension === 'docx') return 'docx'
18
- if (extension === 'doc' || extension === 'odt') return 'download'
41
+ if (extension !== undefined && SHEET_EXTENSIONS.has(extension)) return 'sheet'
42
+ if (extension !== undefined && OFFICE_EXTENSIONS.has(extension)) return 'office'
19
43
  return undefined
20
44
  }
21
45
 
46
+ /** Convert one worksheet matrix into display rows, keeping large books responsive. */
47
+ export function sheetTable(rows: readonly unknown[][], limits: { rows: number; columns: number } = { rows: 500, columns: 50 }): string[][] {
48
+ return rows.slice(0, limits.rows).map(row => (Array.isArray(row) ? row.slice(0, limits.columns) : []).map(cell => cell === null || cell === undefined ? '' : String(cell)))
49
+ }
50
+
51
+ function SheetsPreview({ src, path }: { src: string; path: string }) {
52
+ const [book, setBook] = useState<{ names: string[]; tables: Record<string, string[][]> }>()
53
+ const [active, setActive] = useState('')
54
+ const [error, setError] = useState<string>()
55
+ useEffect(() => {
56
+ const controller = new AbortController()
57
+ setBook(undefined); setError(undefined)
58
+ void (async () => {
59
+ try {
60
+ const response = await fetch(src, { signal: controller.signal })
61
+ if (!response.ok) throw new Error(`无法读取文件 (${response.status})`)
62
+ const buffer = await response.arrayBuffer()
63
+ const xlsx = await loadXlsx()
64
+ if (controller.signal.aborted) return
65
+ const workbook = xlsx.read(buffer, { type: 'array' })
66
+ const tables: Record<string, string[][]> = {}
67
+ for (const name of workbook.SheetNames) {
68
+ tables[name] = sheetTable(xlsx.utils.sheet_to_json<unknown[]>(workbook.Sheets[name]!, { header: 1, defval: '', blankrows: false, raw: false }))
69
+ }
70
+ setBook({ names: workbook.SheetNames, tables })
71
+ setActive(workbook.SheetNames[0] ?? '')
72
+ } catch (cause) {
73
+ if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause))
74
+ }
75
+ })()
76
+ return () => controller.abort()
77
+ }, [src])
78
+ if (error !== undefined) return <div className="dvw-document-error">无法预览表格:{error}</div>
79
+ const rows = book?.tables[active] ?? []
80
+ return <section className="dvw-sheet-preview" aria-label={`${path} 表格预览`}>
81
+ {(book?.names.length ?? 0) > 1 && <div className="dvw-sheet-tabs">{book!.names.map(name => <button key={name} data-active={name === active} onClick={() => setActive(name)}>{name}</button>)}</div>}
82
+ <div className="dvw-sheet-scroll">{book === undefined ? <div className="dvw-message">正在加载表格…</div> : rows.length === 0 ? <div className="dvw-message">此工作表为空</div> : <table className="dvw-sheet-table"><tbody>{rows.map((row, index) => <tr key={index}>{row.map((cell, column) => <td key={column} title={cell}>{cell}</td>)}</tr>)}</tbody></table>}</div>
83
+ </section>
84
+ }
85
+
22
86
  function DocxPreview({ src }: { src: string }) {
23
87
  const root = useRef<HTMLDivElement>(null)
24
88
  const [error, setError] = useState<string>()
@@ -114,9 +178,32 @@ function PdfPreview({ src, path }: { src: string; path: string }) {
114
178
  </section>
115
179
  }
116
180
 
117
- /** Render an in-workbench preview for PDF and Word documents. */
181
+ /** Session carried by the shared file URL, used for the converter status probe. */
182
+ function sessionOfDocumentUrl(src: string): string | undefined {
183
+ try { return new URL(src, location.href).searchParams.get('sessionId') ?? undefined } catch { return undefined }
184
+ }
185
+
186
+ /** Word and PowerPoint open as a PDF converted on the host, matching DSH's own preview. */
187
+ function OfficePreview({ src, path }: { src: string; path: string }) {
188
+ const [status, setStatus] = useState<{ available: boolean; hint?: string }>()
189
+ useEffect(() => {
190
+ const sessionId = sessionOfDocumentUrl(src)
191
+ if (sessionId === undefined) { setStatus({ available: false }); return }
192
+ let cancelled = false
193
+ void api<{ available: boolean; hint?: string }>('office.status', { sessionId }).then(value => { if (!cancelled) setStatus(value) }).catch(() => { if (!cancelled) setStatus({ available: false }) })
194
+ return () => { cancelled = true }
195
+ }, [src])
196
+ if (status === undefined) return <div className="dvw-message">正在准备 Office 预览…</div>
197
+ if (status.available) return <PdfPreview src={src.replace('/dsh-vscode/file?', '/dsh-vscode/office?')} path={path}/>
198
+ // Without LibreOffice, newer formats still render in the browser; legacy binaries fall back to a download.
199
+ const extension = path.split('.').at(-1)?.toLowerCase()
200
+ if (extension === 'docx') return <DocxPreview src={src}/>
201
+ return <div className="dvw-document-download"><strong>需要 LibreOffice 才能预览此格式</strong><span>{status.hint}</span><a href={src} target="_blank" rel="noreferrer">下载 {path}</a></div>
202
+ }
203
+
118
204
  export function DocumentPreview({ kind, src, path }: { kind: DocumentPreviewKind; src: string; path: string }) {
119
205
  if (kind === 'pdf') return <PdfPreview src={src} path={path}/>
120
- if (kind === 'docx') return <DocxPreview src={src}/>
206
+ if (kind === 'office') return <OfficePreview src={src} path={path}/>
207
+ if (kind === 'sheet') return <SheetsPreview src={src} path={path}/>
121
208
  return <div className="dvw-document-download"><strong>此格式无法在浏览器中可靠渲染</strong><a href={src} target="_blank" rel="noreferrer">打开 {path}</a></div>
122
209
  }
@@ -0,0 +1,73 @@
1
+ /** Selected editor text, with 1-based inclusive line numbers. */
2
+ export interface EditorSelection { text: string; startLine: number; endLine: number }
3
+
4
+ /** Explorer and editor shortcut that adds the current file or selection to the DSH chat. */
5
+ export const ADD_TO_DSH_SHORTCUT = 'Ctrl+Alt+K'
6
+
7
+ /** True for the add-to-DSH chord; Monaco binds it separately, so this covers the Explorer. */
8
+ export function isAddToDshShortcut(event: Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey' | 'code' | 'isComposing'>): boolean {
9
+ return !event.isComposing && (event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey && event.code === 'KeyK'
10
+ }
11
+
12
+ /** Workspace-relative reference the agent can read: `path`, `dir/`, or `path:start-end` plus a fenced copy of the selection. */
13
+ export function dshReference(path: string, options: { directory?: boolean; selection?: EditorSelection } = {}): string {
14
+ const { directory, selection } = options
15
+ if (selection === undefined || selection.text === '') return `\`${directory ? `${path}/` : path}\``
16
+ const lines = selection.startLine === selection.endLine ? `${selection.startLine}` : `${selection.startLine}-${selection.endLine}`
17
+ const language = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase() ?? ''
18
+ const fence = selection.text.includes('```') ? '````' : '```'
19
+ return `\`${path}:${lines}\`\n${fence}${language}\n${selection.text.replace(/\n$/, '')}\n${fence}\n`
20
+ }
21
+
22
+ /** Separator that keeps an inserted reference from gluing onto the existing draft. */
23
+ export function referenceSeparator(draft: string, reference: string): string {
24
+ if (draft === '' || /\s$/.test(draft)) return ''
25
+ return reference.includes('\n') ? '\n' : ' '
26
+ }
27
+
28
+ // DSH Desktop renders the draft as a Lexical contenteditable (`data-composer-input`); older web builds use a textarea.
29
+ const COMPOSER = '[data-composer-input],textarea'
30
+
31
+ /** The DSH composer input, preferring the copy shown in the workbench's right column. */
32
+ export function findDshComposer(documentRoot: Document = document): HTMLElement | null {
33
+ return documentRoot.querySelector<HTMLElement>(`[data-dvw-conversation-panel] [data-composer-card] :is(${COMPOSER})`)
34
+ ?? documentRoot.querySelector<HTMLElement>(`[data-composer-card] :is(${COMPOSER})`)
35
+ }
36
+
37
+ function composerEditable(input: HTMLElement): boolean {
38
+ if (input instanceof HTMLTextAreaElement) return !input.disabled && !input.readOnly
39
+ return input.isContentEditable || input.getAttribute('contenteditable') === 'true'
40
+ }
41
+
42
+ function composerText(input: HTMLElement): string { return input instanceof HTMLTextAreaElement ? input.value : (input.textContent ?? '') }
43
+
44
+ const nextFrame = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
45
+
46
+ /** Append `reference` to the DSH draft through native editing so DSH's input machine and undo stack see it. */
47
+ export async function insertIntoDshComposer(reference: string, documentRoot: Document = document): Promise<boolean> {
48
+ const input = findDshComposer(documentRoot)
49
+ if (input === null || !composerEditable(input)) return false
50
+ const text = referenceSeparator(composerText(input), reference) + (reference.endsWith('\n') ? reference : `${reference} `)
51
+ input.focus()
52
+ if (input instanceof HTMLTextAreaElement) {
53
+ input.setSelectionRange(input.value.length, input.value.length)
54
+ if (typeof documentRoot.execCommand === 'function' && documentRoot.execCommand('insertText', false, text)) return true
55
+ // execCommand is unavailable: set the value through the native setter so React's onChange still fires.
56
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
57
+ setter?.call(input, input.value + text)
58
+ input.dispatchEvent(new Event('input', { bubbles: true }))
59
+ return true
60
+ }
61
+ const selection = documentRoot.getSelection()
62
+ const range = documentRoot.createRange(); range.selectNodeContents(input); range.collapse(false)
63
+ selection?.removeAllRanges(); selection?.addRange(range)
64
+ // Lexical adopts the DOM caret on selectionchange; paste only after it has.
65
+ await nextFrame()
66
+ // Lexical's paste handler keeps line breaks and records one undo step; insertText is the fallback.
67
+ if (typeof DataTransfer === 'function' && typeof ClipboardEvent === 'function') {
68
+ const data = new DataTransfer(); data.setData('text/plain', text)
69
+ const paste = new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })
70
+ if (!input.dispatchEvent(paste)) return true
71
+ }
72
+ return typeof documentRoot.execCommand === 'function' && documentRoot.execCommand('insertText', false, text)
73
+ }
@@ -0,0 +1,81 @@
1
+ /** Access to DSH's client-side workspace and session stores for the in-workbench switcher. */
2
+ export interface WorkspaceChoice {
3
+ id: string
4
+ title: string
5
+ path: string
6
+ sessions: SessionChoice[]
7
+ }
8
+ export interface SessionChoice {
9
+ id: string
10
+ title: string
11
+ blank: boolean
12
+ active: boolean
13
+ }
14
+ interface SessionSummary {
15
+ title?: string
16
+ blank?: boolean
17
+ projectionValues?: { title?: string }
18
+ }
19
+ interface WorkspacesSnapshot {
20
+ items?: readonly { workspaceId: string; title: string; path: string; sessionIds: readonly string[] }[]
21
+ phase?: string
22
+ }
23
+ interface SessionsSnapshot {
24
+ ids: readonly string[]
25
+ byId: Record<string, SessionSummary>
26
+ phase?: string
27
+ }
28
+ interface SnapshotStore<T> { getSnapshot(): T }
29
+ interface UiWorkspace {
30
+ openSession(id: string): void
31
+ openWorkspace?(id: string, beforeOpen: (nextId: string | undefined) => void, options?: Record<string, unknown>): unknown
32
+ }
33
+
34
+ /** The slice of DSH's client context the switcher needs, read through `ctx.get` like ui-conversation does. */
35
+ export interface DshNavigation {
36
+ available: boolean
37
+ choices(activeSessionId?: string): WorkspaceChoice[]
38
+ openSession(id: string): boolean
39
+ openWorkspace(id: string): boolean
40
+ }
41
+
42
+ /** Build display rows from the two snapshot stores, keeping DSH's own workspace and session order. */
43
+ export function buildWorkspaceChoices(workspaces: WorkspacesSnapshot | undefined, sessions: SessionsSnapshot | undefined, activeSessionId?: string): WorkspaceChoice[] {
44
+ if (workspaces?.items === undefined || sessions === undefined) return []
45
+ return workspaces.items.map(workspace => ({
46
+ id: workspace.workspaceId,
47
+ title: workspace.title,
48
+ path: workspace.path,
49
+ sessions: workspace.sessionIds.flatMap(id => {
50
+ const summary = sessions.byId[id]
51
+ if (summary === undefined) return []
52
+ return [{ id, title: summary.title ?? summary.projectionValues?.title ?? '未命名会话', blank: summary.blank === true, active: id === activeSessionId }]
53
+ }),
54
+ }))
55
+ }
56
+
57
+ /** Resolve the DSH client services; `get` returns undefined outside the web/desktop client. */
58
+ export function createDshNavigation(get: ((name: string) => unknown) | undefined): DshNavigation {
59
+ const look = (name: string): unknown => { try { return get?.(name) } catch { return undefined } }
60
+ const workspaces = () => look('workspaces') as { list?: SnapshotStore<WorkspacesSnapshot> } | undefined
61
+ const sessions = () => look('sessions') as { list?: SnapshotStore<SessionsSnapshot> } | undefined
62
+ const uiWorkspace = () => look('uiWorkspace') as UiWorkspace | undefined
63
+ return {
64
+ get available() { return workspaces()?.list !== undefined && sessions()?.list !== undefined },
65
+ choices(activeSessionId) {
66
+ return buildWorkspaceChoices(workspaces()?.list?.getSnapshot(), sessions()?.list?.getSnapshot(), activeSessionId)
67
+ },
68
+ openSession(id) {
69
+ const navigation = uiWorkspace()
70
+ if (navigation === undefined) return false
71
+ navigation.openSession(id)
72
+ return true
73
+ },
74
+ openWorkspace(id) {
75
+ const navigation = uiWorkspace()
76
+ if (navigation?.openWorkspace === undefined) return false
77
+ void navigation.openWorkspace(id, () => undefined)
78
+ return true
79
+ },
80
+ }
81
+ }
@@ -0,0 +1,7 @@
1
+ import { getIconForFile, getIconForFolder, getIconForOpenFolder } from 'vscode-icons-js'
2
+
3
+ /** Resolve the vscode-icons asset name for a tree entry or tab. */
4
+ export function fileIconName(name: string, directory: boolean, expanded: boolean): string {
5
+ if (directory) return (expanded ? getIconForOpenFolder(name) : getIconForFolder(name)) ?? (expanded ? 'default_folder_opened.svg' : 'default_folder.svg')
6
+ return getIconForFile(name) ?? 'default_file.svg'
7
+ }
@@ -0,0 +1,5 @@
1
+ /** Normalize a Git/API error into the sentence shown in the branch-switch dialog. */
2
+ export function branchErrorMessage(error: unknown): string {
3
+ const message = error instanceof Error ? error.message : String(error)
4
+ return message.replace(/^(?:Error:\s*)?(?:error:\s*)?/i, '').trim() || '无法切换分支。'
5
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Context as ClientContext } from '@deepseek-ai/cordis'
2
- import { useEffect, useLayoutEffect, useState } from 'react'
3
- import { createPortal } from 'react-dom'
2
+ import { useLayoutEffect } from 'react'
4
3
  import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
4
+ import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
5
5
  import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
6
6
  import type {} from '@deepseek-ai/dsh-client-ui-session/client'
7
7
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -10,37 +10,51 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
10
10
  import type { VscodeWorkbench } from './service.ts'
11
11
  import { WorkbenchController } from './service.ts'
12
12
  import { TerminalPanel } from './TerminalPanel.tsx'
13
- import { Launcher, Workbench } from './Workbench.tsx'
13
+ import { Workbench } from './Workbench.tsx'
14
14
  import { installStyles } from './styles.ts'
15
15
  import { handleTerminalShortcut } from './shortcuts.ts'
16
16
  import { mainViewSession } from './session.ts'
17
+ import { createDshNavigation } from './dshNavigation.ts'
18
+ import type { DshNavigation } from './dshNavigation.ts'
17
19
 
18
20
  export type { BottomPanelContribution, BottomPanelProps, VscodeWorkbench, WorkbenchCommand, WorkbenchSnapshot, WorkbenchView } from './service.ts'
19
21
 
20
22
  declare module '@deepseek-ai/cordis' { interface Context { vscodeWorkbench: VscodeWorkbench } }
21
23
 
22
- const Overlay = ({ service, useSessions }: PropsRuntime<'shell.overlay'> & { service: VscodeWorkbench }) => {
24
+ const Overlay = ({ service, useSessions, navigation }: PropsRuntime<'shell.overlay'> & { service: VscodeWorkbench; navigation: DshNavigation }) => {
23
25
  const session = useSessions(mainViewSession)
24
26
  const cwd = session?.projectionValues?.workingDirectory ?? session?.cwd
25
- return <Workbench key={`${session?.id ?? 'no-session'}:${cwd ?? ''}`} service={service} sessionId={session?.id} cwd={cwd}/>
27
+ return <Workbench key={cwd ?? 'no-workspace'} service={service} sessionId={session?.id} cwd={cwd} navigation={navigation}/>
26
28
  }
27
- const Footer = ({ service }: PropsRuntime<'sidebar.footer.action'> & { service: VscodeWorkbench }) => {
28
- const [target, setTarget] = useState<HTMLElement>()
29
- const [visible, setVisible] = useState(() => service.getSnapshot().visible)
30
- useLayoutEffect(() => { const mount = document.createElement('div'); mount.className = 'dvw-launcher-mount'; document.body.append(mount); setTarget(mount); return () => mount.remove() }, [])
31
- useEffect(() => service.subscribe(() => setVisible(service.getSnapshot().visible)), [service])
32
- return target === undefined || visible ? null : createPortal(<Launcher service={service}/>, target)
29
+
30
+ const WORKBENCH_PANEL_ID = 'vscode-workbench' as MainPanelId
31
+
32
+ /** Panel-list icon; the panel itself opens the overlay and hands the centre back to the conversation. */
33
+ function WorkbenchPanelIcon({ size }: { size: number }) {
34
+ return <span className="codicon codicon-code" aria-hidden="true" style={{ fontSize: size, lineHeight: 1 }}/>
35
+ }
36
+
37
+ function WorkbenchPanel({ service, restoreConversation }: { service: VscodeWorkbench; restoreConversation(): void }) {
38
+ useLayoutEffect(() => { service.show(); restoreConversation() }, [service, restoreConversation])
39
+ return null
33
40
  }
34
41
 
35
- export const inject = ['slots']
42
+ export const inject = ['slots', 'layout']
36
43
  export function apply(ctx: ClientContext): void {
37
44
  const service = new WorkbenchController()
45
+ // Optional access to DSH's own workspace/session stores, used by the centered workspace switcher.
46
+ const navigation = createDshNavigation((name) => (ctx as unknown as { get?: (name: string) => unknown }).get?.(name))
38
47
  ctx.effect(() => installStyles(), 'dsh-vscode-workbench: styles')
39
48
  ctx.effect(() => { const dispose = ctx.reflect.provide('vscodeWorkbench', service); return () => { void dispose() } }, 'dsh-vscode-workbench: service')
40
49
  ctx.effect(() => service.registerBottomPanel({ id: 'terminal', title: '终端', component: TerminalPanel }), 'dsh-vscode-workbench: terminal panel')
41
50
  ctx.effect(() => service.registerCommand({ id: 'terminal.new', run() { window.dispatchEvent(new Event('dvw-terminal-new')) } }), 'dsh-vscode-workbench: terminal command')
42
- ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'dsh-vscode-workbench-launcher', inject: () => ({ service }) }, Footer))
43
- ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-vscode-workbench-overlay', inject: () => ({ service }) }, Overlay))
51
+ const restoreConversation = () => { ctx.layout?.selectPanel(null) }
52
+ ctx.slots.inject('main', () => ctx.slots.inject('sidebar.panellist', () => {
53
+ const disposeMain = ctx.slots.register({ name: 'main', key: WORKBENCH_PANEL_ID, inject: () => ({ service, restoreConversation }) }, WorkbenchPanel)
54
+ const disposeIcon = ctx.slots.register({ name: 'sidebar.panellist', id: WORKBENCH_PANEL_ID, order: 60, label: () => 'VS Code 工作台' }, WorkbenchPanelIcon)
55
+ return () => { disposeIcon(); disposeMain() }
56
+ }))
57
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-vscode-workbench-overlay', inject: () => ({ service, navigation }) }, Overlay))
44
58
  ctx.effect(() => {
45
59
  const onKey = (event: KeyboardEvent) => {
46
60
  if (handleTerminalShortcut(event, service)) return