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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-workbench",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Explorer, Monaco editor, search and local Git overlay for DSH",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- const connection = workspace.connect(id)
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">
@@ -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===undefined||directoryAffected(path,paths))load() }; window.addEventListener('dvw-files-changed', handler); return () => window.removeEventListener('dvw-files-changed', handler)
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 Set<string>())
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(()=>{if(!snapshot.visible||sessionId===undefined)return;const epoch=workspaceRequests.current.begin();const cleared=resetWorkspaceDocuments<Tab>();loadingTabs.current.clear();setWorkspace(undefined);setTabs(cleared.tabs);tabsRef.current=cleared.tabs;setActive(cleared.active);setError(cleared.error);const controller=new AbortController();void api<{cwd:string;name:string}>('workspace',{sessionId},controller.signal).then(value=>{if(!workspaceRequests.current.isCurrent(epoch))return;setWorkspace(value);const saved=localStorage.getItem(`dvw:${value.cwd}`);if(saved){try{const parsed=JSON.parse(saved) as {view?:WorkbenchView;paths?:string[];active?:string;panelHeight?:number;sideWidth?:number};if(parsed.view)service.show(parsed.view);if(typeof parsed.panelHeight==='number')setPanelHeight(parsed.panelHeight);if(typeof parsed.sideWidth==='number')setSideWidth(Math.max(210,Math.min(640,Math.round(parsed.sideWidth))));const restored=restoreTabPaths(parsed.paths??[]);setTabs(restored);setActive(restored.some(tab=>tab.path===parsed.active)?parsed.active:restored.at(-1)?.path)}catch{}}}).catch(error=>{if(!controller.signal.aborted&&workspaceRequests.current.isCurrent(epoch))setError(String(error))});return()=>{controller.abort();loadingTabs.current.clear();workspaceRequests.current.invalidate(epoch)}},[snapshot.visible,sessionId])
300
- useEffect(()=>{if(workspace) localStorage.setItem(`dvw:${workspace.cwd}`,JSON.stringify({view:snapshot.view,paths:tabs.filter(t=>!t.path.startsWith('diff:')).map(t=>t.path),active,panelHeight,sideWidth}))},[workspace,snapshot.view,tabs,active,panelHeight,sideWidth])
301
- useEffect(()=>{if(!snapshot.visible||sessionId===undefined)return;const protocol=location.protocol==='https:'?'wss':'ws';const ws=new WebSocket(`${protocol}://${location.host}/dsh-vscode/ws/files?sessionId=${encodeURIComponent(sessionId)}`);ws.onmessage=event=>{let paths:string[]=[];try{paths=(JSON.parse(String(event.data)) as {paths?:string[]}).paths??[]}catch{}window.dispatchEvent(new CustomEvent('dvw-files-changed',{detail:paths}));setTabs(current=>current.map(tab=>tab.dirty?{...tab,external:true}:tab))};return()=>ws.close()},[snapshot.visible,sessionId])
302
- useEffect(()=>{if(sessionId===undefined||active===undefined)return;const tab=tabsRef.current.find(row=>row.path===active);if(tab===undefined||tab.loaded!==false||loadingTabs.current.has(active))return;const epoch=workspaceRequests.current.current();loadingTabs.current.add(active);const documentPreview=documentPreviewKind(active);if(documentPreview!==undefined){const documentUrl=`/dsh-vscode/file?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(active)}`;setTabs(rows=>rows.map(row=>row.path===active?{...row,loaded:true,documentPreview,documentUrl}:row));loadingTabs.current.delete(active);return}if(/\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i.test(active)){const imageUrl=`/dsh-vscode/file?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(active)}`;setTabs(rows=>rows.map(row=>row.path===active?{...row,loaded:true,imageUrl}:row));loadingTabs.current.delete(active);return}void api<FileDocument>('fs.read',{sessionId,path:active}).then(doc=>{if(!workspaceRequests.current.isCurrent(epoch))return;if(doc.binary||doc.tooLarge){setTabs(rows=>rows.filter(row=>row.path!==active));setActive(current=>current===active?undefined:current);setError(doc.binary?'二进制文件不能在文本编辑器中打开':`文件超过 5 MiB:${active}`);return}setTabs(rows=>rows.map(row=>row.path===active?{...row,...doc,draft:doc.content,loaded:true}:row))}).catch(error=>{if(workspaceRequests.current.isCurrent(epoch))setError(String(error))}).finally(()=>loadingTabs.current.delete(active))},[active,sessionId,tabs])
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 save=async(tab:Tab)=>{if(sessionId===undefined||!tab.dirty)return;try{const doc=await api<FileDocument>('fs.save',{sessionId,path:tab.path,content:tab.draft,revision:tab.revision});setTabs(rows=>rows.map(row=>row.path===tab.path?{...row,...doc,draft:doc.content,dirty:false,external:false}:row))}catch(error){setError(String(error))}}
306
- const close=(tab:Tab)=>{if(tab.dirty&&!confirm(`${tab.path} 有未保存更改,确定关闭?`))return;setTabs(rows=>rows.filter(row=>row.path!==tab.path));if(active===tab.path)setActive(tabs.filter(row=>row.path!==tab.path).at(-1)?.path)}
307
- const closeAll=()=>{const dirty=tabs.filter(tab=>tab.dirty);if(dirty.length>0&&!confirm(`有 ${dirty.length} 个文件包含未保存更改,确定全部关闭?`))return;setTabs([]);setActive(undefined)}
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?.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>
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}/>:<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>
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>
@@ -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:36px;right:calc(var(--dvw-chat-width) - 2px);bottom:0;width:4px;z-index:4;cursor:ew-resize;touch-action:none}
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:36px;right:calc(var(--dvw-chat-width) - 5px);z-index:22}
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:36px!important;right:0!important;bottom:var(--dvw-status-height,24px)!important;left:auto!important;width:var(--dvw-chat-width)!important;height:auto!important;min-width:0!important;z-index:21!important;border-left:1px solid var(--dsw-alias-border-l2)!important;box-shadow:none!important}
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}
@@ -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 protocol = location.protocol === 'https:' ? 'wss' : 'ws'
39
- this.socket = new WebSocket(`${protocol}://${location.host}/dsh-vscode-terminal/ws?sessionId=${encodeURIComponent(sessionId)}&id=${encodeURIComponent(id)}`)
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> { const terminal = await request<TerminalInfo>('create', { sessionId: this.sessionId, ...(title === undefined ? {} : { title }) }); await this.refresh(); return terminal }
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
+ }