dsh-vscode-workbench 0.2.3 → 0.2.5

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.
@@ -0,0 +1,6 @@
1
+ export declare function DshBrowserPage({ sessionId, tabId, active, colorScheme }: {
2
+ sessionId: string;
3
+ tabId: string;
4
+ active: boolean;
5
+ colorScheme: 'dark' | 'light' | 'white' | 'eyecare';
6
+ }): null;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Pin a DSH global panel over the workbench editor while its tab is active.
3
+ * The official page keeps its own DOM and state; switching to a file only
4
+ * hides it, so returning to the tab does not rebuild the page.
5
+ */
6
+ export declare function DshPanelPage({ panelId, active }: {
7
+ panelId: string;
8
+ active: boolean;
9
+ }): null;
@@ -1,4 +1,4 @@
1
- export type CodiconName = 'add' | 'arrow-right' | 'comment-discussion' | 'check' | 'check-all' | 'chevron-down' | 'chevron-right' | 'close' | 'close-all' | 'cloud-upload' | 'code' | 'color-mode' | 'edit' | 'diff' | 'diff-sidebyside' | 'diff-single' | 'discard' | 'file' | 'file-code' | 'file-media' | 'file-pdf' | 'file-zip' | 'files' | 'folder' | 'folder-opened' | 'git-branch' | 'git-commit' | 'graph' | 'home' | 'list-tree' | 'new-file' | 'layout-sidebar-right' | 'layout-sidebar-right-off' | 'new-folder' | 'preview' | 'refresh' | 'remove' | 'search' | 'settings' | 'source-control' | 'sparkle' | 'split-horizontal' | 'terminal' | 'trash';
1
+ export type CodiconName = 'add' | 'arrow-right' | 'comment-discussion' | 'check' | 'check-all' | 'chevron-down' | 'chevron-right' | 'close' | 'close-all' | 'cloud-upload' | 'code' | 'color-mode' | 'edit' | 'diff' | 'diff-sidebyside' | 'diff-single' | 'discard' | 'file' | 'file-code' | 'file-media' | 'file-pdf' | 'file-zip' | 'files' | 'folder' | 'folder-opened' | 'git-branch' | 'git-commit' | 'globe' | 'graph' | 'home' | 'list-tree' | 'new-file' | 'layout-sidebar-right' | 'layout-sidebar-right-off' | 'new-folder' | 'preview' | 'refresh' | 'remove' | 'search' | 'settings' | 'source-control' | 'sparkle' | 'split-horizontal' | 'terminal' | 'trash';
2
2
  /** Render one glyph from Microsoft's VS Code Codicon font. */
