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.
- package/README.en.md +3 -11
- package/README.md +3 -12
- package/lib/client.js +192 -84
- package/lib/client.js.map +1 -1
- package/lib/types/client/MonacoEditor.d.ts +13 -0
- package/lib/types/client/Workbench.d.ts +9 -3
- package/lib/types/client/shortcuts.d.ts +3 -0
- package/lib/types/client/workspaceState.d.ts +20 -0
- package/package.json +1 -1
- package/src/client/MonacoEditor.tsx +11 -2
- package/src/client/Workbench.tsx +40 -19
- package/src/client/index.tsx +6 -1
- package/src/client/shortcuts.ts +17 -0
- package/src/client/workspaceState.ts +37 -0
|
@@ -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
|
|
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:
|
|
22
|
-
after:
|
|
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,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
|
@@ -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,
|
|
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,
|
|
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())
|
package/src/client/Workbench.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
82
|
-
if (lane < 0) { lanes=[commit.hash
|
|
83
|
-
const before=[...lanes]
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
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((
|
|
99
|
-
<path d={`M ${x} 0 V 19`} fill="none" stroke={graphColor(
|
|
100
|
-
{row.parentLanes.map((parent,index)=>{const target=parent*14+8;return <path key={`parent-${
|
|
101
|
-
<circle cx={x} cy="19" r="5" fill="var(--dvw-surface-1)" stroke={graphColor(
|
|
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((
|
|
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
|
-
|
|
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;
|
|
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)}
|
package/src/client/index.tsx
CHANGED
|
@@ -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 }) =>
|
|
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
|
+
}
|