dsh-vscode-workbench 0.1.11 → 0.1.12
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 +21 -2
- package/README.md +21 -2
- package/lib/client.js +293 -104
- package/lib/client.js.map +1 -1
- package/lib/index.js +93 -41
- package/lib/index.js.map +1 -1
- package/lib/types/client/Workbench.d.ts +3 -0
- package/lib/types/client/terminal.d.ts +1 -0
- package/lib/types/client/websocket.d.ts +15 -0
- package/lib/types/terminal.d.ts +5 -0
- package/package.json +1 -1
- package/src/client/DshConversationPanel.tsx +2 -0
- package/src/client/TerminalPanel.tsx +12 -5
- package/src/client/Workbench.tsx +140 -15
- package/src/client/styles.ts +9 -4
- package/src/client/terminal.ts +15 -4
- package/src/client/websocket.ts +50 -0
- package/src/host.ts +47 -14
- package/src/terminal.ts +27 -2
package/lib/types/terminal.d.ts
CHANGED
|
@@ -25,9 +25,14 @@ export declare function defaultTerminalShell(platform?: NodeJS.Platform, env?: N
|
|
|
25
25
|
shell: string;
|
|
26
26
|
args: string[];
|
|
27
27
|
};
|
|
28
|
+
/** Add execute bits when a registry package extracted a native helper without them. */
|
|
29
|
+
export declare function ensureExecutable(path: string): boolean;
|
|
30
|
+
/** Repair node-pty's macOS/Linux helper, whose npm package currently loses its execute bit. */
|
|
31
|
+
export declare function repairNodePtyHelper(): void;
|
|
28
32
|
/** Own interactive terminals by their canonical workspace directory. */
|
|
29
33
|
export declare class TerminalManager {
|
|
30
34
|
private readonly workspaces;
|
|
35
|
+
constructor();
|
|
31
36
|
private workspace;
|
|
32
37
|
list(cwd: string): TerminalInfo[];
|
|
33
38
|
create(cwd: string, title?: string): TerminalInfo;
|
package/package.json
CHANGED
|
@@ -24,10 +24,12 @@ export function DshConversationPanel({ visible, width, statusHeight }: { visible
|
|
|
24
24
|
attached?.style.setProperty('--dvw-status-height', `${statusHeight}px`)
|
|
25
25
|
}
|
|
26
26
|
attach()
|
|
27
|
+
document.body.setAttribute('data-dvw-conversation-active', '')
|
|
27
28
|
const observer = new MutationObserver(attach)
|
|
28
29
|
observer.observe(document.body, { childList: true, subtree: true })
|
|
29
30
|
return () => {
|
|
30
31
|
observer.disconnect()
|
|
32
|
+
document.body.removeAttribute('data-dvw-conversation-active')
|
|
31
33
|
attached?.removeAttribute('data-dvw-conversation-panel')
|
|
32
34
|
attached?.style.removeProperty('--dvw-chat-width')
|
|
33
35
|
attached?.style.removeProperty('--dvw-status-height')
|
|
@@ -20,17 +20,24 @@ function XtermView({ workspace, id, colorScheme }: { workspace: TerminalWorkspac
|
|
|
20
20
|
const fit = new FitAddon()
|
|
21
21
|
terminal.loadAddon(fit)
|
|
22
22
|
terminal.open(root.current)
|
|
23
|
-
|
|
23
|
+
let connection
|
|
24
|
+
try { connection = workspace.connect(id) }
|
|
25
|
+
catch (exception) {
|
|
26
|
+
setError(exception instanceof Error ? exception.message : String(exception))
|
|
27
|
+
terminal.dispose()
|
|
28
|
+
return
|
|
29
|
+
}
|
|
24
30
|
const output = connection.subscribe(value => {
|
|
25
31
|
if (value.replay) { replaying.current = true; clearTimeout(replayGuard.current) }
|
|
26
32
|
terminal.write(value.data, () => { if (value.replay) replayGuard.current = setTimeout(() => { replaying.current = false }, 250) })
|
|
27
33
|
})
|
|
34
|
+
const connectionError = connection.subscribeError(setError)
|
|
28
35
|
const input = terminal.onData(data => { if (!replaying.current || !isTerminalProtocolReply(data)) connection.write(data) })
|
|
29
36
|
const resize = terminal.onResize(({ cols, rows }) => connection.resize(cols, rows))
|
|
30
37
|
const observer = new ResizeObserver(() => { try { fit.fit() } catch { /* The panel can be hidden during layout changes. */ } })
|
|
31
38
|
observer.observe(root.current)
|
|
32
39
|
try { fit.fit(); terminal.focus() } catch (exception) { setError(exception instanceof Error ? exception.message : String(exception)) }
|
|
33
|
-
return () => { terminalRef.current = undefined; clearTimeout(replayGuard.current); observer.disconnect(); input.dispose(); resize.dispose(); output(); connection.close(); terminal.dispose() }
|
|
40
|
+
return () => { terminalRef.current = undefined; clearTimeout(replayGuard.current); observer.disconnect(); connectionError(); input.dispose(); resize.dispose(); output(); connection.close(); terminal.dispose() }
|
|
34
41
|
}, [workspace, id])
|
|
35
42
|
useEffect(() => { if (terminalRef.current !== undefined) terminalRef.current.options.theme = terminalTheme(colorScheme) }, [colorScheme])
|
|
36
43
|
return error === undefined ? <div ref={root} className="dvw-terminal-host" /> : <div className="dvw-terminal-error">{error}</div>
|
|
@@ -49,11 +56,11 @@ export function TerminalPanel({ sessionId, colorScheme }: BottomPanelProps) {
|
|
|
49
56
|
const [sideWidth, setSideWidth] = useState(220)
|
|
50
57
|
useEffect(() => {
|
|
51
58
|
let cancelled = false
|
|
52
|
-
void workspace.refresh().then(() => { if (!cancelled && workspace.getSnapshot().terminals.length === 0) void workspace.create().then(value => { if (!cancelled) setActive(value.id) }) })
|
|
59
|
+
void workspace.refresh().then(() => { if (!cancelled && workspace.getSnapshot().terminals.length === 0) void workspace.create().then(value => { if (!cancelled) setActive(value.id) }).catch(() => undefined) }).catch(() => undefined)
|
|
53
60
|
return () => { cancelled = true }
|
|
54
61
|
}, [workspace])
|
|
55
62
|
useEffect(() => { if (snapshot.terminals.length > 0 && (!active || !snapshot.terminals.some(value => value.id === active))) setActive(snapshot.terminals[0]?.id) }, [snapshot.terminals, active])
|
|
56
|
-
useEffect(() => { const create = () => { void workspace.create().then(value => setActive(value.id)) }; window.addEventListener('dvw-terminal-new', create); return () => window.removeEventListener('dvw-terminal-new', create) }, [workspace])
|
|
63
|
+
useEffect(() => { const create = () => { void workspace.create().then(value => setActive(value.id)).catch(() => undefined) }; window.addEventListener('dvw-terminal-new', create); return () => window.removeEventListener('dvw-terminal-new', create) }, [workspace])
|
|
57
64
|
useEffect(() => {
|
|
58
65
|
if (context === undefined) return
|
|
59
66
|
const close = () => setContext(undefined)
|
|
@@ -64,7 +71,7 @@ export function TerminalPanel({ sessionId, colorScheme }: BottomPanelProps) {
|
|
|
64
71
|
}, [context])
|
|
65
72
|
const closeTerminal = (id: string) => { setContext(undefined); setSplit(current => current?.filter(value => value !== id)); void workspace.close(id) }
|
|
66
73
|
const saveRename = (id: string) => { const title = renaming?.id === id ? renaming.value.trim() : ''; setRenaming(undefined); if (title) void workspace.rename(id, title) }
|
|
67
|
-
const splitTerminal = (first = active) => { if (first === undefined) return; void workspace.create().then(value => { setSplit(current => [...(current?.includes(first) ? current : [first]), value.id]); setActive(value.id) }) }
|
|
74
|
+
const splitTerminal = (first = active) => { if (first === undefined) return; void workspace.create().then(value => { setSplit(current => [...(current?.includes(first) ? current : [first]), value.id]); setActive(value.id) }).catch(() => undefined) }
|
|
68
75
|
const shown = (split ?? (active === undefined ? [] : [active])).filter(id => snapshot.terminals.some(value => value.id === id))
|
|
69
76
|
const selectTerminal = (id: string) => { setActive(id); setSplit(current => current?.includes(id) ? current : [id]) }
|
|
70
77
|
return <div className="dvw-terminal-root">
|
package/src/client/Workbench.tsx
CHANGED
|
@@ -11,9 +11,10 @@ import { DshConversationPanel, DshConversationTheme } from './DshConversationPan
|
|
|
11
11
|
import { DshMark } from './DshMark.tsx'
|
|
12
12
|
import { DocumentPreview, documentPreviewKind, type DocumentPreviewKind } from './documentPreview.tsx'
|
|
13
13
|
import { toggleTerminal } from './shortcuts.ts'
|
|
14
|
+
import { websocketUrl } from './websocket.ts'
|
|
14
15
|
import { resetWorkspaceDocuments, WorkspaceRequestScope } from './workspaceState.ts'
|
|
15
16
|
|
|
16
|
-
interface Tab extends FileDocument { dirty: boolean; draft: string; loaded?: boolean; line?: number; column?: number; external?: boolean; preview?: boolean; title?: string; diff?: GitDiff; diffMode?: 'inline' | 'split'; diffSourcePath?:string; imageUrl?:string; documentPreview?:DocumentPreviewKind; documentUrl?:string }
|
|
17
|
+
interface Tab extends FileDocument { instance?: symbol; dirty: boolean; draft: string; loaded?: boolean; line?: number; column?: number; external?: boolean; preview?: boolean; title?: string; diff?: GitDiff; diffMode?: 'inline' | 'split'; diffSourcePath?:string; imageUrl?:string; documentPreview?:DocumentPreviewKind; documentUrl?:string }
|
|
17
18
|
type WorkbenchTheme = 'dsh' | 'dark' | 'light'
|
|
18
19
|
interface TreeProps { sessionId: string; path: string; depth: number; expanded: Set<string>; selected?: string; selectedDirectory: string; statuses: Map<string, string>; onToggle(path: string): void; onSelectDirectory(path: string): void; onOpen(path: string): void; onChange(): void; onUpload(directory: string, files: FileList): void }
|
|
19
20
|
interface CreatingEntry { kind: 'file' | 'directory'; directory: string; value: string; error?: string }
|
|
@@ -25,6 +26,12 @@ export function resizedPanelWidth(startWidth: number, startX: number, nextX: num
|
|
|
25
26
|
return Math.max(min, Math.min(max, Math.round(startWidth + (nextX - startX) * direction)))
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/** Retry a workspace lookup that raced the Host publishing its session after startup. */
|
|
30
|
+
export function workspaceRetryDelay(error: unknown, attempt: number): number | undefined {
|
|
31
|
+
if (!(error instanceof ApiError) || error.code !== 'session-not-found' || attempt >= 10) return undefined
|
|
32
|
+
return Math.min(250 * (attempt + 1), 1000)
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
function PanelResizer({ className, label, width, min, max, defaultWidth, direction, onWidth }: { className: string; label: string; width: number; min: number; max: number; defaultWidth: number; direction: ResizeDirection; onWidth(width: number): void }) {
|
|
29
36
|
const [dragging, setDragging] = useState(false)
|
|
30
37
|
const stopDragging = useRef<() => void>(() => {})
|
|
@@ -72,7 +79,11 @@ function PanelResizer({ className, label, width, min, max, defaultWidth, directi
|
|
|
72
79
|
}}/>
|
|
73
80
|
}
|
|
74
81
|
|
|
75
|
-
function unloadedTab(path:string):Tab{return {path,content:'',draft:'',revision:'',size:0,binary:false,tooLarge:false,dirty:false,loaded:false,preview:/\.md$/i.test(path)}}
|
|
82
|
+
function unloadedTab(path:string):Tab{return {instance:Symbol(path),path,content:'',draft:'',revision:'',size:0,binary:false,tooLarge:false,dirty:false,loaded:false,preview:/\.md$/i.test(path)}}
|
|
83
|
+
|
|
84
|
+
function WorkbenchTitlebar({ name }: { name?: string }) {
|
|
85
|
+
return <div className="dvw-titlebar"><strong>DSH Workbench</strong>{name !== undefined && <span>{name}</span>}</div>
|
|
86
|
+
}
|
|
76
87
|
|
|
77
88
|
/** Restore tab headers without reading their file contents. */
|
|
78
89
|
export function restoreTabPaths(paths:string[]):Tab[]{return [...new Set(paths)].map(unloadedTab)}
|
|
@@ -100,10 +111,10 @@ function useDshDarkTheme(): boolean {
|
|
|
100
111
|
|
|
101
112
|
function Tree({ sessionId, path, depth, expanded, selected, selectedDirectory, statuses, onToggle, onSelectDirectory, onOpen, onChange, onUpload, creating, onCreatingChange, onCreatingCancel, onCreatingSubmit }: TreeProps & { creating?: CreatingEntry; onCreatingChange(value: string): void; onCreatingCancel(): void; onCreatingSubmit(): void }) {
|
|
102
113
|
const [rows, setRows] = useState<FileEntry[]>([]); const [error, setError] = useState<string>(); const [dropTarget, setDropTarget] = useState(false); const [context, setContext] = useState<{ entry: FileEntry; x: number; y: number }>(); const [renaming, setRenaming] = useState<{ path: string; value: string }>(); const [pendingDelete, setPendingDelete] = useState<FileEntry>()
|
|
103
|
-
const load = useCallback(() => { void api<FileEntry[]>('fs.list', { sessionId, path }).then(setRows).catch(error => setError(String(error))) }, [sessionId, path])
|
|
114
|
+
const load = useCallback(() => { void api<FileEntry[]>('fs.list', { sessionId, path }).then(value => { setRows(value); setError(undefined) }).catch(error => setError(String(error))) }, [sessionId, path])
|
|
104
115
|
useEffect(load, [load])
|
|
105
116
|
useEffect(() => {
|
|
106
|
-
const handler = (event:Event) => { const paths=(event as CustomEvent<string[]>).detail; if(paths
|
|
117
|
+
const handler = (event:Event) => { const paths=(event as CustomEvent<string[]>).detail; if(!Array.isArray(paths)||directoryAffected(path,paths))load() }; window.addEventListener('dvw-files-changed', handler); return () => window.removeEventListener('dvw-files-changed', handler)
|
|
107
118
|
}, [load])
|
|
108
119
|
useEffect(() => { if (context === undefined) return; const close = () => setContext(undefined); const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') close() }; window.addEventListener('pointerdown', close); window.addEventListener('keydown', escape); return () => { window.removeEventListener('pointerdown', close); window.removeEventListener('keydown', escape) } }, [context])
|
|
109
120
|
useEffect(() => { if (pendingDelete === undefined) return; const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') setPendingDelete(undefined) }; window.addEventListener('keydown', escape); return () => window.removeEventListener('keydown', escape) }, [pendingDelete])
|
|
@@ -284,7 +295,7 @@ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbenc
|
|
|
284
295
|
useEffect(() => service.subscribe(() => { setSnapshot(service.getSnapshot()); setPanels(service.getPanels()) }), [service])
|
|
285
296
|
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')
|
|
286
297
|
const tabsRef=useRef(tabs);tabsRef.current=tabs
|
|
287
|
-
const workspaceRequests=useRef(new WorkspaceRequestScope());const loadingTabs=useRef(new
|
|
298
|
+
const workspaceRequests=useRef(new WorkspaceRequestScope());const loadingTabs=useRef(new Map<string, symbol>())
|
|
288
299
|
const dshDark = useDshDarkTheme()
|
|
289
300
|
const [theme,setTheme]=useState<WorkbenchTheme>(()=>{const saved=localStorage.getItem('dvw:theme');return saved==='dark'||saved==='light'?saved:'dsh'})
|
|
290
301
|
const colorScheme: 'dark' | 'light' = theme === 'dsh' ? dshDark ? 'dark' : 'light' : theme
|
|
@@ -294,37 +305,151 @@ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbenc
|
|
|
294
305
|
useEffect(()=>localStorage.setItem('dvw:dsh-chat-width',String(Math.round(chatWidth))),[chatWidth])
|
|
295
306
|
useEffect(()=>localStorage.setItem('dvw:primary-side-visible',primarySideVisible?'open':'closed'),[primarySideVisible])
|
|
296
307
|
useEffect(()=>localStorage.setItem('dvw:status-visible',statusVisible?'open':'closed'),[statusVisible])
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (!snapshot.visible) return
|
|
310
|
+
document.body.setAttribute('data-dvw-workbench-active', '')
|
|
311
|
+
return () => document.body.removeAttribute('data-dvw-workbench-active')
|
|
312
|
+
}, [snapshot.visible])
|
|
297
313
|
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])
|
|
298
314
|
const openForWorkspace=(path:string,line?:number,column?:number)=>{const existing=tabsRef.current.find(tab=>tab.path===path);if(existing)setTabs(rows=>rows.map(tab=>tab.path===path?{...tab,line,column}:tab));else setTabs(rows=>[...rows,{...unloadedTab(path),line,column}]);setActive(path)}
|
|
299
|
-
useEffect(()
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
if (sessionId === undefined) return
|
|
317
|
+
const epoch = workspaceRequests.current.begin()
|
|
318
|
+
const cleared = resetWorkspaceDocuments<Tab>()
|
|
319
|
+
loadingTabs.current.clear(); setWorkspace(undefined); setTabs(cleared.tabs)
|
|
320
|
+
tabsRef.current = cleared.tabs; setActive(cleared.active); setError(cleared.error)
|
|
321
|
+
const controller = new AbortController()
|
|
322
|
+
const load = async () => {
|
|
323
|
+
for (let attempt = 0; ; attempt++) {
|
|
324
|
+
try {
|
|
325
|
+
const value = await api<{ cwd: string; name: string }>('workspace', { sessionId }, controller.signal)
|
|
326
|
+
if (!workspaceRequests.current.isCurrent(epoch)) return
|
|
327
|
+
setWorkspace(value)
|
|
328
|
+
let restored: Tab[] = []
|
|
329
|
+
let selected: string | undefined
|
|
330
|
+
try {
|
|
331
|
+
const parsed = JSON.parse(localStorage.getItem(`dvw:${value.cwd}`) ?? '{}') as { view?: WorkbenchView; paths?: string[]; active?: string; panelHeight?: number; sideWidth?: number }
|
|
332
|
+
if (parsed.view && service.getSnapshot().visible) service.show(parsed.view)
|
|
333
|
+
if (typeof parsed.panelHeight === 'number') setPanelHeight(parsed.panelHeight)
|
|
334
|
+
if (typeof parsed.sideWidth === 'number') setSideWidth(Math.max(210, Math.min(640, Math.round(parsed.sideWidth))))
|
|
335
|
+
restored = restoreTabPaths(parsed.paths ?? []); selected = parsed.active
|
|
336
|
+
const drafts: unknown = JSON.parse(localStorage.getItem(`dvw:drafts:${sessionId}:${value.cwd}`) ?? '[]')
|
|
337
|
+
if (Array.isArray(drafts)) for (const draft of drafts) {
|
|
338
|
+
if (!draft || typeof draft.path !== 'string' || typeof draft.content !== 'string' || typeof draft.draft !== 'string' || typeof draft.revision !== 'string' || draft.path.startsWith('diff:')) continue
|
|
339
|
+
const tab = { ...unloadedTab(draft.path), content: draft.content, draft: draft.draft, revision: draft.revision, dirty: draft.draft !== draft.content, loaded: true }
|
|
340
|
+
restored = [...restored.filter(row => row.path !== tab.path), tab]
|
|
341
|
+
}
|
|
342
|
+
} catch { /* Invalid saved state must not prevent opening the workspace. */ }
|
|
343
|
+
setTabs(restored)
|
|
344
|
+
setActive(restored.some(tab => tab.path === selected) ? selected : restored.at(-1)?.path)
|
|
345
|
+
return
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (controller.signal.aborted || !workspaceRequests.current.isCurrent(epoch)) return
|
|
348
|
+
const delay = workspaceRetryDelay(error, attempt)
|
|
349
|
+
if (delay === undefined) { setError(String(error)); return }
|
|
350
|
+
await new Promise(resolve => setTimeout(resolve, delay))
|
|
351
|
+
if (controller.signal.aborted || !workspaceRequests.current.isCurrent(epoch)) return
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
void load()
|
|
356
|
+
return () => { controller.abort(); loadingTabs.current.clear(); workspaceRequests.current.invalidate(epoch) }
|
|
357
|
+
}, [sessionId, cwd])
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
if (!workspace || sessionId === undefined) return
|
|
360
|
+
try {
|
|
361
|
+
localStorage.setItem(`dvw:${workspace.cwd}`, JSON.stringify({ view: snapshot.view, paths: tabs.filter(t => !t.path.startsWith('diff:')).map(t => t.path), active, panelHeight, sideWidth }))
|
|
362
|
+
localStorage.setItem(`dvw:drafts:${sessionId}:${workspace.cwd}`, JSON.stringify(tabs.filter(tab => tab.dirty).map(({ path, content, draft, revision }) => ({ path, content, draft, revision }))))
|
|
363
|
+
} catch { setError('无法保存工作区草稿,请在离开页面前保存文件。') }
|
|
364
|
+
}, [workspace, sessionId, snapshot.view, tabs, active, panelHeight, sideWidth])
|
|
365
|
+
const loadTab = useCallback((path: string) => {
|
|
366
|
+
if (sessionId === undefined) return
|
|
367
|
+
const epoch = workspaceRequests.current.current()
|
|
368
|
+
const token = Symbol(path)
|
|
369
|
+
loadingTabs.current.set(path, token)
|
|
370
|
+
const current = () => workspaceRequests.current.isCurrent(epoch) && loadingTabs.current.get(path) === token
|
|
371
|
+
const documentPreview = documentPreviewKind(path)
|
|
372
|
+
const image = /\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i.test(path)
|
|
373
|
+
if (documentPreview !== undefined || image) {
|
|
374
|
+
const url = `/dsh-vscode/file?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}&v=${Date.now()}`
|
|
375
|
+
setTabs(rows => rows.map(row => row.path === path ? { ...row, loaded: true, ...(image ? { imageUrl: url } : { documentPreview, documentUrl: url }) } : row))
|
|
376
|
+
loadingTabs.current.delete(path); return
|
|
377
|
+
}
|
|
378
|
+
void api<FileDocument>('fs.read', { sessionId, path }).then(doc => {
|
|
379
|
+
if (!current()) return
|
|
380
|
+
if (doc.binary || doc.tooLarge) {
|
|
381
|
+
setTabs(rows => rows.map(row => row.path === path ? { ...row, loaded: true, ...(row.dirty ? {} : { binary: doc.binary, tooLarge: doc.tooLarge }), external: true } : row)); return
|
|
382
|
+
}
|
|
383
|
+
setTabs(rows => rows.map(row => row.path !== path ? row : row.dirty
|
|
384
|
+
? { ...row, external: row.revision !== doc.revision }
|
|
385
|
+
: { ...row, ...doc, draft: doc.content, loaded: true, external: false }))
|
|
386
|
+
}).catch(error => { if (current()) setError(String(error)) })
|
|
387
|
+
.finally(() => { if (current()) loadingTabs.current.delete(path) })
|
|
388
|
+
}, [sessionId])
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
if (!snapshot.visible || sessionId === undefined || workspace === undefined) return
|
|
391
|
+
const url = websocketUrl(`/dsh-vscode/ws/files?sessionId=${encodeURIComponent(sessionId)}`)
|
|
392
|
+
if (url === undefined) return
|
|
393
|
+
const ws = new WebSocket(url)
|
|
394
|
+
let closed = false
|
|
395
|
+
const refresh = (paths?: string[]) => {
|
|
396
|
+
if (closed) return
|
|
397
|
+
window.dispatchEvent(new CustomEvent('dvw-files-changed', { detail: paths }))
|
|
398
|
+
for (const tab of tabsRef.current) {
|
|
399
|
+
if (tab.path.startsWith('diff:') || tab.loaded === false) continue
|
|
400
|
+
if (paths === undefined || paths.some(path => tab.path === path || tab.path.startsWith(`${path}/`))) loadTab(tab.path)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
ws.onopen = () => refresh()
|
|
404
|
+
ws.onmessage = event => {
|
|
405
|
+
try { const value = JSON.parse(String(event.data)) as { paths?: string[] }; if (Array.isArray(value.paths) && value.paths.every(path => typeof path === 'string')) refresh(value.paths) } catch { /* Ignore invalid notifications. */ }
|
|
406
|
+
}
|
|
407
|
+
return () => { closed = true; ws.close() }
|
|
408
|
+
}, [snapshot.visible, sessionId, workspace, loadTab])
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
if (sessionId === undefined || active === undefined || workspace === undefined) return
|
|
411
|
+
const tab = tabsRef.current.find(row => row.path === active)
|
|
412
|
+
if (tab?.loaded === false && !loadingTabs.current.has(active)) loadTab(active)
|
|
413
|
+
}, [active, sessionId, workspace, tabs, loadTab])
|
|
303
414
|
const open=(path:string,line?:number,column?:number)=>{if(sessionId!==undefined)openForWorkspace(path,line,column)}
|
|
304
415
|
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])
|
|
305
|
-
const
|
|
306
|
-
const
|
|
307
|
-
|
|
416
|
+
const savingTabs = useRef(new Set<string>())
|
|
417
|
+
const save = async (tab: Tab) => {
|
|
418
|
+
if (sessionId === undefined || !tab.dirty || savingTabs.current.has(tab.path)) return
|
|
419
|
+
const epoch = workspaceRequests.current.current()
|
|
420
|
+
savingTabs.current.add(tab.path)
|
|
421
|
+
loadingTabs.current.delete(tab.path)
|
|
422
|
+
try {
|
|
423
|
+
const doc = await api<FileDocument>('fs.save', { sessionId, path: tab.path, content: tab.draft, revision: tab.revision })
|
|
424
|
+
if (!workspaceRequests.current.isCurrent(epoch)) return
|
|
425
|
+
loadingTabs.current.delete(tab.path)
|
|
426
|
+
setTabs(rows => rows.map(row => row.path === tab.path && row.instance === tab.instance ? { ...row, ...doc, draft: row.draft, dirty: row.draft !== doc.content, external: false } : row))
|
|
427
|
+
} catch (error) { if (workspaceRequests.current.isCurrent(epoch)) setError(String(error)) }
|
|
428
|
+
finally { savingTabs.current.delete(tab.path) }
|
|
429
|
+
}
|
|
430
|
+
const close=(tab:Tab)=>{if(tab.dirty&&!confirm(`${tab.path} 有未保存更改,确定关闭?`))return;loadingTabs.current.delete(tab.path);setTabs(rows=>rows.filter(row=>row.path!==tab.path));if(active===tab.path)setActive(tabs.filter(row=>row.path!==tab.path).at(-1)?.path)}
|
|
431
|
+
const closeAll=()=>{const dirty=tabs.filter(tab=>tab.dirty);if(dirty.length>0&&!confirm(`有 ${dirty.length} 个文件包含未保存更改,确定全部关闭?`))return;loadingTabs.current.clear();setTabs([]);setActive(undefined);setError(undefined)}
|
|
308
432
|
const activeTab=tabs.find(tab=>tab.path===active); const Panel=panels.find(panel=>panel.id===snapshot.bottomPanel)?.component; const terminalAvailable=panels.some(panel=>panel.id==='terminal')
|
|
309
433
|
const selectView=(view:WorkbenchView)=>{if(snapshot.view===view&&primarySideVisible){setPrimarySideVisible(false);return}setPrimarySideVisible(true);service.show(view)}
|
|
310
434
|
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)}
|
|
311
435
|
const suppressAttachmentDrop=(event:DragEvent)=>{if(!event.dataTransfer.types.includes('Files'))return;const target=event.target;const insideTree=target instanceof Element&&target.closest('.dvw-tree')!==null;if(!insideTree){event.preventDefault();event.stopPropagation()}}
|
|
312
436
|
if(!snapshot.visible)return null
|
|
313
|
-
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>
|
|
437
|
+
if(sessionId===undefined)return <div className="dvw-overlay" data-dvw-theme={theme==='dsh'?undefined:theme}><WorkbenchTitlebar/><div className="dvw-empty" style={{gridColumn:'1/4'}}><div>请先选择或创建一个 DSH 会话。<br/><button onClick={()=>service.hide()}>返回 DSH</button></div></div></div>
|
|
314
438
|
const markdown=activeTab!==undefined&&!activeTab.path.startsWith('diff:')&&/\.md$/i.test(activeTab.path)
|
|
315
439
|
const diffMarkdown=activeTab?.diff!==undefined&&/\.md$/i.test(activeTab.diffSourcePath??'')
|
|
316
440
|
const markdownPreview=markdown||(diffMarkdown&&activeTab?.preview===true)
|
|
317
441
|
const headings=markdownPreview&&activeTab?markdownHeadings(activeTab.diff?.modified??activeTab.draft):[]
|
|
318
442
|
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 工作台" onDragEnterCapture={suppressAttachmentDrop} onDragOverCapture={suppressAttachmentDrop} onDropCapture={suppressAttachmentDrop}>
|
|
443
|
+
<WorkbenchTitlebar name={workspace?.name}/>
|
|
319
444
|
<DshConversationPanel visible={chatVisible&&!settingsOpen} width={chatWidth} statusHeight={statusVisible?24:0}/>
|
|
320
445
|
<DshConversationTheme visible={chatVisible} colorScheme={colorScheme} followDsh={theme==='dsh'}/>
|
|
321
446
|
{!chatVisible&&<button className="dvw-chat-toggle dvw-icon" title="显示 DSH 对话栏 (Ctrl+Alt+B)" onClick={()=>setChatVisible(true)}><DshMark/></button>}
|
|
322
447
|
{chatVisible&&<aside className="dvw-chat"><PanelResizer className="dvw-chat-resizer" label="拖动调整 DSH 对话栏宽度" width={chatWidth} min={400} max={720} defaultWidth={420} direction={-1} onWidth={setChatWidth}/><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>}
|
|
323
448
|
{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>}
|
|
324
449
|
<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" data-active={snapshot.bottomPanel==='terminal'} disabled={!terminalAvailable} title={terminalAvailable?(snapshot.bottomPanel==='terminal'?'隐藏终端':'显示终端'):'终端适配器未安装'} onClick={()=>toggleTerminal(service)}><Icon name="terminal"/></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>
|
|
325
|
-
<aside className="dvw-side" data-side-width={sideWidth}>{snapshot.view==='explorer'&&<Explorer sessionId={sessionId} cwd={workspace
|
|
450
|
+
<aside className="dvw-side" data-side-width={sideWidth}>{snapshot.view==='explorer'&&workspace!==undefined&&<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>
|
|
326
451
|
<PanelResizer className="dvw-side-resizer" label="拖动调整源码管理侧边栏宽度" width={sideWidth} min={210} max={640} defaultWidth={210} direction={1} onWidth={setSideWidth}/>
|
|
327
|
-
<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>:activeTab.documentPreview&&activeTab.documentUrl?<DocumentPreview kind={activeTab.documentPreview} src={activeTab.documentUrl} path={activeTab.path}/>:markdown&&activeTab.preview?<MarkdownPreview source={activeTab.draft} sessionId={sessionId} path={activeTab.path}
|
|
452
|
+
<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>:activeTab.documentPreview&&activeTab.documentUrl?<DocumentPreview kind={activeTab.documentPreview} src={activeTab.documentUrl} path={activeTab.path}/>:markdown&&activeTab.preview?<MarkdownPreview source={activeTab.draft} sessionId={sessionId} path={activeTab.path}/>:activeTab.binary||activeTab.tooLarge?<div className="dvw-message">{activeTab.binary?'二进制文件不能在文本编辑器中打开':`文件超过 5 MiB:${activeTab.path}`}</div>:activeTab.loaded===false?<div className="dvw-message">正在加载文件…</div>:<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>
|
|
328
453
|
{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>{snapshot.bottomPanel==='terminal'&&<button className="dvw-icon" title="新建终端" onClick={()=>window.dispatchEvent(new Event('dvw-terminal-new'))}><Icon name="add"/></button>}<button className="dvw-icon" style={{marginLeft:'auto'}} onClick={()=>service.toggleBottomPanel(snapshot.bottomPanel!)}><Icon name="close"/></button></div><div className="dvw-bottom-body">{Panel&&workspace&&<Panel sessionId={sessionId} cwd={workspace.cwd} visible colorScheme={colorScheme}/>}</div></section>}
|
|
329
454
|
<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>
|
|
330
455
|
</div>
|
package/src/client/styles.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
const CSS = String.raw`
|
|
2
2
|
@font-face{font-family:codicon;font-display:block;src:url('/dsh-vscode/codicons/codicon.ttf') format('truetype')}
|
|
3
|
+
:root{--dvw-titlebar-height:0px}html[data-platform=darwin]{--dvw-titlebar-height:var(--dsh-frame-top-clearance,48px)}
|
|
3
4
|
.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
5
|
.codicon-add:before{content:"\ea60"}.codicon-git-branch:before,.codicon-source-control:before{content:"\ea68"}.codicon-search:before{content:"\ea6d"}.codicon-edit:before{content:"\ea73"}.codicon-close:before{content:"\ea76"}.codicon-close-all:before{content:"\eac1"}.codicon-cloud-upload:before{content:"\eac3"}.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
6
|
.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"}.codicon-split-horizontal:before{content:"\eb56"}.codicon-terminal:before{content:"\ea85"}
|
|
6
7
|
.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-launcher-mount{position:fixed;z-index:20;right:16px;bottom:16px}.dvw-launcher-mount .dvw-launch{width:auto;height:32px;padding:0 12px;justify-content:center;border:1px solid var(--dsw-alias-border-l3,#e5e7eb);border-radius:6px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 1px 2px rgba(0,0,0,.06);color:var(--dsw-alias-label-primary,#333)}body[data-ds-dark-theme] .dvw-launcher-mount .dvw-launch{color:var(--dsw-alias-label-primary,#ddd)}@media(max-width:640px){.dvw-launcher-mount{right:8px;bottom:8px}.dvw-launcher-mount .dvw-launch{height:30px;padding:0 10px;font-size:12px}}
|
|
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
|
+
.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;box-sizing:border-box;padding-top:var(--dvw-titlebar-height);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}
|
|
9
|
+
.dvw-titlebar{display:none}html[data-platform=darwin] .dvw-titlebar{position:absolute;top:0;right:0;left:0;z-index:6;display:flex;align-items:center;gap:8px;height:var(--dvw-titlebar-height);padding:0 12px 0 84px;border-bottom:1px solid var(--dvw-border-soft);background:var(--dvw-base);color:var(--dvw-text-3);font-size:12px;-webkit-app-region:drag;user-select:none}.dvw-titlebar strong{flex:none;font-weight:600;color:var(--dvw-text-2)}.dvw-titlebar span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
8
10
|
body[data-ds-dark-theme] .dvw-overlay,.dvw-overlay[data-dvw-theme=dark]{color-scheme:dark}
|
|
9
11
|
.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
12
|
.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}
|
|
@@ -28,11 +30,11 @@ body[data-ds-dark-theme] .dvw-overlay,.dvw-overlay[data-dvw-theme=dark]{color-sc
|
|
|
28
30
|
.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}
|
|
29
31
|
.dvw-chat-head>span{display:flex;align-items:center;gap:7px}.dvw-chat-body{height:calc(100% - 36px);background:var(--dvw-base)}
|
|
30
32
|
.dvw-dsh-mark{display:block}
|
|
31
|
-
.dvw-chat-resizer{position:fixed;top:
|
|
33
|
+
.dvw-chat-resizer{position:fixed;top:var(--dvw-titlebar-height);right:calc(var(--dvw-chat-width) - 2px);bottom:0;width:4px;z-index:4;cursor:ew-resize;touch-action:none}
|
|
32
34
|
.dvw-chat-resizer:hover,.dvw-chat-resizer:focus-visible,.dvw-chat-resizer[data-dragging=true]{background:var(--dvw-accent)}
|
|
33
35
|
.dvw-side-resizer,.dvw-chat-resizer{width:10px;cursor:ew-resize;touch-action:none;background:transparent}
|
|
34
36
|
.dvw-side-resizer{position:relative;transform:translateX(5px);z-index:22}
|
|
35
|
-
.dvw-chat-resizer{position:fixed;top:
|
|
37
|
+
.dvw-chat-resizer{position:fixed;top:var(--dvw-titlebar-height);right:calc(var(--dvw-chat-width) - 5px);z-index:22}
|
|
36
38
|
.dvw-side-resizer::after,.dvw-chat-resizer::after{content:"";position:absolute;top:0;bottom:0;left:4px;width:2px;background:var(--dvw-border)}
|
|
37
39
|
.dvw-chat-resizer::after{left:1px;width:6px}
|
|
38
40
|
.dvw-side-resizer:hover,.dvw-side-resizer:focus-visible,.dvw-side-resizer[data-dragging=true],.dvw-chat-resizer:hover,.dvw-chat-resizer:focus-visible,.dvw-chat-resizer[data-dragging=true]{background:transparent}
|
|
@@ -42,7 +44,10 @@ body[data-ds-dark-theme] .dvw-overlay,.dvw-overlay[data-dvw-theme=dark]{color-sc
|
|
|
42
44
|
.dvw-side-resizer:hover::after,.dvw-side-resizer:focus-visible::after,.dvw-side-resizer[data-dragging=true]::after,.dvw-chat-resizer:hover::after,.dvw-chat-resizer:focus-visible::after,.dvw-chat-resizer[data-dragging=true]::after{background:var(--dvw-accent)}
|
|
43
45
|
.dvw-chat-toggle{position:absolute;z-index:5;top:6px;right:6px;border:1px solid var(--dvw-border);background:var(--dvw-base)}
|
|
44
46
|
.dvw-overlay:not([data-dvw-chat=true]) .dvw-tabs{padding-right:36px}
|
|
45
|
-
[data-dvw-conversation-panel]{position:fixed!important;top:
|
|
47
|
+
[data-dvw-conversation-panel]{position:fixed!important;top:var(--dvw-titlebar-height)!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}
|
|
48
|
+
body[data-dvw-workbench-active] [data-sidebar-right-expand],body[data-dvw-conversation-active] [data-sidebar-right-expand]{display:none!important}
|
|
49
|
+
body[data-dvw-workbench-active] [data-shell-leading]{display:none!important}
|
|
50
|
+
body[data-dvw-workbench-active] [data-sidebar-toggle]{display:none!important}
|
|
46
51
|
.dvw-confirm-backdrop{position:fixed;z-index:50;inset:0;display:grid;place-items:center;padding:24px;background:rgba(0,0,0,.38)}.dvw-confirm{width:min(380px,calc(100vw - 48px));border:1px solid var(--dvw-border);border-radius:7px;background:var(--dvw-surface-1);box-shadow:0 12px 36px rgba(0,0,0,.34);padding:18px}.dvw-confirm h2{margin:0 0 12px;font-size:15px}.dvw-confirm p{margin:0;color:var(--dvw-text)}.dvw-confirm-note{margin-top:8px!important;color:var(--dvw-text-3)!important;font-size:12px}.dvw-confirm footer{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.dvw-confirm footer button{height:28px;border:1px solid var(--dvw-border);border-radius:4px;background:var(--dvw-surface-2);color:var(--dvw-text);padding:0 12px;font:inherit;cursor:pointer}.dvw-confirm footer button:hover{background:var(--dvw-hover)}.dvw-confirm footer .dvw-danger-button{border-color:color-mix(in srgb,var(--dvw-error) 65%,var(--dvw-border));background:var(--dvw-error);color:#fff}.dvw-confirm footer .dvw-danger-button:hover{background:color-mix(in srgb,var(--dvw-error) 82%,#000)}
|
|
47
52
|
.dvw-tree[data-dvw-native-drop-target=true]{outline:1px dashed var(--dvw-accent);outline-offset:-3px}.dvw-tree-row[data-dvw-native-drop-target=true]{background:var(--dvw-active)}
|
|
48
53
|
.dvw-act:disabled{opacity:.4;cursor:not-allowed}
|
package/src/client/terminal.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { TerminalInfo } from '../terminal.ts'
|
|
2
|
+
import { websocketUrl } from './websocket.ts'
|
|
2
3
|
|
|
3
4
|
export interface TerminalConnection {
|
|
4
5
|
write(data: string): void
|
|
5
6
|
resize(cols: number, rows: number): void
|
|
6
7
|
close(): void
|
|
7
8
|
subscribe(listener: (output: { sequence: number; data: string; replay?: boolean }) => void): () => void
|
|
9
|
+
subscribeError(listener: (message: string) => void): () => void
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export interface TerminalWorkspaceSnapshot { terminals: readonly TerminalInfo[]; loading: boolean; error?: string }
|
|
@@ -32,12 +34,17 @@ async function request<T>(method: string, body: Record<string, unknown>): Promis
|
|
|
32
34
|
class SocketConnection implements TerminalConnection {
|
|
33
35
|
private readonly socket: WebSocket
|
|
34
36
|
private readonly listeners = new Set<(output: { sequence: number; data: string; replay?: boolean }) => void>()
|
|
37
|
+
private readonly errorListeners = new Set<(message: string) => void>()
|
|
38
|
+
private closed = false
|
|
35
39
|
private queue: string[] = []
|
|
36
40
|
|
|
37
41
|
constructor(sessionId: string, id: string) {
|
|
38
|
-
const
|
|
39
|
-
|
|
42
|
+
const url = websocketUrl(`/dsh-vscode-terminal/ws?sessionId=${encodeURIComponent(sessionId)}&id=${encodeURIComponent(id)}`)
|
|
43
|
+
if (url === undefined) throw new Error('无法连接终端:桌面端没有发布本地 Host 地址')
|
|
44
|
+
this.socket = new WebSocket(url)
|
|
40
45
|
this.socket.onopen = () => { for (const value of this.queue) this.socket.send(value); this.queue = [] }
|
|
46
|
+
this.socket.onerror = () => { for (const listener of this.errorListeners) listener('终端 WebSocket 连接失败') }
|
|
47
|
+
this.socket.onclose = () => { if (!this.closed) for (const listener of this.errorListeners) listener('终端 WebSocket 连接已断开') }
|
|
41
48
|
this.socket.onmessage = event => {
|
|
42
49
|
try {
|
|
43
50
|
const value = JSON.parse(String(event.data)) as { type?: unknown; sequence?: unknown; data?: unknown }
|
|
@@ -50,8 +57,9 @@ class SocketConnection implements TerminalConnection {
|
|
|
50
57
|
private send(value: unknown): void { const text = JSON.stringify(value); if (this.socket.readyState === WebSocket.OPEN) this.socket.send(text); else this.queue.push(text) }
|
|
51
58
|
write(data: string): void { this.send({ type: 'input', data }) }
|
|
52
59
|
resize(cols: number, rows: number): void { this.send({ type: 'resize', cols, rows }) }
|
|
53
|
-
close(): void { this.socket.close() }
|
|
60
|
+
close(): void { this.closed = true; this.socket.close() }
|
|
54
61
|
subscribe(listener: (output: { sequence: number; data: string; replay?: boolean }) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener) }
|
|
62
|
+
subscribeError(listener: (message: string) => void): () => void { this.errorListeners.add(listener); return () => this.errorListeners.delete(listener) }
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
/** Identify automatic terminal-emulator replies that must not be echoed during replay. */
|
|
@@ -73,7 +81,10 @@ export class TerminalWorkspaceController implements TerminalWorkspace {
|
|
|
73
81
|
catch (error) { this.set({ ...this.snapshot, loading: false, error: error instanceof Error ? error.message : String(error) }) }
|
|
74
82
|
}
|
|
75
83
|
|
|
76
|
-
async create(title?: string): Promise<TerminalInfo> {
|
|
84
|
+
async create(title?: string): Promise<TerminalInfo> {
|
|
85
|
+
try { const terminal = await request<TerminalInfo>('create', { sessionId: this.sessionId, ...(title === undefined ? {} : { title }) }); await this.refresh(); return terminal }
|
|
86
|
+
catch (error) { this.set({ ...this.snapshot, loading: false, error: error instanceof Error ? error.message : String(error) }); throw error }
|
|
87
|
+
}
|
|
77
88
|
async close(id: string): Promise<void> { await request('close', { sessionId: this.sessionId, id }); await this.refresh() }
|
|
78
89
|
async rename(id: string, title: string): Promise<void> { await request('rename', { sessionId: this.sessionId, id, title }); await this.refresh() }
|
|
79
90
|
connect(id: string): TerminalConnection { return new SocketConnection(this.sessionId, id) }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Page facts needed to resolve a WebSocket URL. */
|
|
2
|
+
export interface WebSocketPageLocation {
|
|
3
|
+
readonly protocol: string
|
|
4
|
+
readonly host: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface NetworkBase {
|
|
8
|
+
readonly protocol: 'http:' | 'https:'
|
|
9
|
+
readonly host: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const WEB_PAGE_PROTOCOLS = new Set(['http:', 'https:', 'blob:', 'data:', 'about:', 'filesystem:'])
|
|
13
|
+
|
|
14
|
+
/** Parse a shell-published base only when it is a credential-free HTTP(S) origin. */
|
|
15
|
+
function networkBase(value: unknown): NetworkBase | undefined {
|
|
16
|
+
if (typeof value !== 'string' || value === '') return undefined
|
|
17
|
+
let url: URL
|
|
18
|
+
try { url = new URL(value) } catch { return undefined }
|
|
19
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
|
|
20
|
+
if (url.username !== '' || url.password !== '' || url.host === '') return undefined
|
|
21
|
+
return { protocol: url.protocol, host: url.host }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read the Host origin published by the DSH Desktop shell before application boot. */
|
|
25
|
+
export function transportStreamBaseUrl(globals: typeof globalThis = globalThis): string | undefined {
|
|
26
|
+
const transport = (globals as typeof globalThis & { __DSH_TRANSPORT__?: { streamBaseUrl?: unknown } }).__DSH_TRANSPORT__
|
|
27
|
+
return typeof transport?.streamBaseUrl === 'string' ? transport.streamBaseUrl : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve an absolute workbench route to a WebSocket URL.
|
|
32
|
+
*
|
|
33
|
+
* Network pages keep their own origin. Application-delivered pages such as
|
|
34
|
+
* `dsh-app://app/` have no WebSocket transport of their own, so they must use
|
|
35
|
+
* the local Host origin published by the desktop shell.
|
|
36
|
+
*/
|
|
37
|
+
export function websocketUrl(
|
|
38
|
+
path: string,
|
|
39
|
+
location: WebSocketPageLocation = globalThis.location,
|
|
40
|
+
streamBaseUrl: string | undefined = transportStreamBaseUrl(),
|
|
41
|
+
): string | undefined {
|
|
42
|
+
const route = path.startsWith('/') ? path : `/${path}`
|
|
43
|
+
if (WEB_PAGE_PROTOCOLS.has(location.protocol)) {
|
|
44
|
+
if (location.host === '') return undefined
|
|
45
|
+
return `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${route}`
|
|
46
|
+
}
|
|
47
|
+
const base = networkBase(streamBaseUrl)
|
|
48
|
+
if (base === undefined) return undefined
|
|
49
|
+
return `${base.protocol === 'https:' ? 'wss' : 'ws'}://${base.host}${route}`
|
|
50
|
+
}
|