3
3
  export declare function Icon({ name, className }: {
4
4
  name: CodiconName;
@@ -1,3 +1,6 @@
1
1
  import type { BottomPanelProps } from './service.ts';
2
- /** Render interactive terminal tabs in the workbench bottom panel. */
3
- export declare function TerminalPanel({ sessionId, colorScheme }: BottomPanelProps): import("react").JSX.Element;
2
+ import { type TerminalWorkspaceController } from './terminal.ts';
3
+ /** Keep the incumbent terminal layout while using the official process and stream models. */
4
+ export declare function TerminalPanel({ workspace, colorScheme }: BottomPanelProps & {
5
+ workspace: TerminalWorkspaceController;
6
+ }): import("react").JSX.Element;
@@ -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 { NativeBrowser } from './browser.ts';
3
4
  import type { DshNavigation } from './dshNavigation.ts';
4
5
  import { type DocumentPreviewKind } from './documentPreview.tsx';
5
6
  interface Tab extends FileDocument {
@@ -80,10 +81,15 @@ export declare function graphRows(commits: GitCommit[]): GraphRow[];
80
81
  export declare function graphTrackPath(from: number, to: number, startY: number, endY: number, bendY: number): string;
81
82
  /** Leave the node horizontally and turn down into a parent lane with a lane-width arc. */
82
83
  export declare function graphParentPath(from: number, to: number, midY: number, endY: number): string;
83
- export declare function Workbench({ service, sessionId, cwd, navigation }: {
84
+ export declare function Workbench({ service, browser, sessionId, cwd, navigation, dshPanelId: hostDshPanelId, onOpenDshPanel, onRestoreConversation, onCloseDshPanel }: {
84
85
  service: VscodeWorkbench;
86
+ browser?: NativeBrowser;
85
87
  sessionId?: string;
86
88
  cwd?: string;
87
89
  navigation?: DshNavigation;
90
+ dshPanelId?: string;
91
+ onOpenDshPanel?(panelId: string): void;
92
+ onRestoreConversation?(): void;
93
+ onCloseDshPanel?(): void;
88
94
  }): import("react").JSX.Element | null;
89
95
  export {};
@@ -0,0 +1,26 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ export interface BrowserTab {
3
+ id: string;
4
+ sessionId: string;
5
+ title: string;
6
+ }
7
+ export interface BrowserSnapshot {
8
+ available: boolean;
9
+ tabs: readonly BrowserTab[];
10
+ }
11
+ /** Navigate official browser tabs; their controller, renderer and persistence stay with DSH. */
12
+ export declare class NativeBrowser {
13
+ private readonly ctx;
14
+ private snapshot;
15
+ private readonly listeners;
16
+ private readonly lifetime;
17
+ private readonly disposers;
18
+ constructor(ctx: Context);
19
+ getSnapshot: () => BrowserSnapshot;
20
+ subscribe: (listener: () => void) => (() => void);
21
+ open(sessionId: string, request?: AbortSignal): Promise<string>;
22
+ focus(id: string): void;
23
+ close(sessionId: string, id: string): void;
24
+ dispose(): void;
25
+ private update;
26
+ }
@@ -0,0 +1,4 @@
1
+ /** Locate the central panel DSH renders when a global panel (Plugins, market…) is open. */
2
+ export declare function findDshGlobalPanel(documentRoot?: Document): HTMLElement | null;
3
+ /** Tab-style label for an embedded DSH panel; unknown ids keep their own name. */
4
+ export declare function dshPanelLabel(id: string): string;
@@ -5,3 +5,5 @@ export declare function openTerminal(workbench: VscodeWorkbench): boolean;
5
5
  export declare function toggleTerminal(workbench: VscodeWorkbench): boolean;
6
6
  /** Handle terminal shortcuts through contributions registered by a terminal adapter. */
7
7
  export declare function handleTerminalShortcut(event: KeyboardEvent, workbench: VscodeWorkbench): boolean;
8
+ /** Match the official desktop browser shortcut while the workbench owns focus. */
9
+ export declare function handleBrowserShortcut(event: KeyboardEvent, workbench: VscodeWorkbench): boolean;
@@ -1,44 +1,49 @@
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
- subscribeError(listener: (message: string) => void): () => void;
1
+ import type { ClientTerminals, TerminalView, TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client';
2
+ export interface TerminalEntry {
3
+ id: string;
4
+ title: string;
12
5
  }
13
6
  export interface TerminalWorkspaceSnapshot {
14
- terminals: readonly TerminalInfo[];
7
+ terminals: readonly TerminalEntry[];
15
8
  loading: boolean;
16
9
  error?: string;
17
10
  }
18
- /** Browser client for the workbench's local terminal transport. */
19
- export interface TerminalWorkspace {
20
- refresh(): Promise<boolean>;
21
- create(title?: string): Promise<TerminalInfo>;
22
- close(id: string): Promise<void>;
23
- rename(id: string, title: string): Promise<void>;
24
- connect(id: string): TerminalConnection;
25
- getSnapshot(): TerminalWorkspaceSnapshot;
26
- subscribe(listener: () => void): () => void;
11
+ /** Own workbench membership without overwriting the official sidebar's terminal inventory. */
12
+ export declare class TerminalWorkspaces {
13
+ readonly models: ClientTerminals;
14
+ private readonly workspaces;
15
+ private saved;
16
+ constructor(models: ClientTerminals);
17
+ get(sessionId: string): TerminalWorkspaceController;
18
+ save(sessionId: string, ids: string[]): void;
19
+ private readSaved;
20
+ private sync;
21
+ private onStorage;
22
+ private retain;
23
+ dispose(): void;
27
24
  }
28
- /** Identify automatic terminal-emulator replies that must not be echoed during replay. */
29
- export declare function isTerminalProtocolReply(data: string): boolean;
30
- /** Maintain one terminal collection for a DSH session. */
31
- export declare class TerminalWorkspaceController implements TerminalWorkspace {
32
- private readonly sessionId;
25
+ /** Layout only; the official model owns processes, stream ordering, recovery and input control. */
26
+ export declare class TerminalWorkspaceController {
27
+ private readonly owner;
28
+ readonly sessionId: string;
29
+ readonly closeFailures: ClientTerminals['closeFailures'];
33
30
  private snapshot;
34
31
  private readonly listeners;
35
- constructor(sessionId: string);
32
+ private readonly views;
33
+ constructor(owner: TerminalWorkspaces, sessionId: string, ids: readonly string[]);
34
+ view(id: string): TerminalView;
36
35
  refresh(): Promise<boolean>;
37
- create(title?: string): Promise<TerminalInfo>;
38
- close(id: string): Promise<void>;
36
+ create(): Promise<TerminalEntry>;
37
+ close(id: string): void;
39
38
  rename(id: string, title: string): Promise<void>;
40
- connect(id: string): TerminalConnection;
41
- getSnapshot(): TerminalWorkspaceSnapshot;
42
- subscribe(listener: () => void): () => void;
39
+ retryClose(id: Parameters<ClientTerminals['retryClose']>[0]): void;
40
+ reconcile(ids: readonly string[]): void;
41
+ getSnapshot: () => TerminalWorkspaceSnapshot;
42
+ subscribe: (listener: () => void) => (() => void);
43
+ dispose(): void;
44
+ private save;
45
+ private updateTitles;
46
+ private fail;
43
47
  private set;
44
48
  }
49
+ export declare function terminalIssue(state: TerminalViewState): string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-workbench",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Explorer, Monaco editor, search and local Git overlay for DSH",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,7 +51,12 @@
51
51
  "@deepseek-ai/dsh-client-ui-renderer",
52
52
  "@deepseek-ai/dsh-client-ui-session",
53
53
  "@deepseek-ai/dsh-client-ui-layout",
54
- "@deepseek-ai/dsh-client-ui-sidebar"
54
+ "@deepseek-ai/dsh-client-ui-sidebar",
55
+ "@deepseek-ai/dsh-api-terminal-controller",
56
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
57
+ ],
58
+ "external": [
59
+ "@deepseek-ai/dsh-api-terminal-controller/client"
55
60
  ]
56
61
  }
57
62
  },
@@ -66,7 +71,6 @@
66
71
  "dompurify": "^3.2.6",
67
72
  "marked": "^16.3.0",
68
73
  "monaco-editor": "^0.52.2",
69
- "node-pty": "^1.1.0",
70
74
  "pdfjs-dist": "4.10.38",
71
75
  "vscode-icons-js": "^11.6.1",
72
76
  "ws": "^8.18.0",
@@ -75,11 +79,13 @@
75
79
  "peerDependencies": {
76
80
  "@deepseek-ai/cordis": "^4.0.5",
77
81
  "@deepseek-ai/dsh-api-session-controller": "^0.2.1-rc.5 || ^0.2.2-rc.1",
82
+ "@deepseek-ai/dsh-api-terminal-controller": "^0.2.1-rc.5 || ^0.2.2-rc.1",
78
83
  "@deepseek-ai/dsh-client-connection": "^0.2.1-rc.5 || ^0.2.2-rc.1",
79
84
  "@deepseek-ai/dsh-client-ui-layout": "^0.2.1-rc.5 || ^0.2.2-rc.1",
80
85
  "@deepseek-ai/dsh-client-ui-renderer": "^0.2.1-rc.5 || ^0.2.2-rc.1",
81
86
  "@deepseek-ai/dsh-client-ui-session": "^0.2.1-rc.5 || ^0.2.2-rc.1",
82
87
  "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.1-rc.5 || ^0.2.2-rc.1",
88
+ "@deepseek-ai/dsh-client-ui-sidebar-right": "^0.2.1-rc.5 || ^0.2.2-rc.1",
83
89
  "@deepseek-ai/dsh-client-ui-slots": "^0.2.1-rc.5 || ^0.2.2-rc.1",
84
90
  "@deepseek-ai/dsh-host-webserver": "^0.2.1-rc.5 || ^0.2.2-rc.1",
85
91
  "@deepseek-ai/dsh-llm": "^0.2.1-rc.5 || ^0.2.2-rc.1",
@@ -93,7 +99,9 @@
93
99
  "@deepseek-ai/cordis-plugin-group": "1.1.0",
94
100
  "@deepseek-ai/cordis-plugin-include": "1.1.0",
95
101
  "@deepseek-ai/cordis-plugin-loader": "1.1.0",
102
+ "@deepseek-ai/dsh-api-gateway": "0.2.2-rc.1",
96
103
  "@deepseek-ai/dsh-api-session-controller": "0.2.2-rc.1",
104
+ "@deepseek-ai/dsh-api-terminal-controller": "0.2.2-rc.1",
97
105
  "@deepseek-ai/dsh-app-boot": "0.2.2-rc.1",
98
106
  "@deepseek-ai/dsh-client-connection": "0.2.2-rc.1",
99
107
  "@deepseek-ai/dsh-client-store": "0.2.2-rc.1",
@@ -101,6 +109,7 @@
101
109
  "@deepseek-ai/dsh-client-ui-renderer": "0.2.2-rc.1",
102
110
  "@deepseek-ai/dsh-client-ui-session": "0.2.2-rc.1",
103
111
  "@deepseek-ai/dsh-client-ui-sidebar": "0.2.2-rc.1",
112
+ "@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.2-rc.1",
104
113
  "@deepseek-ai/dsh-client-ui-slots": "0.2.2-rc.1",
105
114
  "@deepseek-ai/dsh-host-webserver": "0.2.2-rc.1",
106
115
  "@deepseek-ai/dsh-llm": "0.2.2-rc.1",
@@ -111,12 +120,14 @@
111
120
  "@types/react": "^18.3.0",
112
121
  "@types/react-dom": "^18.3.0",
113
122
  "@types/ws": "^8.18.0",
123
+ "immer": "^10.2.0",
114
124
  "jsdom": "29.1.1",
115
125
  "react": "^18.2.0",
116
126
  "react-dom": "^18.3.1",
117
127
  "tsdown": "^0.22.2",
118
128
  "typescript": "^5.9.0",
119
- "vitest": "^4.0.0"
129
+ "vitest": "^4.0.0",
130
+ "zustand": "~4.5.7"
120
131
  },
121
132
  "scripts": {
122
133
  "clean": "node scripts/clean.mjs lib dist",
@@ -0,0 +1,61 @@
1
+ import { useLayoutEffect } from 'react'
2
+
3
+ /** Present the original browser DOM without moving React-owned nodes or rebuilding webviews. */
4
+ const PALETTE: Record<string, string> = {
5
+ '--dsw-alias-bg-base': '--dvw-surface-1', '--dsw-alias-bg-layer-1': '--dvw-surface-2',
6
+ '--dsw-alias-bg-layer-2': '--dvw-surface-3', '--dsw-alias-label-primary': '--dvw-text',
7
+ '--dsw-alias-label-secondary': '--dvw-text-2', '--dsw-alias-label-tertiary': '--dvw-text-3',
8
+ '--dsw-alias-border-l2': '--dvw-border', '--dsw-alias-border-l3': '--dvw-border-soft',
9
+ '--dsw-alias-interactive-bg-hover': '--dvw-hover', '--dsw-alias-interactive-bg-active': '--dvw-active',
10
+ }
11
+
12
+ export function DshBrowserPage({ sessionId, tabId, active, colorScheme }: { sessionId: string; tabId: string; active: boolean; colorScheme: 'dark' | 'light' | 'white' | 'eyecare' }) {
13
+ useLayoutEffect(() => {
14
+ let page: HTMLElement | undefined
15
+ let restore: (() => void) | undefined
16
+ let frame = 0
17
+ const place = () => {
18
+ const next = [...document.querySelectorAll<HTMLElement>('div[data-sidebar-right-tab]')].find(node => node.dataset.sidebarRightTab === tabId && node.closest('[data-sidebar-right-session]')?.getAttribute('data-sidebar-right-session') === sessionId)
19
+ if (next !== page) {
20
+ restore?.(); page = next
21
+ if (page !== undefined) {
22
+ const node = page
23
+ const popover = node.getAttribute('popover')
24
+ const properties = ['left', 'top', 'width', 'height', 'color-scheme', ...Object.keys(PALETTE)]
25
+ const styles = properties.map(property => [property, node.style.getPropertyValue(property), node.style.getPropertyPriority(property)])
26
+ node.setAttribute('popover', 'manual')
27
+ node.setAttribute('data-dvw-browser-page', '')
28
+ restore = () => {
29
+ if (node.matches(':popover-open')) node.hidePopover()
30
+ if (popover === null) node.removeAttribute('popover'); else node.setAttribute('popover', popover)
31
+ node.removeAttribute('data-dvw-browser-page')
32
+ for (const [property, value, priority] of styles) node.style.setProperty(property!, value!, priority)
33
+ }
34
+ }
35
+ }
36
+ if (page === undefined) return
37
+ const rect = document.querySelector('.dvw-editor')?.getBoundingClientRect()
38
+ const visible = active && rect !== undefined && rect.width > 0 && rect.height > 0
39
+ if (!visible) { if (page.matches(':popover-open')) page.hidePopover(); return }
40
+ for (const [property, value] of Object.entries({ left: rect.left, top: rect.top, width: rect.width, height: rect.height })) page.style.setProperty(property, `${Math.round(value)}px`, 'important')
41
+ const overlay = document.querySelector('.dvw-overlay')
42
+ if (overlay !== null) {
43
+ const palette = getComputedStyle(overlay)
44
+ for (const [alias, token] of Object.entries(PALETTE)) page.style.setProperty(alias, palette.getPropertyValue(token))
45
+ }
46
+ page.style.setProperty('color-scheme', colorScheme === 'dark' ? 'dark' : 'light')
47
+ // The top layer escapes dockkit clipping while keeping the original DOM parent and lease.
48
+ if (!page.matches(':popover-open')) page.showPopover()
49
+ }
50
+ const schedule = () => { if (frame === 0) frame = requestAnimationFrame(() => { frame = 0; place() }) }
51
+ place()
52
+ const resize = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(schedule)
53
+ const editor = document.querySelector('.dvw-editor')
54
+ if (editor !== null) resize?.observe(editor)
55
+ const observer = new MutationObserver(schedule)
56
+ observer.observe(document.body, { childList: true, subtree: true })
57
+ window.addEventListener('resize', schedule)
58
+ return () => { observer.disconnect(); resize?.disconnect(); window.removeEventListener('resize', schedule); cancelAnimationFrame(frame); restore?.() }
59
+ }, [sessionId, tabId, active, colorScheme])
60
+ return null
61
+ }
@@ -0,0 +1,63 @@
1
+ import { useLayoutEffect, useRef } from 'react'
2
+ import { findDshGlobalPanel } from './dshPanels.ts'
3
+
4
+ /**
5
+ * Pin a DSH global panel over the workbench editor while its tab is active.
6
+ * The official page keeps its own DOM and state; switching to a file only
7
+ * hides it, so returning to the tab does not rebuild the page.
8
+ */
9
+ export function DshPanelPage({ panelId, active }: { panelId: string; active: boolean }) {
10
+ const activeRef = useRef(active)
11
+ activeRef.current = active
12
+ useLayoutEffect(() => {
13
+ let panel: HTMLElement | null = null
14
+ let frame = 0
15
+ const clear = (node: HTMLElement) => {
16
+ node.removeAttribute('data-dvw-dsh-page')
17
+ node.removeAttribute('data-dvw-dsh-hidden')
18
+ for (const property of ['left', 'top', 'width', 'height']) node.style.removeProperty(property)
19
+ }
20
+ const place = () => {
21
+ const next = findDshGlobalPanel()
22
+ if (next !== panel) {
23
+ if (panel !== null) clear(panel)
24
+ panel = next
25
+ panel?.setAttribute('data-dvw-dsh-page', '')
26
+ }
27
+ if (panel === null) return
28
+ panel.toggleAttribute('data-dvw-dsh-hidden', !activeRef.current)
29
+ if (!activeRef.current) return
30
+ const editor = document.querySelector<HTMLElement>('.dvw-editor')
31
+ if (editor === null) return
32
+ const rect = editor.getBoundingClientRect()
33
+ if (rect.width <= 0 || rect.height <= 0) return
34
+ panel.style.setProperty('left', `${Math.round(rect.left)}px`)
35
+ panel.style.setProperty('top', `${Math.round(rect.top)}px`)
36
+ panel.style.setProperty('width', `${Math.round(rect.width)}px`)
37
+ panel.style.setProperty('height', `${Math.round(rect.height)}px`)
38
+ }
39
+ const schedule = () => {
40
+ if (frame !== 0) return
41
+ frame = requestAnimationFrame(() => { frame = 0; place() })
42
+ }
43
+ place()
44
+ const resize = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(schedule)
45
+ const editor = document.querySelector<HTMLElement>('.dvw-editor')
46
+ if (editor !== null) resize?.observe(editor)
47
+ window.addEventListener('resize', schedule)
48
+ // The page may mount after this effect, or rebuild its subtree on navigation.
49
+ const observer = new MutationObserver(records => {
50
+ if (records.every(record => record.target instanceof Element && record.target.closest('.dvw-overlay') !== null)) return
51
+ schedule()
52
+ })
53
+ observer.observe(document.body, { childList: true, subtree: true })
54
+ return () => {
55
+ observer.disconnect()
56
+ resize?.disconnect()
57
+ cancelAnimationFrame(frame)
58
+ window.removeEventListener('resize', schedule)
59
+ if (panel !== null) clear(panel)
60
+ }
61
+ }, [panelId, active])
62
+ return null
63
+ }
@@ -1,7 +1,7 @@
1
1
  export type CodiconName =
2
2
  | 'add' | 'arrow-right' | 'comment-discussion' | '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
- | 'files' | 'folder' | 'folder-opened' | 'git-branch' | 'git-commit' | 'graph' | 'home' | 'list-tree' | 'new-file'
4
+ | 'files' | 'folder' | 'folder-opened' | 'git-branch' | 'git-commit' | 'globe' | 'graph' | 'home' | 'list-tree' | 'new-file'
5
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. */
@@ -1,57 +1,79 @@
1
- import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
1
+ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
2
2
  import { FitAddon } from '@xterm/addon-fit'
3
3
  import { Terminal } from '@xterm/xterm'
4
4
  import { Icon } from './Icon.tsx'
5
5
  import type { BottomPanelProps } from './service.ts'
6
- import { isTerminalProtocolReply, TerminalWorkspaceController } from './terminal.ts'
6
+ import { terminalIssue, type TerminalWorkspaceController } from './terminal.ts'
7
7
 
8
8
  // VS Code Light+ ANSI palette: the default xterm colors (e.g. yellow #e5e510) vanish on a light background.
9
9
  const LIGHT_ANSI = { black: '#000000', red: '#cd3131', green: '#107c10', yellow: '#949800', blue: '#0451a5', magenta: '#bc05bc', cyan: '#0598bc', white: '#555555', brightBlack: '#666666', brightRed: '#cd3131', brightGreen: '#14ce14', brightYellow: '#b5ba00', brightBlue: '#0451a5', brightMagenta: '#bc05bc', brightCyan: '#0598bc', brightWhite: '#a5a5a5' }
10
10
  function terminalTheme(colorScheme: 'dark' | 'light' | 'white' | 'eyecare') { return colorScheme === 'dark' ? { background: '#191a1b', foreground: '#bfbfbf', cursor: '#bfbfbf', selectionBackground: '#264f78' } : { background: '#ffffff', foreground: '#1d2025', cursor: '#4b515b', selectionBackground: '#add6ff', ...LIGHT_ANSI } }
11
11
 
12
- function XtermView({ workspace, id, colorScheme }: { workspace: TerminalWorkspaceController; id: string; colorScheme: 'dark' | 'light' | 'white' | 'eyecare' }) {
12
+ function XtermView({ workspace, id, colorScheme }: { workspace: TerminalWorkspaceController; id: string; colorScheme: BottomPanelProps['colorScheme'] }) {
13
+ const model = workspace.view(id)
14
+ const state = useSyncExternalStore(model.state.subscribe, model.state.getSnapshot)
13
15
  const root = useRef<HTMLDivElement>(null)
14
- const terminalRef = useRef<Terminal>()
15
- const replaying = useRef(false)
16
- const replayGuard = useRef<ReturnType<typeof setTimeout>>()
17
- const [error, setError] = useState<string>()
18
- useEffect(() => {
16
+ const screen = useRef<Terminal>()
17
+ const fit = useRef<FitAddon>()
18
+ const revision = useRef(0)
19
+ const current = useRef(state)
20
+ current.current = state
21
+ const measure = () => {
22
+ const node = root.current
23
+ const dimensions = fit.current?.proposeDimensions()
24
+ if (!node?.clientWidth || !node.clientHeight || !current.current.writable || dimensions === undefined) return
25
+ const cols = Math.min(dimensions.cols, current.current.environment?.maxCols ?? 500)
26
+ const rows = Math.min(dimensions.rows, current.current.environment?.maxRows ?? 200)
27
+ if (cols < 2 || rows < 1) return
28
+ screen.current?.resize(cols, rows)
29
+ model.resize(cols, rows)
30
+ }
31
+ useLayoutEffect(() => {
19
32
  if (root.current === null) return
20
- const background = root.current === null ? undefined : getComputedStyle(root.current).backgroundColor
21
- const terminal = new Terminal({ convertEol: false, cursorBlink: true, fontFamily: 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', fontSize: 13, minimumContrastRatio: 4.5, theme: { ...terminalTheme(colorScheme), ...(background === undefined ? {} : { background }) } })
22
- terminalRef.current = terminal
23
- const fit = new FitAddon()
24
- terminal.loadAddon(fit)
33
+ const terminal = new Terminal({ cursorBlink: true, fontFamily: 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', fontSize: 13, minimumContrastRatio: 4.5, theme: { ...terminalTheme(colorScheme), background: getComputedStyle(root.current).backgroundColor }, scrollback: 1000 })
34
+ const addon = new FitAddon()
35
+ terminal.loadAddon(addon)
25
36
  terminal.open(root.current)
26
- let connection
27
- try { connection = workspace.connect(id) }
28
- catch (exception) {
29
- setError(exception instanceof Error ? exception.message : String(exception))
30
- terminal.dispose()
31
- return
32
- }
33
- const output = connection.subscribe(value => {
34
- if (value.replay) { replaying.current = true; clearTimeout(replayGuard.current) }
35
- terminal.write(value.data, () => { if (value.replay) replayGuard.current = setTimeout(() => { replaying.current = false }, 250) })
36
- })
37
- const connectionError = connection.subscribeError(setError)
38
- const input = terminal.onData(data => { if (!replaying.current || !isTerminalProtocolReply(data)) connection.write(data) })
39
- const resize = terminal.onResize(({ cols, rows }) => connection.resize(cols, rows))
40
- const observer = new ResizeObserver(() => { try { fit.fit() } catch { /* The panel can be hidden during layout changes. */ } })
37
+ terminal.textarea?.setAttribute('aria-label', '终端输入')
38
+ screen.current = terminal; fit.current = addon; revision.current = 0
39
+ const input = terminal.onData(data => model.write(data))
40
+ const observer = new ResizeObserver(measure)
41
41
  observer.observe(root.current)
42
- try { fit.fit(); terminal.focus() } catch (exception) { setError(exception instanceof Error ? exception.message : String(exception)) }
43
- return () => { terminalRef.current = undefined; clearTimeout(replayGuard.current); observer.disconnect(); connectionError(); input.dispose(); resize.dispose(); output(); connection.close(); terminal.dispose() }
44
- }, [workspace, id])
45
- useEffect(() => { if (terminalRef.current === undefined) return; const background = root.current === null ? undefined : getComputedStyle(root.current).backgroundColor; terminalRef.current.options.theme = { ...terminalTheme(colorScheme), ...(background === undefined ? {} : { background }) } }, [colorScheme])
46
- return error === undefined ? <div ref={root} className="dvw-terminal-host" /> : <div className="dvw-terminal-error">{error}</div>
42
+ return () => { observer.disconnect(); input.dispose(); terminal.dispose(); screen.current = undefined; fit.current = undefined }
43
+ }, [model])
44
+ useEffect(() => model.mount(), [model])
45
+ useLayoutEffect(() => {
46
+ const terminal = screen.current
47
+ const render = state.render
48
+ if (terminal === undefined || render === undefined || render.revision <= revision.current) return
49
+ revision.current = render.revision
50
+ if (render.frame.type === 'snapshot') { terminal.reset(); terminal.resize(render.frame.info.cols, render.frame.info.rows) }
51
+ terminal.write(render.frame.type === 'snapshot' ? render.frame.screen : render.frame.data, () => model.acknowledge(render.revision))
52
+ }, [state.render, model])
53
+ useLayoutEffect(() => {
54
+ if (screen.current === undefined) return
55
+ screen.current.options.disableStdin = !state.writable
56
+ if (state.writable) { measure(); screen.current.focus() }
57
+ else if (state.info !== undefined) screen.current.resize(state.info.cols, state.info.rows)
58
+ }, [state.writable, state.info?.cols, state.info?.rows, model])
59
+ useEffect(() => { if (screen.current !== undefined) screen.current.options.theme = { ...terminalTheme(colorScheme), ...(root.current === null ? {} : { background: getComputedStyle(root.current).backgroundColor }) } }, [colorScheme])
60
+ const error = terminalIssue(state)
61
+ const retry = state.phase === 'failed' || state.phase === 'disconnected'
62
+ const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable
63
+ const ended = state.info?.state === 'exited'
64
+ return <section className="dvw-terminal-pane">
65
+ {(error || retry || readOnly || ended || state.info === undefined) && <div className="dvw-terminal-notice" role="status">
66
+ <span>{error ?? (ended ? `进程已退出(${state.info?.exitCode ?? '—'})` : readOnly ? '其他页面正在控制此终端' : retry ? '终端连接已断开' : '正在启动终端…')}</span>
67
+ {(retry || readOnly) && <button onClick={() => state.info === undefined ? void model.refresh() : model.connect()}>{readOnly ? '接管输入' : '重新连接'}</button>}
68
+ </div>}
69
+ <div ref={root} className="dvw-terminal-host"/>
70
+ </section>
47
71
  }
48
72
 
49
- /** Render interactive terminal tabs in the workbench bottom panel. */
50
- export function TerminalPanel({ sessionId, colorScheme }: BottomPanelProps) {
51
- const workspaceRef = useRef<{ sessionId: string; value: TerminalWorkspaceController }>()
52
- if (workspaceRef.current?.sessionId !== sessionId) workspaceRef.current = { sessionId, value: new TerminalWorkspaceController(sessionId) }
53
- const workspace = workspaceRef.current.value
54
- const snapshot = useSyncExternalStore(workspace.subscribe.bind(workspace), workspace.getSnapshot.bind(workspace))
73
+ /** Keep the incumbent terminal layout while using the official process and stream models. */
74
+ export function TerminalPanel({ workspace, colorScheme }: BottomPanelProps & { workspace: TerminalWorkspaceController }) {
75
+ const snapshot = useSyncExternalStore(workspace.subscribe, workspace.getSnapshot)
76
+ const closeFailures = useSyncExternalStore(workspace.closeFailures.subscribe, workspace.closeFailures.getSnapshot)
55
77
  const [active, setActive] = useState<string>()
56
78
  const [context, setContext] = useState<{ id: string; x: number; y: number }>()
57
79
  const [renaming, setRenaming] = useState<{ id: string; value: string }>()
@@ -72,7 +94,7 @@ export function TerminalPanel({ sessionId, colorScheme }: BottomPanelProps) {
72
94
  window.addEventListener('keydown', escape)
73
95
  return () => { window.removeEventListener('pointerdown', close); window.removeEventListener('keydown', escape) }
74
96
  }, [context])
75
- const closeTerminal = (id: string) => { setContext(undefined); setSplit(current => current?.filter(value => value !== id)); void workspace.close(id) }
97
+ const closeTerminal = (id: string) => { setContext(undefined); setSplit(current => current?.filter(value => value !== id)); workspace.close(id) }
76
98
  const saveRename = (id: string) => { const title = renaming?.id === id ? renaming.value.trim() : ''; setRenaming(undefined); if (title) void workspace.rename(id, title) }
77
99
  const splitTerminal = (first = active) => { if (first === undefined) return; void workspace.create().then(value => { setSplit(current => [...(current?.includes(first) ? current : [first]), value.id]); setActive(value.id) }).catch(() => undefined) }
78
100
  const shown = (split ?? (active === undefined ? [] : [active])).filter(id => snapshot.terminals.some(value => value.id === id))
@@ -80,6 +102,7 @@ export function TerminalPanel({ sessionId, colorScheme }: BottomPanelProps) {
80
102
  return <div className="dvw-terminal-root">
81
103
  <div className="dvw-terminal-main">
82
104
  {snapshot.error !== undefined && <div className="dvw-terminal-error">{snapshot.error}</div>}
105
+ {closeFailures.map(failure => <div className="dvw-terminal-notice" role="alert" key={failure.id}><span>终端“{failure.title}”未能关闭:{failure.message}</span><button onClick={() => workspace.retryClose(failure.id)}>重试关闭</button></div>)}
83
106
  <div className="dvw-terminal-panes" data-split={shown.length > 1 || undefined}>{shown.map(id => <XtermView key={id} workspace={workspace} id={id} colorScheme={colorScheme} />)}</div>
84
107
  </div>
85
108
  <div className="dvw-terminal-resizer" role="separator" aria-label="终端列表宽度" aria-orientation="vertical" onPointerDown={event => {