dsh-vscode-workbench 0.1.1 → 0.1.3

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.
@@ -8,6 +8,19 @@ declare global {
8
8
  monaco?: Monaco;
9
9
  }
10
10
  }
11
+ /** Monaco chrome used by both editors so only the narrow native scrollbar remains. */
12
+ export declare const WORKBENCH_EDITOR_CHROME: {
13
+ readonly minimap: {
14
+ readonly enabled: false;
15
+ };
16
+ readonly renderOverviewRuler: false;
17
+ readonly overviewRulerLanes: 0;
18
+ readonly hideCursorInOverviewRuler: true;
19
+ readonly scrollbar: {
20
+ readonly verticalScrollbarSize: 10;
21
+ readonly horizontalScrollbarSize: 10;
22
+ };
23
+ };
11
24
  export declare function loadMonaco(): Promise<Monaco>;
12
25
  export declare function MonacoDiffEditor(props: {
13
26
  uri: string;
@@ -15,14 +15,20 @@ interface Tab extends FileDocument {
15
15
  }
16
16
  /** Return the basename shown in a tab while preserving its full internal path. */
17
17
  export declare function tabLabel(tab: Pick<Tab, 'path' | 'title' | 'diffSourcePath'>): string;
18
- interface GraphRow {
18
+ export interface GraphTrack {
19
+ hash: string;
20
+ color: number;
21
+ }
22
+ export interface GraphRow {
19
23
  commit: GitCommit;
20
24
  lane: number;
21
- before: string[];
22
- after: string[];
25
+ before: GraphTrack[];
26
+ after: GraphTrack[];
23
27
  parentLanes: number[];
24
28
  }
25
29
  export declare function graphRows(commits: GitCommit[]): GraphRow[];
30
+ /** Draw a fixed-radius, orthogonal transition between two graph lanes. */
31
+ export declare function graphTrackPath(from: number, to: number, startY: number, endY: number, bendY: number): string;
26
32
  export declare function Launcher({ wide, service }: {
27
33
  wide: boolean;
28
34
  service: VscodeWorkbench;
@@ -0,0 +1,3 @@
1
+ import type { VscodeWorkbench } from './service.ts';
2
+ /** Handle terminal shortcuts through contributions registered by a terminal adapter. */
3
+ export declare function handleTerminalShortcut(event: KeyboardEvent, workbench: VscodeWorkbench): boolean;
@@ -0,0 +1,20 @@
1
+ /** Values cleared before another workspace starts loading. */
2
+ export interface EmptyWorkspaceDocuments<T> {
3
+ tabs: T[];
4
+ active: undefined;
5
+ error: undefined;
6
+ }
7
+ /** Remove editor state that belongs to the previous workspace. */
8
+ export declare function resetWorkspaceDocuments<T>(): EmptyWorkspaceDocuments<T>;
9
+ /** Identifies responses that still belong to the active workspace request. */
10
+ export declare class WorkspaceRequestScope {
11
+ private epoch;
12
+ /** Begin loading another workspace and invalidate earlier work. */
13
+ begin(): number;
14
+ /** Return the active workspace request identifier. */
15
+ current(): number;
16
+ /** Invalidate an active request while preserving any newer request. */
17
+ invalidate(epoch: number): void;
18
+ /** Return whether a response may update the current workspace. */
19
+ isCurrent(epoch: number): boolean;
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-workbench",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Explorer, Monaco editor, search and local Git overlay for DSH",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,6 +3,15 @@ import { useEffect, useRef, useState } from 'react'
3
3
  type Monaco = typeof import('monaco-editor')
4
4
  declare global { interface Window { require?: { (ids: string[], done: () => void): void; config(value: unknown): void }; monaco?: Monaco } }
5
5
  let loading: Promise<Monaco> | undefined
6
+ /** Monaco chrome used by both editors so only the narrow native scrollbar remains. */
7
+ export const WORKBENCH_EDITOR_CHROME = {
8
+ minimap: { enabled: false },
9
+ renderOverviewRuler: false,
10
+ overviewRulerLanes: 0,
11
+ hideCursorInOverviewRuler: true,
12
+ scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 },
13
+ } as const
14
+
6
15
  export function loadMonaco(): Promise<Monaco> {
7
16
  loading ??= new Promise((resolve, reject) => {
8
17
  const start = () => {
@@ -19,7 +28,7 @@ export function loadMonaco(): Promise<Monaco> {
19
28
 
20
29
  export function MonacoDiffEditor(props: { uri: string; original: string; modified: string; mode: 'inline' | 'split'; theme: 'dark' | 'light' }) {
21
30
  const root=useRef<HTMLDivElement>(null);const editor=useRef<import('monaco-editor').editor.IStandaloneDiffEditor>();const models=useRef<{original:import('monaco-editor').editor.ITextModel;modified:import('monaco-editor').editor.ITextModel}>();const [error,setError]=useState<string>()
22
- useEffect(()=>{let disposed=false;void loadMonaco().then(monaco=>{if(disposed||root.current===null)return;const safe=props.uri.replace(/^\/+/, '');const original=monaco.editor.createModel(props.original,undefined,monaco.Uri.parse(`dvw-diff-original:///${safe}`));const modified=monaco.editor.createModel(props.modified,undefined,monaco.Uri.parse(`dvw-diff-modified:///${safe}`));models.current={original,modified};const instance=monaco.editor.createDiffEditor(root.current,{automaticLayout:true,readOnly:true,renderSideBySide:props.mode==='split',theme:props.theme==='dark'?'vs-dark':'vs',fontSize:14,minimap:{enabled:false},originalEditable:false,renderOverviewRuler:true,ignoreTrimWhitespace:false});instance.setModel({original,modified});editor.current=instance}).catch(error=>setError(error instanceof Error?error.message:String(error)));return()=>{disposed=true;editor.current?.dispose();editor.current=undefined;models.current?.original.dispose();models.current?.modified.dispose();models.current=undefined}},[props.uri])
31
+ useEffect(()=>{let disposed=false;void loadMonaco().then(monaco=>{if(disposed||root.current===null)return;const safe=props.uri.replace(/^\/+/, '');const original=monaco.editor.createModel(props.original,undefined,monaco.Uri.parse(`dvw-diff-original:///${safe}`));const modified=monaco.editor.createModel(props.modified,undefined,monaco.Uri.parse(`dvw-diff-modified:///${safe}`));models.current={original,modified};const instance=monaco.editor.createDiffEditor(root.current,{automaticLayout:true,readOnly:true,renderSideBySide:props.mode==='split',theme:props.theme==='dark'?'vs-dark':'vs',fontSize:14,...WORKBENCH_EDITOR_CHROME,originalEditable:false,ignoreTrimWhitespace:false});instance.setModel({original,modified});editor.current=instance}).catch(error=>setError(error instanceof Error?error.message:String(error)));return()=>{disposed=true;editor.current?.dispose();editor.current=undefined;models.current?.original.dispose();models.current?.modified.dispose();models.current=undefined}},[props.uri])
23
32
  useEffect(()=>{const value=models.current;if(value===undefined)return;if(value.original.getValue()!==props.original)value.original.setValue(props.original);if(value.modified.getValue()!==props.modified)value.modified.setValue(props.modified)},[props.original,props.modified])
24
33
  useEffect(()=>{editor.current?.updateOptions({renderSideBySide:props.mode==='split'})},[props.mode])
25
34
  useEffect(()=>{if(window.monaco!==undefined)window.monaco.editor.setTheme(props.theme==='dark'?'vs-dark':'vs')},[props.theme])
@@ -39,7 +48,7 @@ export function MonacoEditor(props: { uri: string; value: string; theme: 'dark'
39
48
  const uri = monaco.Uri.parse(`file:///${props.uri.replace(/^\/+/, '')}`)
40
49
  model = monaco.editor.getModel(uri) ?? monaco.editor.createModel(props.value, undefined, uri)
41
50
  if (model.getValue() !== props.value) model.setValue(props.value)
42
- const instance = monaco.editor.create(root.current, { model, automaticLayout: true, theme: props.theme === 'dark' ? 'vs-dark' : 'vs', fontSize: 14, minimap: { enabled: true }, wordWrap: 'off' })
51
+ const instance = monaco.editor.create(root.current, { model, automaticLayout: true, theme: props.theme === 'dark' ? 'vs-dark' : 'vs', fontSize: 14, ...WORKBENCH_EDITOR_CHROME, wordWrap: 'off' })
43
52
  editor.current = instance
44
53
  const change = instance.onDidChangeModelContent(() => callbacks.current.onChange(instance.getValue()))
45
54
  instance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => callbacks.current.onSave())
@@ -9,6 +9,7 @@ import type { MarkdownHeading } from './MarkdownPreview.tsx'
9
9
  import { MonacoDiffEditor, MonacoEditor } from './MonacoEditor.tsx'
10
10
  import { DshConversationPanel, DshConversationTheme } from './DshConversationPanel.tsx'
11
11
  import { DshMark } from './DshMark.tsx'
12
+ import { resetWorkspaceDocuments, WorkspaceRequestScope } from './workspaceState.ts'
12
13
 
13
14
  interface Tab extends FileDocument { dirty: boolean; draft: string; line?: number; column?: number; external?: boolean; preview?: boolean; title?: string; diff?: GitDiff; diffMode?: 'inline' | 'split'; diffSourcePath?:string; imageUrl?:string }
14
15
  type WorkbenchTheme = 'dsh' | 'dark' | 'light'
@@ -73,38 +74,55 @@ function Search(props: { sessionId: string; onOpen(path: string, line: number, c
73
74
  return <><div className="dvw-side-head">搜索</div><form className="dvw-search-form" onSubmit={event => { event.preventDefault(); void search() }}><input className="dvw-input" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索" autoFocus/><div className="dvw-options">{([['caseSensitive','Aa'],['wholeWord','Ab'],['regex','.*']] as const).map(([key,label]) => <label key={key}><input type="checkbox" checked={flags[key]} onChange={e => setFlags({ ...flags, [key]: e.target.checked })}/>{label}</label>)}<button className="dvw-icon" title="搜索" disabled={busy}><Icon name="search"/></button></div><input className="dvw-input" value={include} onChange={e=>setInclude(e.target.value)} placeholder="包含文件,例如 src/**"/><input className="dvw-input" value={exclude} onChange={e=>setExclude(e.target.value)} placeholder="排除文件,例如 **/*.test.ts"/></form>{error&&<div className="dvw-error">{error}</div>}{limited&&<div className="dvw-message">结果已截断到 2000 条</div>}<div className="dvw-results">{[...groups].map(([path, rows]) => <div key={path}><div className="dvw-result-file"><Icon name="chevron-down"/> {path} <span className="dvw-badge">{rows.length}</span></div>{rows.map((row,index)=><div className="dvw-result" key={`${row.line}:${row.column}:${index}`} onClick={()=>props.onOpen(path,row.line,row.column)}><span className="dvw-line">{row.line}</span><span className="dvw-preview">{row.preview}</span></div>)}</div>)}</div></>
74
75
  }
75
76
 
76
- interface GraphRow { commit: GitCommit; lane: number; before: string[]; after: string[]; parentLanes: number[] }
77
+ export interface GraphTrack { hash: string; color: number }
78
+ export interface GraphRow { commit: GitCommit; lane: number; before: GraphTrack[]; after: GraphTrack[]; parentLanes: number[] }
77
79
  export function graphRows(commits: GitCommit[]): GraphRow[] {
78
- const visible=new Set(commits.map(commit=>commit.hash))
79
- let lanes: string[] = commits[0] ? [commits[0].hash] : []
80
+ const visible = new Set(commits.map(commit => commit.hash))
81
+ let nextColor = 0
82
+ let lanes: GraphTrack[] = commits[0] ? [{ hash: commits[0].hash, color: nextColor++ }] : []
80
83
  return commits.map(commit => {
81
- let lane = lanes.indexOf(commit.hash)
82
- if (lane < 0) { lanes=[commit.hash,...lanes]; lane = 0 }
83
- const before=[...lanes];const after=before.filter(hash=>hash!==commit.hash)
84
- const parents=commit.parents.filter(parent=>visible.has(parent));parents.forEach((parent,index)=>{if(!after.includes(parent))after.splice(Math.min(lane+index,after.length),0,parent)})
85
- const parentLanes=parents.map(parent=>after.indexOf(parent)).filter(index=>index>=0)
86
- lanes=after
87
- return {commit,lane,before,after,parentLanes}
84
+ let lane = lanes.findIndex(track => track.hash === commit.hash)
85
+ if (lane < 0) { lanes = [{ hash: commit.hash, color: nextColor++ }, ...lanes]; lane = 0 }
86
+ const before = [...lanes]
87
+ const current = before[lane]!
88
+ const after = before.filter(track => track.hash !== commit.hash)
89
+ const parents = commit.parents.filter(parent => visible.has(parent))
90
+ parents.forEach((parent, index) => {
91
+ if (after.some(track => track.hash === parent)) return
92
+ const color = index === 0 ? current.color : nextColor++
93
+ after.splice(Math.min(lane + index, after.length), 0, { hash: parent, color })
94
+ })
95
+ const parentLanes = parents.map(parent => after.findIndex(track => track.hash === parent)).filter(index => index >= 0)
96
+ lanes = after
97
+ return { commit, lane, before, after, parentLanes }
88
98
  })
89
99
  }
90
100
 
91
101
  const GRAPH_COLORS = ['#4c8dff','#d94f9f','#4fc1b0','#d9a441','#9b7bea']
92
- function graphColor(lane:number):string{return GRAPH_COLORS[lane%GRAPH_COLORS.length]!}
102
+ function graphColor(color:number):string{return GRAPH_COLORS[color%GRAPH_COLORS.length]!}
93
103
  function graphWidth(row:GraphRow):number{return Math.max(row.before.length,row.after.length,row.lane+1,1)*14+8}
94
104
 
105
+ /** Draw a fixed-radius, orthogonal transition between two graph lanes. */
106
+ export function graphTrackPath(from: number, to: number, startY: number, endY: number, bendY: number): string {
107
+ if (from === to) return `M ${from} ${startY} V ${endY}`
108
+ const direction = Math.sign(to - from)
109
+ const radius = Math.min(5, Math.abs(to - from) / 2, Math.abs(bendY - startY), Math.abs(endY - bendY))
110
+ return `M ${from} ${startY} V ${bendY - radius} Q ${from} ${bendY} ${from + direction * radius} ${bendY} H ${to - direction * radius} Q ${to} ${bendY} ${to} ${bendY + radius} V ${endY}`
111
+ }
112
+
95
113
  function GraphTracks({row}:{row:GraphRow}) {
96
- const width=graphWidth(row);const x=row.lane*14+8
114
+ const width=graphWidth(row);const x=row.lane*14+8;const current=row.before[row.lane]!
97
115
  return <svg className="dvw-graph-svg" width={width} height="38" viewBox={`0 0 ${width} 38`} aria-hidden="true">
98
- {row.before.map((hash,lane)=>{if(hash===row.commit.hash)return null;const target=row.after.indexOf(hash);const nextLane=target>=0?target:lane;const next=nextLane*14+8;const start=lane*14+8;return <path key={`continue-${hash}`} d={`M ${start} 0 C ${start} 14 ${next} 24 ${next} 38`} fill="none" stroke={graphColor(nextLane)} strokeWidth="2"/>})}
99
- <path d={`M ${x} 0 V 19`} fill="none" stroke={graphColor(row.lane)} strokeWidth="2"/>
100
- {row.parentLanes.map((parent,index)=>{const target=parent*14+8;return <path key={`parent-${parent}-${index}`} d={`M ${x} 19 C ${x} 27 ${target} 30 ${target} 38`} fill="none" stroke={graphColor(parent)} strokeWidth="2"/>})}
101
- <circle cx={x} cy="19" r="5" fill="var(--dvw-surface-1)" stroke={graphColor(row.lane)} strokeWidth="2"/>
116
+ {row.before.map((track,lane)=>{if(track.hash===row.commit.hash)return null;const target=row.after.findIndex(next=>next.hash===track.hash);const nextLane=target>=0?target:lane;return <path key={`continue-${track.hash}`} d={graphTrackPath(lane*14+8,nextLane*14+8,0,38,19)} fill="none" stroke={graphColor(track.color)} strokeWidth="2" vectorEffect="non-scaling-stroke"/>})}
117
+ <path d={`M ${x} 0 V 19`} fill="none" stroke={graphColor(current.color)} strokeWidth="2" vectorEffect="non-scaling-stroke"/>
118
+ {row.parentLanes.map((parent,index)=>{const target=parent*14+8;const track=row.after[parent]!;return <path key={`parent-${track.hash}-${index}`} d={graphTrackPath(x,target,19,38,29)} fill="none" stroke={graphColor(track.color)} strokeWidth="2" vectorEffect="non-scaling-stroke"/>})}
119
+ <circle cx={x} cy="19" r="5" fill="var(--dvw-surface-1)" stroke={graphColor(current.color)} strokeWidth="2" vectorEffect="non-scaling-stroke"/>
102
120
  </svg>
103
121
  }
104
122
 
105
123
  function GraphContinuation({row}:{row:GraphRow}) {
106
124
  const width=graphWidth(row)
107
- return <svg className="dvw-graph-continuation" width={width} height="100%" viewBox={`0 0 ${width} 100`} preserveAspectRatio="none" aria-hidden="true">{row.after.map((hash,lane)=><line key={hash} x1={lane*14+8} y1="0" x2={lane*14+8} y2="100" stroke={graphColor(lane)} strokeWidth="2" vectorEffect="non-scaling-stroke"/>)}</svg>
125
+ return <svg className="dvw-graph-continuation" width={width} height="100%" viewBox={`0 0 ${width} 100`} preserveAspectRatio="none" aria-hidden="true">{row.after.map((track,lane)=><line key={track.hash} x1={lane*14+8} y1="0" x2={lane*14+8} y2="100" stroke={graphColor(track.color)} strokeWidth="2" vectorEffect="non-scaling-stroke"/>)}</svg>
108
126
  }
109
127
 
110
128
  function CommitGraph({ commits, expanded, files, onToggle, onOpenFile }: { commits: GitCommit[]; expanded?: string; files: Map<string,GitCommitFile[]>; onToggle(commit:GitCommit):void; onOpenFile(commit:GitCommit,file:GitCommitFile):void }) {
@@ -142,6 +160,8 @@ export function Workbench({ service, useSessions }: { service: VscodeWorkbench;
142
160
  useEffect(() => service.subscribe(() => { setSnapshot(service.getSnapshot()); setPanels(service.getPanels()) }), [service])
143
161
  const sessionId = useSessions(state=>state.current) as string|undefined; const cwd = useSessions(state=>sessionId===undefined?undefined:state.byId[sessionId]?.cwd) as string|undefined
144
162
  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')
163
+ const tabsRef=useRef(tabs);tabsRef.current=tabs
164
+ const workspaceRequests=useRef(new WorkspaceRequestScope())
145
165
  const dshDark = useDshDarkTheme()
146
166
  const [theme,setTheme]=useState<WorkbenchTheme>(()=>{const saved=localStorage.getItem('dvw:theme');return saved==='dark'||saved==='light'?saved:'dsh'})
147
167
  const colorScheme: 'dark' | 'light' = theme === 'dsh' ? dshDark ? 'dark' : 'light' : theme
@@ -152,10 +172,11 @@ export function Workbench({ service, useSessions }: { service: VscodeWorkbench;
152
172
  useEffect(()=>localStorage.setItem('dvw:primary-side-visible',primarySideVisible?'open':'closed'),[primarySideVisible])
153
173
  useEffect(()=>localStorage.setItem('dvw:status-visible',statusVisible?'open':'closed'),[statusVisible])
154
174
  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])
155
- useEffect(()=>{if(!snapshot.visible||sessionId===undefined)return;void api<{cwd:string;name:string}>('workspace',{sessionId}).then(value=>{setWorkspace(value);const saved=localStorage.getItem(`dvw:${value.cwd}`);if(saved){try{const parsed=JSON.parse(saved) as {view?:WorkbenchView;paths?: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))));for(const path of parsed.paths??[])void open(path)}catch{}}}).catch(error=>setError(String(error)))},[snapshot.visible,sessionId])
175
+ const openForWorkspace=async(targetSessionId:string,epoch:number,path:string,line?:number,column?:number)=>{if(!workspaceRequests.current.isCurrent(epoch))return;const existing=tabsRef.current.find(tab=>tab.path===path);if(existing){setActive(path);setTabs(rows=>rows.map(tab=>tab.path===path?{...tab,line,column}:tab));return}if(/\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i.test(path)){const imageUrl=`/dsh-vscode/file?sessionId=${encodeURIComponent(targetSessionId)}&path=${encodeURIComponent(path)}`;if(!workspaceRequests.current.isCurrent(epoch))return;setTabs(rows=>workspaceRequests.current.isCurrent(epoch)?[...rows.filter(tab=>tab.path!==path),{path,content:'',draft:'',revision:'',size:0,binary:false,tooLarge:false,dirty:false,imageUrl}]:rows);setActive(path);return}try{const doc=await api<FileDocument>('fs.read',{sessionId:targetSessionId,path});if(!workspaceRequests.current.isCurrent(epoch))return;if(doc.binary||doc.tooLarge){setError(doc.binary?'二进制文件不能在文本编辑器中打开':`文件超过 5 MiB:${path}`);return}setTabs(rows=>workspaceRequests.current.isCurrent(epoch)?[...rows.filter(tab=>tab.path!==path),{...doc,draft:doc.content,dirty:false,line,column,preview:/\.md$/i.test(path)}]:rows);setActive(path)}catch(error){if(workspaceRequests.current.isCurrent(epoch))setError(String(error))}}
176
+ useEffect(()=>{if(!snapshot.visible||sessionId===undefined)return;const epoch=workspaceRequests.current.begin();const cleared=resetWorkspaceDocuments<Tab>();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[];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))));for(const path of parsed.paths??[])void openForWorkspace(sessionId,epoch,path)}catch{}}}).catch(error=>{if(!controller.signal.aborted&&workspaceRequests.current.isCurrent(epoch))setError(String(error))});return()=>{controller.abort();workspaceRequests.current.invalidate(epoch)}},[snapshot.visible,sessionId])
156
177
  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),panelHeight,sideWidth}))},[workspace,snapshot.view,tabs,panelHeight,sideWidth])
