- TerminalView: replace whole-settings subscription with five useShallow field groups; theme writes no longer refit every pane, and unrelated settings writes no longer touch xterm options at all. useResolvedTheme is now a shallow subscription + memoized lookup. TerminalHandle was dead (no caller ever passed a ref) — dropped forwardRef/useImperativeHandle - FilePanel: fixed-row-height (24px) windowing above 200 entries, no new dependency (antd 6 ships @rc-component/virtual-list only transitively); per-row Dropdown kept. NOTE: .sftp-row is now box-sizing:border-box height:24px, keep in sync with ROW_HEIGHT in FilePanel.tsx - PanelErrorBoundary wraps each dockview panel so a pane crash no longer unmounts the whole workspace (i18n: workspace.error.panelTitle/panelRetry) - SshBottomPanel: memo(FilePanel) — sessionId is the only prop and constant - TransferPanel: ref-held Map + version counter replaces per-event Map copies; memo rows skip unchanged entries - MonitorPanel: ResizeObserver/canvas setup runs once, data updates only redraw
111 lines
4.3 KiB
TypeScript
111 lines
4.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import type { IDockviewPanelProps } from 'dockview-react'
|
|
import { Button } from 'antd'
|
|
import { AreaChartOutlined } from '@ant-design/icons'
|
|
|
|
import { t } from '@shared/i18n'
|
|
import { PanelErrorBoundary } from '../ErrorBoundary'
|
|
import { TerminalView } from '../terminal/TerminalView'
|
|
import { MonitorPanel } from '../monitor/MonitorPanel'
|
|
import { SshBottomPanel } from './SshBottomPanel'
|
|
import { useBroadcastStore } from './broadcastStore'
|
|
import type { TerminalParams } from './Workspace'
|
|
|
|
export interface TerminalPanelExtraProps {
|
|
/**
|
|
* Notifies the workspace that a session is already dead (its process
|
|
* exited) so the panel close skips the pty kill.
|
|
*/
|
|
onSessionDead?: (sessionId: string) => void
|
|
}
|
|
|
|
export type TerminalPanelProps = IDockviewPanelProps<TerminalParams> & TerminalPanelExtraProps
|
|
|
|
/**
|
|
* Dockview content component registered as `'terminal'`.
|
|
*
|
|
* Binds the pty `sessionId` param to <TerminalView>. When the underlying
|
|
* process exits, TerminalView calls `onClose` and we close the panel —
|
|
* marking the session dead first so onDidRemovePanel won't try to kill an
|
|
* already-exited process.
|
|
*
|
|
* For SSH sessions (`sessionKind === 'ssh'`) a collapsible right sidebar with
|
|
* [系统监控] is docked next to the terminal, and a resizable file browser
|
|
* (SshBottomPanel) sits below the terminal. Local sessions render terminal
|
|
* only.
|
|
*/
|
|
export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps): React.JSX.Element {
|
|
const sessionId = params.sessionId ?? ''
|
|
const sessionKind = params.sessionKind
|
|
const isSsh = sessionKind === 'ssh'
|
|
const closedRef = useRef(false)
|
|
/** Right sidebar (monitor) visibility for ssh sessions — expanded by default. */
|
|
const [sideOpen, setSideOpen] = useState(true)
|
|
|
|
// M6 broadcast: keep the session in the broadcast registry while mounted so
|
|
// it can be picked as a target / receive fan-out writes. Registrations are
|
|
// keyed by panel id — an SSH split shows one session in two panes, and
|
|
// unmounting one of them must not unregister the other.
|
|
useEffect(() => {
|
|
useBroadcastStore
|
|
.getState()
|
|
.registerSession({ id: sessionId, panelId: api.id, title: api.title ?? '', isSsh })
|
|
return () => useBroadcastStore.getState().unregisterSession(api.id)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- api is stable; title kept fresh below
|
|
}, [sessionId])
|
|
|
|
// M6: keep the registered broadcast title in sync with the tab title.
|
|
useEffect(() => {
|
|
const disposable = api.onDidTitleChange((event) => {
|
|
useBroadcastStore
|
|
.getState()
|
|
.registerSession({ id: sessionId, panelId: api.id, title: event.title, isSsh })
|
|
})
|
|
return () => disposable.dispose()
|
|
}, [api, sessionId, isSsh])
|
|
|
|
const terminal = (
|
|
<TerminalView
|
|
sessionId={sessionId}
|
|
isSsh={isSsh}
|
|
connectionId={params.connectionId}
|
|
className="workspace-terminal-view"
|
|
onClose={() => {
|
|
if (closedRef.current) return
|
|
closedRef.current = true
|
|
onSessionDead?.(sessionId)
|
|
api.close()
|
|
}}
|
|
/>
|
|
)
|
|
|
|
// A crash inside one pane must not take the workspace — and every other live
|
|
// session — down to the root boundary's reload screen. The pane's own content
|
|
// is remounted on retry; the shell (tab, broadcast registration) survives.
|
|
return (
|
|
<div className="workspace-terminal-panel">
|
|
<PanelErrorBoundary label={t('workspace.error.panelTitle')}>
|
|
{isSsh && (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="workspace-terminal-monitor-toggle"
|
|
icon={<AreaChartOutlined />}
|
|
aria-label={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')}
|
|
title={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')}
|
|
onClick={() => setSideOpen((prev) => !prev)}
|
|
/>
|
|
)}
|
|
<div className="workspace-terminal-split">
|
|
{isSsh ? <SshBottomPanel sessionId={sessionId} terminal={terminal} /> : terminal}
|
|
{isSsh && sideOpen && (
|
|
<div className="workspace-terminal-side">
|
|
<MonitorPanel sessionId={sessionId} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PanelErrorBoundary>
|
|
</div>
|
|
)
|
|
}
|