dsh-vscode-workbench 0.1.6 → 0.1.8
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 +16 -2
- package/README.md +18 -2
- package/lib/client.js +45249 -3409
- package/lib/client.js.map +1 -1
- package/lib/index.js +252 -10
- package/lib/index.js.map +1 -1
- package/lib/types/client/Icon.d.ts +1 -1
- package/lib/types/client/TerminalPanel.d.ts +3 -0
- package/lib/types/client/Workbench.d.ts +6 -0
- package/lib/types/client/documentPreview.d.ts +11 -0
- package/lib/types/client/shortcuts.d.ts +4 -0
- package/lib/types/client/terminal.d.ts +43 -0
- package/lib/types/host.d.ts +10 -0
- package/lib/types/terminal.d.ts +41 -0
- package/package.json +6 -1
- package/src/client/Icon.tsx +1 -1
- package/src/client/TerminalPanel.tsx +90 -0
- package/src/client/Workbench.tsx +64 -8
- package/src/client/documentPreview.tsx +122 -0
- package/src/client/index.tsx +3 -0
- package/src/client/shortcuts.ts +17 -1
- package/src/client/styles.ts +22 -4
- package/src/client/terminal.ts +83 -0
- package/src/host.ts +8 -0
- package/src/index.ts +16 -3
- package/src/terminal.ts +145 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FileDocument, GitCommit, GitDiff } from '../types.ts';
|
|
2
2
|
import type { VscodeWorkbench } from './service.ts';
|
|
3
|
+
import { type DocumentPreviewKind } from './documentPreview.tsx';
|
|
3
4
|
interface Tab extends FileDocument {
|
|
4
5
|
dirty: boolean;
|
|
5
6
|
draft: string;
|
|
@@ -13,7 +14,12 @@ interface Tab extends FileDocument {
|
|
|
13
14
|
diffMode?: 'inline' | 'split';
|
|
14
15
|
diffSourcePath?: string;
|
|
15
16
|
imageUrl?: string;
|
|
17
|
+
documentPreview?: DocumentPreviewKind;
|
|
18
|
+
documentUrl?: string;
|
|
16
19
|
}
|
|
20
|
+
type ResizeDirection = 1 | -1;
|
|
21
|
+
/** Calculate a panel width from a horizontal pointer movement. */
|
|
22
|
+
export declare function resizedPanelWidth(startWidth: number, startX: number, nextX: number, direction: ResizeDirection, min: number, max: number): number;
|
|
17
23
|
/** Restore tab headers without reading their file contents. */
|
|
18
24
|
export declare function restoreTabPaths(paths: string[]): Tab[];
|
|
19
25
|
/** Return whether file changes require reloading one visible directory. */
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type DocumentPreviewKind = 'pdf' | 'docx' | 'download';
|
|
2
|
+
/** Bound PDF zoom to values that remain usable in the workbench viewport. */
|
|
3
|
+
export declare function clampPdfScale(scale: number): number;
|
|
4
|
+
/** Return the in-workbench preview supported for a document path. */
|
|
5
|
+
export declare function documentPreviewKind(path: string): DocumentPreviewKind | undefined;
|
|
6
|
+
/** Render an in-workbench preview for PDF and Word documents. */
|
|
7
|
+
export declare function DocumentPreview({ kind, src, path }: {
|
|
8
|
+
kind: DocumentPreviewKind;
|
|
9
|
+
src: string;
|
|
10
|
+
path: string;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import type { VscodeWorkbench } from './service.ts';
|
|
2
|
+
/** Reveal the terminal panel supplied by a terminal adapter without toggling it closed. */
|
|
3
|
+
export declare function openTerminal(workbench: VscodeWorkbench): boolean;
|
|
4
|
+
/** Toggle the terminal panel from the activity bar. */
|
|
5
|
+
export declare function toggleTerminal(workbench: VscodeWorkbench): boolean;
|
|
2
6
|
/** Handle terminal shortcuts through contributions registered by a terminal adapter. */
|
|
3
7
|
export declare function handleTerminalShortcut(event: KeyboardEvent, workbench: VscodeWorkbench): boolean;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { TerminalInfo } from '../terminal.ts';
|
|
2
|
+
export interface TerminalConnection {
|
|
3
|
+
write(data: string): void;
|
|
4
|
+
resize(cols: number, rows: number): void;
|
|
5
|
+
close(): void;
|
|
6
|
+
subscribe(listener: (output: {
|
|
7
|
+
sequence: number;
|
|
8
|
+
data: string;
|
|
9
|
+
replay?: boolean;
|
|
10
|
+
}) => void): () => void;
|
|
11
|
+
}
|
|
12
|
+
export interface TerminalWorkspaceSnapshot {
|
|
13
|
+
terminals: readonly TerminalInfo[];
|
|
14
|
+
loading: boolean;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Browser client for the workbench's local terminal transport. */
|
|
18
|
+
export interface TerminalWorkspace {
|
|
19
|
+
refresh(): Promise<void>;
|
|
20
|
+
create(title?: string): Promise<TerminalInfo>;
|
|
21
|
+
close(id: string): Promise<void>;
|
|
22
|
+
rename(id: string, title: string): Promise<void>;
|
|
23
|
+
connect(id: string): TerminalConnection;
|
|
24
|
+
getSnapshot(): TerminalWorkspaceSnapshot;
|
|
25
|
+
subscribe(listener: () => void): () => void;
|
|
26
|
+
}
|
|
27
|
+
/** Identify automatic terminal-emulator replies that must not be echoed during replay. */
|
|
28
|
+
export declare function isTerminalProtocolReply(data: string): boolean;
|
|
29
|
+
/** Maintain one terminal collection for a DSH session. */
|
|
30
|
+
export declare class TerminalWorkspaceController implements TerminalWorkspace {
|
|
31
|
+
private readonly sessionId;
|
|
32
|
+
private snapshot;
|
|
33
|
+
private readonly listeners;
|
|
34
|
+
constructor(sessionId: string);
|
|
35
|
+
refresh(): Promise<void>;
|
|
36
|
+
create(title?: string): Promise<TerminalInfo>;
|
|
37
|
+
close(id: string): Promise<void>;
|
|
38
|
+
rename(id: string, title: string): Promise<void>;
|
|
39
|
+
connect(id: string): TerminalConnection;
|
|
40
|
+
getSnapshot(): TerminalWorkspaceSnapshot;
|
|
41
|
+
subscribe(listener: () => void): () => void;
|
|
42
|
+
private set;
|
|
43
|
+
}
|
package/lib/types/host.d.ts
CHANGED
|
@@ -19,6 +19,16 @@ export declare function workspaceImage(cwd: string, path: string): Promise<{
|
|
|
19
19
|
path: string;
|
|
20
20
|
type: string;
|
|
21
21
|
}>;
|
|
22
|
+
/** Resolve a supported preview file inside the workspace. */
|
|
23
|
+
export declare function workspacePreviewFile(cwd: string, path: string): Promise<{
|
|
24
|
+
path: string;
|
|
25
|
+
type: string;
|
|
26
|
+
}>;
|
|
27
|
+
/** Resolve the PDF.js worker shipped with this plugin's runtime dependency. */
|
|
28
|
+
export declare function pdfWorkerAsset(): {
|
|
29
|
+
path: string;
|
|
30
|
+
type: string;
|
|
31
|
+
};
|
|
22
32
|
interface ProcessResult {
|
|
23
33
|
stdout: string;
|
|
24
34
|
stderr: string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { IPty } from 'node-pty';
|
|
2
|
+
import type { HostContext } from './context.ts';
|
|
3
|
+
export interface TerminalInfo {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
running: boolean;
|
|
8
|
+
exitCode?: number;
|
|
9
|
+
sequence: number;
|
|
10
|
+
}
|
|
11
|
+
export interface TerminalOutput {
|
|
12
|
+
sequence: number;
|
|
13
|
+
data: string;
|
|
14
|
+
}
|
|
15
|
+
interface TerminalHandle {
|
|
16
|
+
info: TerminalInfo;
|
|
17
|
+
process: IPty;
|
|
18
|
+
replay: string;
|
|
19
|
+
listeners: Set<(output: TerminalOutput) => void>;
|
|
20
|
+
}
|
|
21
|
+
/** Remove credentials while retaining ordinary interactive-shell variables. */
|
|
22
|
+
export declare function sanitizedTerminalEnv(env: NodeJS.ProcessEnv): Record<string, string>;
|
|
23
|
+
/** Select the user's interactive shell with platform-specific fallbacks. */
|
|
24
|
+
export declare function defaultTerminalShell(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): {
|
|
25
|
+
shell: string;
|
|
26
|
+
args: string[];
|
|
27
|
+
};
|
|
28
|
+
/** Own interactive terminals by their canonical workspace directory. */
|
|
29
|
+
export declare class TerminalManager {
|
|
30
|
+
private readonly workspaces;
|
|
31
|
+
private workspace;
|
|
32
|
+
list(cwd: string): TerminalInfo[];
|
|
33
|
+
create(cwd: string, title?: string): TerminalInfo;
|
|
34
|
+
get(cwd: string, id: string): TerminalHandle | undefined;
|
|
35
|
+
close(cwd: string, id: string): void;
|
|
36
|
+
rename(cwd: string, id: string, title: string): TerminalInfo;
|
|
37
|
+
dispose(): void;
|
|
38
|
+
}
|
|
39
|
+
/** Register the private local terminal API and WebSocket transport. */
|
|
40
|
+
export declare function installTerminal(ctx: HostContext): void;
|
|
41
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-workbench",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Explorer, Monaco editor, search and local Git overlay for DSH",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -56,10 +56,15 @@
|
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@vscode/codicons": "^0.0.36",
|
|
58
58
|
"@vscode/ripgrep": "^1.15.14",
|
|
59
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
60
|
+
"@xterm/xterm": "^5.5.0",
|
|
59
61
|
"chokidar": "^4.0.3",
|
|
62
|
+
"docx-preview": "^0.4.0",
|
|
60
63
|
"dompurify": "^3.2.6",
|
|
61
64
|
"marked": "^16.3.0",
|
|
62
65
|
"monaco-editor": "^0.52.2",
|
|
66
|
+
"node-pty": "^1.1.0",
|
|
67
|
+
"pdfjs-dist": "4.10.38",
|
|
63
68
|
"ws": "^8.18.0"
|
|
64
69
|
},
|
|
65
70
|
"peerDependencies": {
|
package/src/client/Icon.tsx
CHANGED
|
@@ -2,7 +2,7 @@ export type CodiconName =
|
|
|
2
2
|
| 'add' | 'check' | 'check-all' | 'chevron-down' | 'chevron-right' | 'close' | 'close-all' | 'cloud-upload' | 'code' | 'color-mode' | 'edit'
|
|
3
3
|
| 'diff' | 'diff-sidebyside' | 'diff-single' | 'discard' | 'file' | 'file-code' | 'file-media' | 'file-pdf' | 'file-zip'
|
|
4
4
|
| 'files' | 'folder' | 'folder-opened' | 'git-branch' | 'git-commit' | 'graph' | 'home' | 'list-tree' | 'new-file'
|
|
5
|
-
| 'layout-sidebar-right' | 'layout-sidebar-right-off' | 'new-folder' | 'preview' | 'refresh' | 'remove' | 'search' | 'settings' | 'source-control' | 'sparkle' | 'trash'
|
|
5
|
+
| 'layout-sidebar-right' | 'layout-sidebar-right-off' | 'new-folder' | 'preview' | 'refresh' | 'remove' | 'search' | 'settings' | 'source-control' | 'sparkle' | 'split-horizontal' | 'terminal' | 'trash'
|
|
6
6
|
|
|
7
7
|
/** Render one glyph from Microsoft's VS Code Codicon font. */
|
|
8
8
|
export function Icon({ name, className = '' }: { name: CodiconName; className?: string }) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
3
|
+
import { Terminal } from '@xterm/xterm'
|
|
4
|
+
import { Icon } from './Icon.tsx'
|
|
5
|
+
import type { BottomPanelProps } from './service.ts'
|
|
6
|
+
import { isTerminalProtocolReply, TerminalWorkspaceController } from './terminal.ts'
|
|
7
|
+
|
|
8
|
+
function terminalTheme(colorScheme: 'dark' | 'light') { return colorScheme === 'dark' ? { background: '#131417', foreground: '#f1f3f5', cursor: '#c2c6cc', selectionBackground: '#355d94' } : { background: '#ffffff', foreground: '#1d2025', cursor: '#4b515b', selectionBackground: '#add6ff' } }
|
|
9
|
+
|
|
10
|
+
function XtermView({ workspace, id, colorScheme }: { workspace: TerminalWorkspaceController; id: string; colorScheme: 'dark' | 'light' }) {
|
|
11
|
+
const root = useRef<HTMLDivElement>(null)
|
|
12
|
+
const terminalRef = useRef<Terminal>()
|
|
13
|
+
const replaying = useRef(false)
|
|
14
|
+
const replayGuard = useRef<ReturnType<typeof setTimeout>>()
|
|
15
|
+
const [error, setError] = useState<string>()
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (root.current === null) return
|
|
18
|
+
const terminal = new Terminal({ convertEol: false, cursorBlink: true, fontFamily: 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', fontSize: 13, theme: terminalTheme(colorScheme) })
|
|
19
|
+
terminalRef.current = terminal
|
|
20
|
+
const fit = new FitAddon()
|
|
21
|
+
terminal.loadAddon(fit)
|
|
22
|
+
terminal.open(root.current)
|
|
23
|
+
const connection = workspace.connect(id)
|
|
24
|
+
const output = connection.subscribe(value => {
|
|
25
|
+
if (value.replay) { replaying.current = true; clearTimeout(replayGuard.current) }
|
|
26
|
+
terminal.write(value.data, () => { if (value.replay) replayGuard.current = setTimeout(() => { replaying.current = false }, 250) })
|
|
27
|
+
})
|
|
28
|
+
const input = terminal.onData(data => { if (!replaying.current || !isTerminalProtocolReply(data)) connection.write(data) })
|
|
29
|
+
const resize = terminal.onResize(({ cols, rows }) => connection.resize(cols, rows))
|
|
30
|
+
const observer = new ResizeObserver(() => { try { fit.fit() } catch { /* The panel can be hidden during layout changes. */ } })
|
|
31
|
+
observer.observe(root.current)
|
|
32
|
+
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() }
|
|
34
|
+
}, [workspace, id])
|
|
35
|
+
useEffect(() => { if (terminalRef.current !== undefined) terminalRef.current.options.theme = terminalTheme(colorScheme) }, [colorScheme])
|
|
36
|
+
return error === undefined ? <div ref={root} className="dvw-terminal-host" /> : <div className="dvw-terminal-error">{error}</div>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Render interactive terminal tabs in the workbench bottom panel. */
|
|
40
|
+
export function TerminalPanel({ sessionId, colorScheme }: BottomPanelProps) {
|
|
41
|
+
const workspaceRef = useRef<{ sessionId: string; value: TerminalWorkspaceController }>()
|
|
42
|
+
if (workspaceRef.current?.sessionId !== sessionId) workspaceRef.current = { sessionId, value: new TerminalWorkspaceController(sessionId) }
|
|
43
|
+
const workspace = workspaceRef.current.value
|
|
44
|
+
const snapshot = useSyncExternalStore(workspace.subscribe.bind(workspace), workspace.getSnapshot.bind(workspace))
|
|
45
|
+
const [active, setActive] = useState<string>()
|
|
46
|
+
const [context, setContext] = useState<{ id: string; x: number; y: number }>()
|
|
47
|
+
const [renaming, setRenaming] = useState<{ id: string; value: string }>()
|
|
48
|
+
const [split, setSplit] = useState<readonly string[]>()
|
|
49
|
+
const [sideWidth, setSideWidth] = useState(220)
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
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) }) })
|
|
53
|
+
return () => { cancelled = true }
|
|
54
|
+
}, [workspace])
|
|
55
|
+
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])
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (context === undefined) return
|
|
59
|
+
const close = () => setContext(undefined)
|
|
60
|
+
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') close() }
|
|
61
|
+
window.addEventListener('pointerdown', close)
|
|
62
|
+
window.addEventListener('keydown', escape)
|
|
63
|
+
return () => { window.removeEventListener('pointerdown', close); window.removeEventListener('keydown', escape) }
|
|
64
|
+
}, [context])
|
|
65
|
+
const closeTerminal = (id: string) => { setContext(undefined); setSplit(current => current?.filter(value => value !== id)); void workspace.close(id) }
|
|
66
|
+
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) }) }
|
|
68
|
+
const shown = (split ?? (active === undefined ? [] : [active])).filter(id => snapshot.terminals.some(value => value.id === id))
|
|
69
|
+
const selectTerminal = (id: string) => { setActive(id); setSplit(current => current?.includes(id) ? current : [id]) }
|
|
70
|
+
return <div className="dvw-terminal-root">
|
|
71
|
+
<div className="dvw-terminal-main">
|
|
72
|
+
{snapshot.error !== undefined && <div className="dvw-terminal-error">{snapshot.error}</div>}
|
|
73
|
+
<div className="dvw-terminal-panes" data-split={shown.length > 1 || undefined}>{shown.map(id => <XtermView key={id} workspace={workspace} id={id} colorScheme={colorScheme} />)}</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="dvw-terminal-resizer" role="separator" aria-label="终端列表宽度" aria-orientation="vertical" onPointerDown={event => {
|
|
76
|
+
if (event.button !== 0) return
|
|
77
|
+
event.preventDefault()
|
|
78
|
+
const startX = event.clientX
|
|
79
|
+
const startWidth = sideWidth
|
|
80
|
+
const move = (next: PointerEvent) => setSideWidth(Math.max(160, Math.min(420, Math.round(startWidth + startX - next.clientX))))
|
|
81
|
+
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
|
|
82
|
+
window.addEventListener('pointermove', move)
|
|
83
|
+
window.addEventListener('pointerup', up)
|
|
84
|
+
}}/>
|
|
85
|
+
<aside className="dvw-terminal-tabs" style={{ width: sideWidth }} aria-label="终端会话">
|
|
86
|
+
<div className="dvw-terminal-list">{snapshot.terminals.map(value => renaming?.id === value.id ? <div key={value.id} className="dvw-terminal-entry" data-active><div className="dvw-terminal-tab"><Icon name="terminal"/><input className="dvw-terminal-rename" value={renaming.value} autoFocus aria-label="终端名称" onChange={event => setRenaming({ id: value.id, value: event.target.value })} onKeyDown={event => { if (event.key === 'Enter') saveRename(value.id); if (event.key === 'Escape') setRenaming(undefined) }} onBlur={() => saveRename(value.id)}/></div></div> : <div key={value.id} className="dvw-terminal-entry" data-active={active === value.id} data-split-member={shown.includes(value.id) || undefined}><button className="dvw-terminal-tab" onClick={() => selectTerminal(value.id)} onContextMenu={event => { event.preventDefault(); selectTerminal(value.id); setContext({ id: value.id, x: event.clientX, y: event.clientY }) }}><Icon name="terminal"/>{value.title}</button><div className="dvw-terminal-entry-actions"><button title="拆分终端" onClick={() => splitTerminal(value.id)}><Icon name="split-horizontal"/></button><button title="终止终端" onClick={() => closeTerminal(value.id)}><Icon name="trash"/></button></div></div>)}</div>
|
|
87
|
+
{context !== undefined && <div className="dvw-terminal-menu" role="menu" style={{ left: context.x, top: context.y }} onPointerDown={event => event.stopPropagation()}><button role="menuitem" onClick={() => { const terminal = snapshot.terminals.find(value => value.id === context.id); setContext(undefined); if (terminal !== undefined) setRenaming({ id: terminal.id, value: terminal.title }) }}>重命名</button><button className="dvw-terminal-menu-danger" role="menuitem" onClick={() => closeTerminal(context.id)}>终止终端</button></div>}
|
|
88
|
+
</aside>
|
|
89
|
+
</div>
|
|
90
|
+
}
|
package/src/client/Workbench.tsx
CHANGED
|
@@ -9,13 +9,69 @@ 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 { DocumentPreview, documentPreviewKind, type DocumentPreviewKind } from './documentPreview.tsx'
|
|
13
|
+
import { toggleTerminal } from './shortcuts.ts'
|
|
12
14
|
import { resetWorkspaceDocuments, WorkspaceRequestScope } from './workspaceState.ts'
|
|
13
15
|
|
|
14
|
-
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 }
|
|
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 }
|
|
15
17
|
type WorkbenchTheme = 'dsh' | 'dark' | 'light'
|
|
16
18
|
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 }
|
|
17
19
|
interface CreatingEntry { kind: 'file' | 'directory'; directory: string; value: string; error?: string }
|
|
18
20
|
|
|
21
|
+
type ResizeDirection = 1 | -1
|
|
22
|
+
|
|
23
|
+
/** Calculate a panel width from a horizontal pointer movement. */
|
|
24
|
+
export function resizedPanelWidth(startWidth: number, startX: number, nextX: number, direction: ResizeDirection, min: number, max: number): number {
|
|
25
|
+
return Math.max(min, Math.min(max, Math.round(startWidth + (nextX - startX) * direction)))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
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
|
+
const [dragging, setDragging] = useState(false)
|
|
30
|
+
const stopDragging = useRef<() => void>(() => {})
|
|
31
|
+
useEffect(() => () => stopDragging.current(), [])
|
|
32
|
+
const stop = () => stopDragging.current()
|
|
33
|
+
return <div className={className} data-dragging={dragging || undefined} role="separator" aria-label={label} aria-orientation="vertical" aria-valuemin={min} aria-valuemax={max} aria-valuenow={Math.round(width)} tabIndex={0} title={`${label},双击恢复默认`} onDoubleClick={() => onWidth(defaultWidth)} onKeyDown={event => {
|
|
34
|
+
if (event.key === 'Home') { event.preventDefault(); onWidth(defaultWidth); return }
|
|
35
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
36
|
+
event.preventDefault()
|
|
37
|
+
const pointerDelta = event.key === 'ArrowRight' ? 10 : -10
|
|
38
|
+
onWidth(Math.max(min, Math.min(max, Math.round(width + pointerDelta * direction))))
|
|
39
|
+
}} onPointerDown={event => {
|
|
40
|
+
if (event.button !== 0) return
|
|
41
|
+
event.preventDefault()
|
|
42
|
+
stop()
|
|
43
|
+
const startX = event.clientX
|
|
44
|
+
const startWidth = width
|
|
45
|
+
const handle = event.currentTarget
|
|
46
|
+
handle.setPointerCapture?.(event.pointerId)
|
|
47
|
+
const previousCursor = document.body.style.cursor
|
|
48
|
+
const previousUserSelect = document.body.style.userSelect
|
|
49
|
+
document.body.style.cursor = 'ew-resize'
|
|
50
|
+
document.body.style.userSelect = 'none'
|
|
51
|
+
setDragging(true)
|
|
52
|
+
const move = (next: PointerEvent) => onWidth(resizedPanelWidth(startWidth, startX, next.clientX, direction, min, max))
|
|
53
|
+
const cleanup = (commit = false, nextX = startX) => {
|
|
54
|
+
window.removeEventListener('pointermove', move)
|
|
55
|
+
window.removeEventListener('pointerup', up)
|
|
56
|
+
window.removeEventListener('pointercancel', cancel)
|
|
57
|
+
window.removeEventListener('blur', cancel)
|
|
58
|
+
document.body.style.cursor = previousCursor
|
|
59
|
+
document.body.style.userSelect = previousUserSelect
|
|
60
|
+
setDragging(false)
|
|
61
|
+
stopDragging.current = () => {}
|
|
62
|
+
if (!commit) return
|
|
63
|
+
onWidth(Math.max(min, Math.min(max, Math.round(startWidth + (nextX - startX) * direction))))
|
|
64
|
+
}
|
|
65
|
+
const up = (next: PointerEvent) => cleanup(true, next.clientX)
|
|
66
|
+
const cancel = () => cleanup()
|
|
67
|
+
stopDragging.current = cancel
|
|
68
|
+
window.addEventListener('pointermove', move)
|
|
69
|
+
window.addEventListener('pointerup', up)
|
|
70
|
+
window.addEventListener('pointercancel', cancel)
|
|
71
|
+
window.addEventListener('blur', cancel)
|
|
72
|
+
}}/>
|
|
73
|
+
}
|
|
74
|
+
|
|
19
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)}}
|
|
20
76
|
|
|
21
77
|
/** Restore tab headers without reading their file contents. */
|
|
@@ -244,13 +300,13 @@ export function Workbench({ service, useSessions }: { service: VscodeWorkbench;
|
|
|
244
300
|
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])
|
|
245
301
|
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])
|
|
246
302
|
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])
|
|
247
|
-
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);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])
|
|
303
|
+
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])
|
|
248
304
|
const open=(path:string,line?:number,column?:number)=>{if(sessionId!==undefined)openForWorkspace(path,line,column)}
|
|
249
305
|
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])
|
|
250
306
|
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))}}
|
|
251
307
|
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)}
|
|
252
308
|
const closeAll=()=>{const dirty=tabs.filter(tab=>tab.dirty);if(dirty.length>0&&!confirm(`有 ${dirty.length} 个文件包含未保存更改,确定全部关闭?`))return;setTabs([]);setActive(undefined)}
|
|
253
|
-
const activeTab=tabs.find(tab=>tab.path===active); const Panel=panels.find(panel=>panel.id===snapshot.bottomPanel)?.component
|
|
309
|
+
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')
|
|
254
310
|
const selectView=(view:WorkbenchView)=>{if(snapshot.view===view&&primarySideVisible){setPrimarySideVisible(false);return}setPrimarySideVisible(true);service.show(view)}
|
|
255
311
|
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)}
|
|
256
312
|
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()}}
|
|
@@ -264,13 +320,13 @@ export function Workbench({ service, useSessions }: { service: VscodeWorkbench;
|
|
|
264
320
|
<DshConversationPanel visible={chatVisible&&!settingsOpen} width={chatWidth} statusHeight={statusVisible?24:0}/>
|
|
265
321
|
<DshConversationTheme visible={chatVisible} colorScheme={colorScheme} followDsh={theme==='dsh'}/>
|
|
266
322
|
{!chatVisible&&<button className="dvw-chat-toggle dvw-icon" title="显示 DSH 对话栏 (Ctrl+Alt+B)" onClick={()=>setChatVisible(true)}><DshMark/></button>}
|
|
267
|
-
{chatVisible&&<aside className="dvw-chat"><
|
|
323
|
+
{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>}
|
|
268
324
|
{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>}
|
|
269
|
-
<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 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
|
+
<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>
|
|
270
326
|
<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>
|
|
271
|
-
<
|
|
272
|
-
<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>: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>
|
|
273
|
-
{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><button className="dvw-icon" style={{marginLeft:'auto'}} onClick={()=>service.toggleBottomPanel(snapshot.bottomPanel!)}><Icon name="close"/></button></div><div className="dvw-bottom-body">{Panel&&cwd&&<Panel sessionId={sessionId} cwd={cwd} visible colorScheme={colorScheme}/>}</div></section>}
|
|
327
|
+
<PanelResizer className="dvw-side-resizer" label="拖动调整源码管理侧边栏宽度" width={sideWidth} min={210} max={640} defaultWidth={210} direction={1} onWidth={setSideWidth}/>
|
|
328
|
+
<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&&cwd&&<Panel sessionId={sessionId} cwd={cwd} visible colorScheme={colorScheme}/>}</div></section>}
|
|
274
330
|
<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>
|
|
275
331
|
</div>
|
|
276
332
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export type DocumentPreviewKind = 'pdf' | 'docx' | 'download'
|
|
4
|
+
|
|
5
|
+
const MIN_PDF_SCALE = 0.5
|
|
6
|
+
const MAX_PDF_SCALE = 3
|
|
7
|
+
|
|
8
|
+
/** Bound PDF zoom to values that remain usable in the workbench viewport. */
|
|
9
|
+
export function clampPdfScale(scale: number): number {
|
|
10
|
+
return Math.max(MIN_PDF_SCALE, Math.min(MAX_PDF_SCALE, Math.round(scale * 100) / 100))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Return the in-workbench preview supported for a document path. */
|
|
14
|
+
export function documentPreviewKind(path: string): DocumentPreviewKind | undefined {
|
|
15
|
+
const extension = path.split('.').at(-1)?.toLowerCase()
|
|
16
|
+
if (extension === 'pdf') return 'pdf'
|
|
17
|
+
if (extension === 'docx') return 'docx'
|
|
18
|
+
if (extension === 'doc' || extension === 'odt') return 'download'
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function DocxPreview({ src }: { src: string }) {
|
|
23
|
+
const root = useRef<HTMLDivElement>(null)
|
|
24
|
+
const [error, setError] = useState<string>()
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const controller = new AbortController()
|
|
27
|
+
const target = root.current
|
|
28
|
+
if (target === null) return
|
|
29
|
+
setError(undefined)
|
|
30
|
+
void (async () => {
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(src, { signal: controller.signal })
|
|
33
|
+
if (!response.ok) throw new Error(`无法读取文档 (${response.status})`)
|
|
34
|
+
const { renderAsync } = await import('docx-preview')
|
|
35
|
+
if (controller.signal.aborted) return
|
|
36
|
+
target.replaceChildren()
|
|
37
|
+
await renderAsync(await response.blob(), target, undefined, { inWrapper: false, ignoreWidth: false, ignoreHeight: false })
|
|
38
|
+
} catch (cause) {
|
|
39
|
+
if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause))
|
|
40
|
+
}
|
|
41
|
+
})()
|
|
42
|
+
return () => controller.abort()
|
|
43
|
+
}, [src])
|
|
44
|
+
return error === undefined ? <div ref={root} className="dvw-docx-preview"/> : <div className="dvw-document-error">无法预览 Word 文档:{error}</div>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type PdfDocument = {
|
|
48
|
+
numPages: number
|
|
49
|
+
getPage(page: number): Promise<{ getViewport(options: { scale: number }): { width: number; height: number }; render(options: { canvasContext: CanvasRenderingContext2D; viewport: { width: number; height: number } }): { promise: Promise<void>; cancel(): void } }>
|
|
50
|
+
destroy(): void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function PdfPreview({ src, path }: { src: string; path: string }) {
|
|
54
|
+
const canvas = useRef<HTMLCanvasElement>(null)
|
|
55
|
+
const documentRef = useRef<PdfDocument>()
|
|
56
|
+
const [page, setPage] = useState(1)
|
|
57
|
+
const [pages, setPages] = useState(0)
|
|
58
|
+
const [scale, setScale] = useState(1)
|
|
59
|
+
const [error, setError] = useState<string>()
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
let cancelled = false
|
|
63
|
+
let document: PdfDocument | undefined
|
|
64
|
+
setPage(1); setPages(0); setError(undefined)
|
|
65
|
+
void (async () => {
|
|
66
|
+
try {
|
|
67
|
+
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
|
68
|
+
pdfjs.GlobalWorkerOptions.workerSrc = '/dsh-vscode/pdfjs/worker.mjs'
|
|
69
|
+
document = await pdfjs.getDocument(src).promise as PdfDocument
|
|
70
|
+
if (cancelled) { document.destroy(); return }
|
|
71
|
+
documentRef.current = document
|
|
72
|
+
setPages(document.numPages)
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause))
|
|
75
|
+
}
|
|
76
|
+
})()
|
|
77
|
+
return () => { cancelled = true; documentRef.current = undefined; document?.destroy() }
|
|
78
|
+
}, [src])
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const document = documentRef.current
|
|
82
|
+
const target = canvas.current
|
|
83
|
+
if (document === undefined || target === null) return
|
|
84
|
+
let cancelled = false
|
|
85
|
+
let task: { promise: Promise<void>; cancel(): void } | undefined
|
|
86
|
+
void document.getPage(page).then(value => {
|
|
87
|
+
if (cancelled) return
|
|
88
|
+
const viewport = value.getViewport({ scale })
|
|
89
|
+
const ratio = window.devicePixelRatio || 1
|
|
90
|
+
target.width = Math.ceil(viewport.width * ratio)
|
|
91
|
+
target.height = Math.ceil(viewport.height * ratio)
|
|
92
|
+
target.style.width = `${viewport.width}px`
|
|
93
|
+
target.style.height = `${viewport.height}px`
|
|
94
|
+
const context = target.getContext('2d')
|
|
95
|
+
if (context === null) throw new Error('无法创建 PDF 画布')
|
|
96
|
+
context.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
97
|
+
task = value.render({ canvasContext: context, viewport })
|
|
98
|
+
return task.promise
|
|
99
|
+
}).catch(cause => { if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause)) })
|
|
100
|
+
return () => { cancelled = true; task?.cancel() }
|
|
101
|
+
}, [page, scale, pages])
|
|
102
|
+
|
|
103
|
+
return <section className="dvw-pdf-preview" aria-label={`${path} PDF 预览`}>
|
|
104
|
+
<header className="dvw-pdf-toolbar">
|
|
105
|
+
<button className="dvw-icon" title="上一页" disabled={page <= 1} onClick={() => setPage(value => Math.max(1, value - 1))}><span aria-hidden="true">‹</span></button>
|
|
106
|
+
<span>{pages === 0 ? '正在加载' : `${page} / ${pages}`}</span>
|
|
107
|
+
<button className="dvw-icon" title="下一页" disabled={pages === 0 || page >= pages} onClick={() => setPage(value => Math.min(pages, value + 1))}><span aria-hidden="true">›</span></button>
|
|
108
|
+
<span className="dvw-pdf-separator"/>
|
|
109
|
+
<button className="dvw-icon" title="缩小" disabled={scale <= MIN_PDF_SCALE} onClick={() => setScale(value => clampPdfScale(value - 0.1))}>−</button>
|
|
110
|
+
<button className="dvw-pdf-zoom" title="重置缩放" onClick={() => setScale(1)}>{Math.round(scale * 100)}%</button>
|
|
111
|
+
<button className="dvw-icon" title="放大" disabled={scale >= MAX_PDF_SCALE} onClick={() => setScale(value => clampPdfScale(value + 0.1))}>+</button>
|
|
112
|
+
</header>
|
|
113
|
+
{error === undefined ? <div className="dvw-pdf-page"><canvas ref={canvas}/></div> : <div className="dvw-document-error">无法预览 PDF:{error}</div>}
|
|
114
|
+
</section>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Render an in-workbench preview for PDF and Word documents. */
|
|
118
|
+
export function DocumentPreview({ kind, src, path }: { kind: DocumentPreviewKind; src: string; path: string }) {
|
|
119
|
+
if (kind === 'pdf') return <PdfPreview src={src} path={path}/>
|
|
120
|
+
if (kind === 'docx') return <DocxPreview src={src}/>
|
|
121
|
+
return <div className="dvw-document-download"><strong>此格式无法在浏览器中可靠渲染</strong><a href={src} target="_blank" rel="noreferrer">打开 {path}</a></div>
|
|
122
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
|
|
6
6
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
7
7
|
import type { VscodeWorkbench } from './service.ts'
|
|
8
8
|
import { WorkbenchController } from './service.ts'
|
|
9
|
+
import { TerminalPanel } from './TerminalPanel.tsx'
|
|
9
10
|
import { Launcher, Workbench } from './Workbench.tsx'
|
|
10
11
|
import { installStyles } from './styles.ts'
|
|
11
12
|
import { handleTerminalShortcut } from './shortcuts.ts'
|
|
@@ -32,6 +33,8 @@ export function apply(ctx: ClientContext): void {
|
|
|
32
33
|
const service = new WorkbenchController()
|
|
33
34
|
ctx.effect(() => installStyles(), 'dsh-vscode-workbench: styles')
|
|
34
35
|
ctx.effect(() => { const dispose = ctx.reflect.provide('vscodeWorkbench', service); return () => { void dispose() } }, 'dsh-vscode-workbench: service')
|
|
36
|
+
ctx.effect(() => service.registerBottomPanel({ id: 'terminal', title: '终端', component: TerminalPanel }), 'dsh-vscode-workbench: terminal panel')
|
|
37
|
+
ctx.effect(() => service.registerCommand({ id: 'terminal.new', run() { window.dispatchEvent(new Event('dvw-terminal-new')) } }), 'dsh-vscode-workbench: terminal command')
|
|
35
38
|
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'dsh-vscode-workbench-launcher', inject: () => ({ service }) }, Footer))
|
|
36
39
|
ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-vscode-workbench-overlay', inject: () => ({ service }) }, Overlay))
|
|
37
40
|
ctx.effect(() => {
|
package/src/client/shortcuts.ts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import type { VscodeWorkbench } from './service.ts'
|
|
2
2
|
|
|
3
|
+
/** Reveal the terminal panel supplied by a terminal adapter without toggling it closed. */
|
|
4
|
+
export function openTerminal(workbench: VscodeWorkbench): boolean {
|
|
5
|
+
if (!workbench.getPanels().some(panel => panel.id === 'terminal')) return false
|
|
6
|
+
workbench.show()
|
|
7
|
+
if (workbench.getSnapshot().bottomPanel !== 'terminal') workbench.toggleBottomPanel('terminal')
|
|
8
|
+
return true
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Toggle the terminal panel from the activity bar. */
|
|
12
|
+
export function toggleTerminal(workbench: VscodeWorkbench): boolean {
|
|
13
|
+
if (!workbench.getPanels().some(panel => panel.id === 'terminal')) return false
|
|
14
|
+
workbench.show()
|
|
15
|
+
workbench.toggleBottomPanel('terminal')
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
18
|
+
|
|
3
19
|
/** Handle terminal shortcuts through contributions registered by a terminal adapter. */
|
|
4
20
|
export function handleTerminalShortcut(event: KeyboardEvent, workbench: VscodeWorkbench): boolean {
|
|
5
21
|
if (event.isComposing || !event.ctrlKey || event.altKey || event.code !== 'Backquote') return false
|
|
@@ -8,7 +24,7 @@ export function handleTerminalShortcut(event: KeyboardEvent, workbench: VscodeWo
|
|
|
8
24
|
event.stopImmediatePropagation()
|
|
9
25
|
workbench.show()
|
|
10
26
|
if (event.shiftKey) {
|
|
11
|
-
|
|
27
|
+
openTerminal(workbench)
|
|
12
28
|
queueMicrotask(() => workbench.runCommand('terminal.new'))
|
|
13
29
|
} else {
|
|
14
30
|
workbench.toggleBottomPanel('terminal')
|