157
178
  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=()=>{window.dispatchEvent(new Event('dvw-files-changed'));setTabs(current=>current.map(tab=>tab.dirty?{...tab,external:true}:tab))};return()=>ws.close()},[snapshot.visible,sessionId])
158
- const open=async(path:string,line?:number,column?:number)=>{if(sessionId===undefined)return;const existing=tabs.find(t=>t.path===path);if(existing){setActive(path);setTabs(rows=>rows.map(t=>t.path===path?{...t,line,column}:t));return}if(/\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i.test(path)){const imageUrl=`/dsh-vscode/file?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`;setTabs(rows=>[...rows,{path,content:'',draft:'',revision:'',size:0,binary:false,tooLarge:false,dirty:false,imageUrl}]);setActive(path);return}try{const doc=await api<FileDocument>('fs.read',{sessionId,path});if(doc.binary||doc.tooLarge){setError(doc.binary?'二进制文件不能在文本编辑器中打开':`文件超过 5 MiB:${path}`);return}setTabs(rows=>[...rows,{...doc,draft:doc.content,dirty:false,line,column,preview:/\.md$/i.test(path)}]);setActive(path)}catch(error){setError(String(error))}}
179
+ const open=async(path:string,line?:number,column?:number)=>{if(sessionId===undefined)return;await openForWorkspace(sessionId,workspaceRequests.current.current(),path,line,column)}
159
180
  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])
