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.
- package/README.en.md +4 -0
- package/README.md +8 -2
- package/lib/client.js +5014 -674
- package/lib/client.js.map +1 -1
- package/lib/index.js +832 -80
- package/lib/index.js.map +1 -1
- package/lib/types/client/ActivityBar.d.ts +24 -0
- package/lib/types/client/DshConversationPanel.d.ts +1 -1
- package/lib/types/client/FileIcon.d.ts +8 -0
- package/lib/types/client/Icon.d.ts +1 -3
- package/lib/types/client/MonacoEditor.d.ts +36 -0
- package/lib/types/client/Workbench.d.ts +19 -7
- package/lib/types/client/WorkspaceSwitcher.d.ts +13 -0
- package/lib/types/client/api.d.ts +2 -0
- package/lib/types/client/documentPreview.d.ts +10 -2
- package/lib/types/client/dshComposer.d.ts +21 -0
- package/lib/types/client/dshNavigation.d.ts +46 -0
- package/lib/types/client/fileIconMap.d.ts +2 -0
- package/lib/types/client/gitError.d.ts +2 -0
- package/lib/types/client/lsp.d.ts +27 -0
- package/lib/types/client/searchHighlight.d.ts +5 -0
- package/lib/types/client/typescriptProject.d.ts +20 -0
- package/lib/types/context.d.ts +15 -0
- package/lib/types/host.d.ts +37 -0
- package/lib/types/lsp.d.ts +54 -0
- package/lib/types/office.d.ts +17 -0
- package/lib/types/types.d.ts +5 -0
- package/lib/types/watch-ignore.d.ts +4 -0
- package/package.json +5 -2
- package/src/client/ActivityBar.tsx +152 -0
- package/src/client/DshConversationPanel.tsx +15 -12
- package/src/client/FileIcon.tsx +6 -0
- package/src/client/Icon.tsx +1 -14
- package/src/client/MonacoEditor.tsx +187 -19
- package/src/client/TerminalPanel.tsx +6 -3
- package/src/client/Workbench.tsx +324 -81
- package/src/client/WorkspaceSwitcher.tsx +53 -0
- package/src/client/api.ts +27 -1
- package/src/client/documentPreview.tsx +92 -5
- package/src/client/dshComposer.ts +73 -0
- package/src/client/dshNavigation.ts +81 -0
- package/src/client/fileIconMap.ts +7 -0
- package/src/client/gitError.ts +5 -0
- package/src/client/index.tsx +28 -14
- package/src/client/lsp.ts +300 -0
- package/src/client/searchHighlight.ts +6 -0
- package/src/client/styles.ts +28 -31
- package/src/client/typescriptProject.ts +81 -0
- package/src/context.ts +8 -0
- package/src/host.ts +100 -15
- package/src/index.ts +121 -17
- package/src/lsp.ts +170 -0
- package/src/office.ts +95 -0
- package/src/types.ts +2 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
if (extension
|
|
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
|
-
/**
|
|
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 === '
|
|
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
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
2
|
-
import {
|
|
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 {
|
|
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={
|
|
27
|
+
return <Workbench key={cwd ?? 'no-workspace'} service={service} sessionId={session?.id} cwd={cwd} navigation={navigation}/>
|
|
26
28
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return
|
|
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
|
-
|
|
43
|
-
ctx.slots.inject('
|
|
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
|