dsh-vscode-workbench 0.1.8 → 0.1.9
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 +102 -35
- package/README.md +102 -36
- package/lib/client.js +15 -9
- package/lib/client.js.map +1 -1
- package/lib/index.js +41 -58
- package/lib/index.js.map +1 -1
- package/lib/types/client/Workbench.d.ts +3 -2
- package/lib/types/client/index.d.ts +1 -11
- package/lib/types/client/session.d.ts +3 -0
- package/lib/types/context.d.ts +8 -8
- package/lib/types/wire.d.ts +3 -1
- package/package.json +28 -16
- package/src/client/DshConversationPanel.tsx +2 -2
- package/src/client/Workbench.tsx +2 -3
- package/src/client/index.tsx +8 -4
- package/src/client/session.ts +7 -0
- package/src/context.ts +5 -3
- package/src/host.ts +3 -2
- package/src/index.ts +12 -10
- package/src/terminal.ts +6 -3
- package/src/wire.ts +8 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-workbench",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Explorer, Monaco editor, search and local Git overlay for DSH",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
"client": {
|
|
48
48
|
"platform": "web",
|
|
49
49
|
"inject": [
|
|
50
|
-
"@deepseek-ai/dsh-client-
|
|
50
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
51
|
+
"@deepseek-ai/dsh-client-ui-session",
|
|
51
52
|
"@deepseek-ai/dsh-client-ui-layout",
|
|
52
53
|
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
53
54
|
]
|
|
@@ -68,30 +69,41 @@
|
|
|
68
69
|
"ws": "^8.18.0"
|
|
69
70
|
},
|
|
70
71
|
"peerDependencies": {
|
|
71
|
-
"@deepseek-ai/
|
|
72
|
-
"@deepseek-ai/
|
|
73
|
-
"@deepseek-ai/dsh-client-ui-
|
|
74
|
-
"@deepseek-ai/dsh-client-ui-
|
|
75
|
-
"@deepseek-ai/dsh-client-ui-
|
|
76
|
-
"@deepseek-ai/dsh-
|
|
77
|
-
"@deepseek-ai/dsh-
|
|
72
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.2.1-alpha.3",
|
|
73
|
+
"@deepseek-ai/cordis": "^4.0.5-alpha.2",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.2.1-alpha.3",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-session": "^0.2.1-alpha.3",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-layout": "^0.2.1-alpha.3",
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-sidebar": "^0.2.1-alpha.3",
|
|
78
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.2.1-alpha.3",
|
|
79
|
+
"@deepseek-ai/dsh-client-connection": "^0.2.1-alpha.3",
|
|
80
|
+
"@deepseek-ai/dsh-host-webserver": "^0.2.1-alpha.3",
|
|
81
|
+
"@deepseek-ai/dsh-working-directory": "^0.2.1-alpha.3",
|
|
82
|
+
"@deepseek-ai/dsh-session": "^0.2.1-alpha.3",
|
|
78
83
|
"react": "^18.2.0",
|
|
79
84
|
"react-dom": "^18.2.0"
|
|
80
85
|
},
|
|
81
86
|
"devDependencies": {
|
|
82
|
-
"@deepseek-ai/
|
|
83
|
-
"@deepseek-ai/dsh-client-
|
|
84
|
-
"@deepseek-ai/
|
|
85
|
-
"@deepseek-ai/dsh-client-ui-
|
|
86
|
-
"@deepseek-ai/dsh-client-ui-
|
|
87
|
-
"@deepseek-ai/dsh-
|
|
88
|
-
"@deepseek-ai/dsh-
|
|
87
|
+
"@deepseek-ai/dsh-api-session-controller": "0.2.1-alpha.3",
|
|
88
|
+
"@deepseek-ai/dsh-client-store": "0.2.1-alpha.3",
|
|
89
|
+
"@deepseek-ai/cordis": "4.0.5-alpha.2",
|
|
90
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-alpha.3",
|
|
91
|
+
"@deepseek-ai/dsh-client-ui-session": "0.2.1-alpha.3",
|
|
92
|
+
"@deepseek-ai/dsh-client-ui-layout": "0.2.1-alpha.3",
|
|
93
|
+
"@deepseek-ai/dsh-client-ui-sidebar": "0.2.1-alpha.3",
|
|
94
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-alpha.3",
|
|
95
|
+
"@deepseek-ai/dsh-client-connection": "0.2.1-alpha.3",
|
|
96
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.1-alpha.3",
|
|
97
|
+
"@deepseek-ai/dsh-working-directory": "0.2.1-alpha.3",
|
|
98
|
+
"@deepseek-ai/dsh-session": "0.2.1-alpha.3",
|
|
89
99
|
"@types/node": "^24.0.0",
|
|
100
|
+
"@types/jsdom": "^28.0.3",
|
|
90
101
|
"@types/react": "^18.3.0",
|
|
91
102
|
"@types/react-dom": "^18.3.0",
|
|
92
103
|
"@types/ws": "^8.18.0",
|
|
93
104
|
"react": "^18.2.0",
|
|
94
105
|
"react-dom": "^18.3.1",
|
|
106
|
+
"jsdom": "29.1.1",
|
|
95
107
|
"tsdown": "^0.22.2",
|
|
96
108
|
"typescript": "^5.9.0",
|
|
97
109
|
"vitest": "^4.0.0"
|
|
@@ -2,8 +2,8 @@ import { useLayoutEffect } from 'react'
|
|
|
2
2
|
|
|
3
3
|
/** Locate the resident DSH conversation without depending on generated CSS module names. */
|
|
4
4
|
export function findDshConversationRoot(documentRoot: Document = document): HTMLElement | null {
|
|
5
|
-
const scroller = documentRoot.querySelector<HTMLElement>('[data-conversation-scroll]')
|
|
6
|
-
const root = scroller?.
|
|
5
|
+
const scroller = documentRoot.querySelector<HTMLElement>('[data-phase] [data-conversation-scroll]')
|
|
6
|
+
const root = scroller?.closest('[data-phase]')
|
|
7
7
|
return root instanceof HTMLElement && root.dataset.phase !== undefined ? root : null
|
|
8
8
|
}
|
|
9
9
|
|
package/src/client/Workbench.tsx
CHANGED
|
@@ -279,10 +279,9 @@ function Git(props: { sessionId: string; onDiff(key:string, diff:GitDiff, title?
|
|
|
279
279
|
|
|
280
280
|
export function Launcher({ service }: { service: VscodeWorkbench }) { return <button className="dvw-launch" title="打开 VS Code 工作台" onClick={()=>service.show()}>VS Code 工作台</button> }
|
|
281
281
|
|
|
282
|
-
export function Workbench({ service,
|
|
282
|
+
export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbench; sessionId?: string; cwd?: string }) {
|
|
283
283
|
const [snapshot, setSnapshot] = useState(() => service.getSnapshot()); const [panels, setPanels] = useState(() => service.getPanels())
|
|
284
284
|
useEffect(() => service.subscribe(() => { setSnapshot(service.getSnapshot()); setPanels(service.getPanels()) }), [service])
|
|
285
|
-
const sessionId = useSessions(state=>state.current) as string|undefined; const cwd = useSessions(state=>sessionId===undefined?undefined:state.byId[sessionId]?.cwd) as string|undefined
|
|
286
285
|
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')
|
|
287
286
|
const tabsRef=useRef(tabs);tabsRef.current=tabs
|
|
288
287
|
const workspaceRequests=useRef(new WorkspaceRequestScope());const loadingTabs=useRef(new Set<string>())
|
|
@@ -326,7 +325,7 @@ export function Workbench({ service, useSessions }: { service: VscodeWorkbench;
|
|
|
326
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>
|
|
327
326
|
<PanelResizer className="dvw-side-resizer" label="拖动调整源码管理侧边栏宽度" width={sideWidth} min={210} max={640} defaultWidth={210} direction={1} onWidth={setSideWidth}/>
|
|
328
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>
|
|
329
|
-
{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&&
|
|
328
|
+
{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>}
|
|
330
329
|
<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>
|
|
331
330
|
</div>
|
|
332
331
|
}
|
package/src/client/index.tsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import type { ClientContext } from '@deepseek-ai/
|
|
1
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
2
2
|
import { useEffect, useLayoutEffect, useState } from 'react'
|
|
3
3
|
import { createPortal } from 'react-dom'
|
|
4
4
|
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
5
5
|
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
|
6
|
+
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
8
|
+
import type {} from '@deepseek-ai/dsh-working-directory'
|
|
6
9
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
7
10
|
import type { VscodeWorkbench } from './service.ts'
|
|
8
11
|
import { WorkbenchController } from './service.ts'
|
|
@@ -10,15 +13,16 @@ import { TerminalPanel } from './TerminalPanel.tsx'
|
|
|
10
13
|
import { Launcher, Workbench } from './Workbench.tsx'
|
|
11
14
|
import { installStyles } from './styles.ts'
|
|
12
15
|
import { handleTerminalShortcut } from './shortcuts.ts'
|
|
16
|
+
import { mainViewSession } from './session.ts'
|
|
13
17
|
|
|
14
18
|
export type { BottomPanelContribution, BottomPanelProps, VscodeWorkbench, WorkbenchCommand, WorkbenchSnapshot, WorkbenchView } from './service.ts'
|
|
15
19
|
|
|
16
20
|
declare module '@deepseek-ai/cordis' { interface Context { vscodeWorkbench: VscodeWorkbench } }
|
|
17
|
-
declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotInjectMap { 'dsh-vscode-workbench.overlay': { service: VscodeWorkbench }; 'dsh-vscode-workbench.launcher': { service: VscodeWorkbench } } }
|
|
18
21
|
|
|
19
22
|
const Overlay = ({ service, useSessions }: PropsRuntime<'shell.overlay'> & { service: VscodeWorkbench }) => {
|
|
20
|
-
const
|
|
21
|
-
|
|
23
|
+
const session = useSessions(mainViewSession)
|
|
24
|
+
const cwd = session?.projectionValues?.workingDirectory ?? session?.cwd
|
|
25
|
+
return <Workbench key={`${session?.id ?? 'no-session'}:${cwd ?? ''}`} service={service} sessionId={session?.id} cwd={cwd}/>
|
|
22
26
|
}
|
|
23
27
|
const Footer = ({ service }: PropsRuntime<'sidebar.footer.action'> & { service: VscodeWorkbench }) => {
|
|
24
28
|
const [target, setTarget] = useState<HTMLElement>()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
2
|
+
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
|
3
|
+
|
|
4
|
+
/** Background retains do not select the workspace shown in the main conversation. */
|
|
5
|
+
export function mainViewSession(state: SessionListState) {
|
|
6
|
+
return Object.values(state.byId).find(session => (session.retainedBy.mainView ?? 0) > 0)
|
|
7
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
2
|
import type { Duplex } from 'node:stream'
|
|
3
3
|
import type { Context as CordisContext } from '@deepseek-ai/cordis'
|
|
4
|
+
import type { Session } from '@deepseek-ai/dsh-session'
|
|
4
5
|
|
|
5
6
|
export interface WorkbenchWebServer {
|
|
6
7
|
register(route: { kind: 'exact' | 'prefix'; path: string; handler(req: IncomingMessage, res: ServerResponse): void | Promise<void> }): () => void
|
|
7
8
|
registerUpgrade(route: { path: string; handler(req: IncomingMessage, socket: Duplex, head: Buffer): void | Promise<void> }): () => void
|
|
8
9
|
}
|
|
9
|
-
export interface WorkbenchSessions { get(id: string):
|
|
10
|
-
export interface
|
|
10
|
+
export interface WorkbenchSessions { get(id: string): Session | undefined }
|
|
11
|
+
export interface WorkbenchConnection { requestRejection(req: IncomingMessage): 401 | 403 | undefined }
|
|
11
12
|
export type HostContext = CordisContext & {
|
|
12
13
|
webServer: WorkbenchWebServer
|
|
13
14
|
sessions: WorkbenchSessions
|
|
14
|
-
|
|
15
|
+
connection: WorkbenchConnection
|
|
16
|
+
workingDirectory: { get(session: Session): string }
|
|
15
17
|
effect(callback: () => void | (() => void), label?: string): void
|
|
16
18
|
}
|
package/src/host.ts
CHANGED
|
@@ -13,8 +13,9 @@ const TEXT_LIMIT = 5 * 1024 * 1024
|
|
|
13
13
|
const PROCESS_TIMEOUT = 30_000
|
|
14
14
|
|
|
15
15
|
export function cwdFor(ctx: HostContext, sessionId: string): string {
|
|
16
|
-
const
|
|
17
|
-
if (
|
|
16
|
+
const session = ctx.sessions.get(sessionId)
|
|
17
|
+
if (session === undefined) throw new WorkbenchError('session-not-found', 'session not found', 404)
|
|
18
|
+
const cwd = ctx.workingDirectory.get(session)
|
|
18
19
|
if (!isAbsolute(cwd)) throw new WorkbenchError('cwd-invalid', 'session working directory is not absolute')
|
|
19
20
|
return normalize(cwd)
|
|
20
21
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,12 +9,12 @@ import type { HostContext } from './context.ts'
|
|
|
9
9
|
import { codiconAsset, createEntry, cwdFor, deleteEntry, gitAction, gitStatus, listFiles, monacoAsset, pdfWorkerAsset, readDocument, renameEntry, saveDocument, searchWorkspace, uploadFile, workspacePreviewFile } from './host.ts'
|
|
10
10
|
import { installTerminal } from './terminal.ts'
|
|
11
11
|
import type { SearchRequest } from './types.ts'
|
|
12
|
-
import { readBinaryBody, readBody, send, sendError, stringField,
|
|
12
|
+
import { readBinaryBody, readBody, send, sendError, stringField, authorize, WorkbenchError } from './wire.ts'
|
|
13
13
|
|
|
14
14
|
export { WorkbenchError } from './wire.ts'
|
|
15
15
|
export * from './types.ts'
|
|
16
16
|
export const name = 'dsh-vscode-workbench'
|
|
17
|
-
export const inject = ['webServer', 'sessions', '
|
|
17
|
+
export const inject = ['webServer', 'sessions', 'connection', 'workingDirectory']
|
|
18
18
|
|
|
19
19
|
async function dispatch(ctx: HostContext, method: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
20
20
|
const sessionId = stringField(body, 'sessionId'); const cwd = cwdFor(ctx, sessionId)
|
|
@@ -37,7 +37,7 @@ export function apply(ctx: HostContext): void {
|
|
|
37
37
|
installTerminal(ctx)
|
|
38
38
|
ctx.effect(() => ctx.webServer.register({
|
|
39
39
|
kind: 'exact', path: '/dsh-vscode/codicons/codicon.ttf', handler: async (req, res) => {
|
|
40
|
-
if (!
|
|
40
|
+
if (!authorize(req, res, ctx.connection)) return
|
|
41
41
|
try {
|
|
42
42
|
const asset = codiconAsset()
|
|
43
43
|
res.writeHead(200, { 'content-type': asset.type, 'cache-control': 'public, max-age=31536000, immutable' })
|
|
@@ -48,7 +48,7 @@ export function apply(ctx: HostContext): void {
|
|
|
48
48
|
|
|
49
49
|
ctx.effect(() => ctx.webServer.register({
|
|
50
50
|
kind: 'exact', path: '/dsh-vscode/pdfjs/worker.mjs', handler: async (req, res) => {
|
|
51
|
-
if (!
|
|
51
|
+
if (!authorize(req, res, ctx.connection)) return
|
|
52
52
|
try {
|
|
53
53
|
const asset = pdfWorkerAsset()
|
|
54
54
|
res.writeHead(200, { 'content-type': asset.type, 'cache-control': 'public, max-age=31536000, immutable' })
|
|
@@ -59,7 +59,7 @@ export function apply(ctx: HostContext): void {
|
|
|
59
59
|
|
|
60
60
|
ctx.effect(() => ctx.webServer.register({
|
|
61
61
|
kind: 'prefix', path: '/dsh-vscode/api', handler: async (req, res) => {
|
|
62
|
-
if (!
|
|
62
|
+
if (!authorize(req, res, ctx.connection)) return
|
|
63
63
|
if (req.method !== 'POST') return send(res, 405, { ok: false, error: { code: 'method', message: 'POST required' } })
|
|
64
64
|
try {
|
|
65
65
|
const controller = new AbortController()
|
|
@@ -73,7 +73,7 @@ export function apply(ctx: HostContext): void {
|
|
|
73
73
|
|
|
74
74
|
ctx.effect(() => ctx.webServer.register({
|
|
75
75
|
kind: 'exact', path: '/dsh-vscode/upload', handler: async (req, res) => {
|
|
76
|
-
if (!
|
|
76
|
+
if (!authorize(req, res, ctx.connection)) return
|
|
77
77
|
if (req.method !== 'POST') return send(res, 405, { ok: false, error: { code: 'method', message: 'POST required' } })
|
|
78
78
|
try {
|
|
79
79
|
const url = new URL(req.url ?? '/', 'http://dsh.local')
|
|
@@ -85,12 +85,12 @@ export function apply(ctx: HostContext): void {
|
|
|
85
85
|
}), 'dsh-vscode-workbench: file upload')
|
|
86
86
|
|
|
87
87
|
ctx.effect(() => ctx.webServer.register({
|
|
88
|
-
kind:'prefix',path:'/dsh-vscode/file',handler:async(req,res)=>{if(!
|
|
88
|
+
kind:'prefix',path:'/dsh-vscode/file',handler:async(req,res)=>{if(!authorize(req,res,ctx.connection))return;try{const url=new URL(req.url??'/','http://dsh.local');const sessionId=url.searchParams.get('sessionId');const path=url.searchParams.get('path');if(sessionId===null||path===null)throw new WorkbenchError('bad-request','sessionId and path are required');const asset=await workspacePreviewFile(cwdFor(ctx,sessionId),path);res.writeHead(200,{'content-type':asset.type,'x-content-type-options':'nosniff','cache-control':'no-store'});res.end(await readFile(asset.path))}catch(error){sendError(res,error)}}
|
|
89
89
|
}), 'dsh-vscode-workbench: file preview')
|
|
90
90
|
|
|
91
91
|
ctx.effect(() => ctx.webServer.register({
|
|
92
92
|
kind: 'prefix', path: '/dsh-vscode/monaco', handler: async (req, res) => {
|
|
93
|
-
if (!
|
|
93
|
+
if (!authorize(req, res, ctx.connection)) return
|
|
94
94
|
try {
|
|
95
95
|
const raw = decodeURIComponent(new URL(req.url ?? '/', 'http://dsh.local').pathname.slice('/dsh-vscode/monaco/'.length))
|
|
96
96
|
const asset = monacoAsset(raw)
|
|
@@ -103,11 +103,13 @@ export function apply(ctx: HostContext): void {
|
|
|
103
103
|
const wss = new WebSocketServer({ noServer: true })
|
|
104
104
|
ctx.effect(() => ctx.webServer.registerUpgrade({
|
|
105
105
|
path: '/dsh-vscode/ws/files', handler(req, socket, head) {
|
|
106
|
-
if (
|
|
106
|
+
if (ctx.connection.requestRejection(req) !== undefined) { socket.destroy(); return }
|
|
107
107
|
wss.handleUpgrade(req as IncomingMessage, socket as Duplex, head, (ws) => {
|
|
108
108
|
const sessionId = new URL(req.url ?? '/', 'http://dsh.local').searchParams.get('sessionId')
|
|
109
109
|
if (sessionId === null) { ws.close(1008, 'sessionId required'); return }
|
|
110
|
-
|
|
110
|
+
let workspace: string
|
|
111
|
+
try { workspace = cwdFor(ctx, sessionId) }
|
|
112
|
+
catch { ws.close(1008, 'session workspace unavailable'); return }
|
|
111
113
|
const watcher = chokidar.watch(workspace, { ignoreInitial: true, ignored: /(^|[/\\])(\.git|node_modules|\.next|\.nuxt|\.turbo|\.vite|coverage)([/\\]|$)/, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 25 } })
|
|
112
114
|
let paths = new Set<string>(); let timer: ReturnType<typeof setTimeout> | undefined
|
|
113
115
|
watcher.on('all', (_event, path) => {
|
package/src/terminal.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { IPty } from 'node-pty'
|
|
|
8
8
|
import { WebSocket, WebSocketServer } from 'ws'
|
|
9
9
|
import type { HostContext } from './context.ts'
|
|
10
10
|
import { cwdFor } from './host.ts'
|
|
11
|
-
import { readBody, send, sendError } from './wire.ts'
|
|
11
|
+
import { authorize, readBody, send, sendError } from './wire.ts'
|
|
12
12
|
|
|
13
13
|
export interface TerminalInfo { id: string; title: string; cwd: string; running: boolean; exitCode?: number; sequence: number }
|
|
14
14
|
export interface TerminalOutput { sequence: number; data: string }
|
|
@@ -99,6 +99,7 @@ function terminalRequestAllowed(req: IncomingMessage): boolean {
|
|
|
99
99
|
export function installTerminal(ctx: HostContext): void {
|
|
100
100
|
const manager = new TerminalManager()
|
|
101
101
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-vscode-terminal/api', handler: async (req, res) => {
|
|
102
|
+
if (!authorize(req, res, ctx.connection)) return
|
|
102
103
|
if (!terminalRequestAllowed(req)) return send(res, 403, { ok: false, error: { message: 'forbidden' } })
|
|
103
104
|
if (req.method !== 'POST') return send(res, 405, { ok: false, error: { message: 'POST required' } })
|
|
104
105
|
try {
|
|
@@ -119,13 +120,15 @@ export function installTerminal(ctx: HostContext): void {
|
|
|
119
120
|
|
|
120
121
|
const webSocket = new WebSocketServer({ noServer: true })
|
|
121
122
|
ctx.effect(() => ctx.webServer.registerUpgrade({ path: '/dsh-vscode-terminal/ws', handler(req, socket, head) {
|
|
122
|
-
if (!terminalRequestAllowed(req
|
|
123
|
+
if (ctx.connection.requestRejection(req) !== undefined || !terminalRequestAllowed(req)) { socket.destroy(); return }
|
|
123
124
|
webSocket.handleUpgrade(req as IncomingMessage, socket as Duplex, head, connection => {
|
|
124
125
|
const url = new URL(req.url ?? '/', 'http://dsh.local')
|
|
125
126
|
const sessionId = url.searchParams.get('sessionId')
|
|
126
127
|
const id = url.searchParams.get('id')
|
|
127
128
|
if (sessionId === null || id === null) { connection.close(1008, 'sessionId and id required'); return }
|
|
128
|
-
|
|
129
|
+
let handle: TerminalHandle | undefined
|
|
130
|
+
try { handle = manager.get(cwdFor(ctx, sessionId), id) }
|
|
131
|
+
catch { connection.close(1008, 'session workspace unavailable'); return }
|
|
129
132
|
if (handle === undefined) { connection.close(1008, 'terminal not found'); return }
|
|
130
133
|
if (handle.replay !== '') connection.send(JSON.stringify({ type: 'replay', sequence: handle.info.sequence, data: handle.replay }))
|
|
131
134
|
const listener = (output: TerminalOutput) => { if (connection.readyState === WebSocket.OPEN) connection.send(JSON.stringify({ type: 'output', ...output })) }
|
package/src/wire.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import type { WorkbenchConnection } from './context.ts'
|
|
2
3
|
|
|
3
4
|
export class WorkbenchError extends Error {
|
|
4
5
|
constructor(public code: string, message: string, public status = 400) { super(message) }
|
|
@@ -49,14 +50,11 @@ export function sendError(res: ServerResponse, error: unknown): void {
|
|
|
49
50
|
send(res, known.status, { ok: false, error: { code: known.code, message: known.message } })
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const origin = req.headers.origin
|
|
60
|
-
if (origin === undefined) return true
|
|
61
|
-
try { return new URL(origin).host === hostUrl.host } catch { return false }
|
|
53
|
+
/** Reuse Harness authentication and its deployment-aware Host/Origin fence. */
|
|
54
|
+
export function authorize(req: IncomingMessage, res: ServerResponse, connection: WorkbenchConnection): boolean {
|
|
55
|
+
const rejection = connection.requestRejection(req)
|
|
56
|
+
if (rejection === undefined) return true
|
|
57
|
+
const code = rejection === 401 ? 'unauthorized' : 'forbidden'
|
|
58
|
+
send(res, rejection, { ok: false, error: { code, message: code } })
|
|
59
|
+
return false
|
|
62
60
|
}
|