160
181
  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))}}
161
182
  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)}
@@ -8,13 +8,17 @@ import type { VscodeWorkbench } from './service.ts'
8
8
  import { WorkbenchController } from './service.ts'
9
9
  import { Launcher, Workbench } from './Workbench.tsx'
10
10
  import { installStyles } from './styles.ts'
11
+ import { handleTerminalShortcut } from './shortcuts.ts'
11
12
 
12
13
  export type { BottomPanelContribution, BottomPanelProps, VscodeWorkbench, WorkbenchCommand, WorkbenchSnapshot, WorkbenchView } from './service.ts'
13
14
 
14
15
  declare module '@deepseek-ai/cordis' { interface Context { vscodeWorkbench: VscodeWorkbench } }
15
16
  declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotInjectMap { 'dsh-vscode-workbench.overlay': { service: VscodeWorkbench }; 'dsh-vscode-workbench.launcher': { service: VscodeWorkbench } } }
16
17
 
17
- const Overlay = ({ service, useSessions }: PropsRuntime<'shell.overlay'> & { service: VscodeWorkbench }) => <Workbench service={service} useSessions={useSessions as never}/>
18
+ const Overlay = ({ service, useSessions }: PropsRuntime<'shell.overlay'> & { service: VscodeWorkbench }) => {
19
+ const sessionId = useSessions(state => state.current) as string | undefined
20
+ return <Workbench key={sessionId ?? 'no-session'} service={service} useSessions={useSessions as never}/>
21
+ }
18
22
  const Footer = ({ service, wide }: PropsRuntime<'sidebar.footer.action'> & { service: VscodeWorkbench }) => {
19
23
  const marker=useRef<HTMLSpanElement>(null);const [target,setTarget]=useState<HTMLElement|null>()
20
24
  useLayoutEffect(()=>{let container=marker.current?.parentElement;while(container?.previousElementSibling===null)container=container.parentElement;const region=container?.previousElementSibling;if(!(region instanceof HTMLElement)){setTarget(null);return}const mount=document.createElement('div');mount.className='dvw-sidebar-launcher-mount';region.prepend(mount);setTarget(mount);return()=>mount.remove()},[])
@@ -30,6 +34,7 @@ export function apply(ctx: ClientContext): void {
30
34
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-vscode-workbench-overlay', inject: () => ({ service }) }, Overlay))
31
35
  ctx.effect(() => {
32
36
  const onKey = (event: KeyboardEvent) => {
37
+ if (handleTerminalShortcut(event, service)) return
33
38
  if (event.isComposing || !event.ctrlKey || event.altKey) return
34
39
  const view = event.shiftKey && event.code === 'KeyE' ? 'explorer' : event.shiftKey && event.code === 'KeyF' ? 'search' : event.shiftKey && event.code === 'KeyG' ? 'git' : undefined
35
40
  if (view !== undefined) { event.preventDefault(); service.show(view) }
@@ -0,0 +1,17 @@
1
+ import type { VscodeWorkbench } from './service.ts'
2
+
3
+ /** Handle terminal shortcuts through contributions registered by a terminal adapter. */
4
+ export function handleTerminalShortcut(event: KeyboardEvent, workbench: VscodeWorkbench): boolean {
5
+ if (event.isComposing || !event.ctrlKey || event.altKey || event.code !== 'Backquote') return false
6
+ if (!workbench.getPanels().some(panel => panel.id === 'terminal')) return false
7
+ event.preventDefault()
8
+ event.stopImmediatePropagation()
9
+ workbench.show()
10
+ if (event.shiftKey) {
11
+ if (workbench.getSnapshot().bottomPanel !== 'terminal') workbench.toggleBottomPanel('terminal')
12
+ queueMicrotask(() => workbench.runCommand('terminal.new'))
13
+ } else {
14
+ workbench.toggleBottomPanel('terminal')
15
+ }
16
+ return true
17
+ }
@@ -0,0 +1,37 @@
1
+ /** Values cleared before another workspace starts loading. */
2
+ export interface EmptyWorkspaceDocuments<T> {
3
+ tabs: T[]
4
+ active: undefined
5
+ error: undefined
6
+ }
7
+
8
+ /** Remove editor state that belongs to the previous workspace. */
9
+ export function resetWorkspaceDocuments<T>(): EmptyWorkspaceDocuments<T> {
10
+ return { tabs: [], active: undefined, error: undefined }
11
+ }
12
+
13
+ /** Identifies responses that still belong to the active workspace request. */
14
+ export class WorkspaceRequestScope {
15
+ private epoch = 0
16
+
17
+ /** Begin loading another workspace and invalidate earlier work. */
18
+ begin(): number {
19
+ this.epoch += 1
20
+ return this.epoch
21
+ }
22
+
23
+ /** Return the active workspace request identifier. */
24
+ current(): number {
25
+ return this.epoch
26
+ }
27
+
28
+ /** Invalidate an active request while preserving any newer request. */
29
+ invalidate(epoch: number): void {
30
+ if (this.epoch === epoch) this.epoch += 1
31
+ }
32
+
33
+ /** Return whether a response may update the current workspace. */
34
+ isCurrent(epoch: number): boolean {
35
+ return epoch === this.epoch
36
+ }
37
+ }