Files
OpenTerminal/src/renderer/src/workspace/TerminalPanel.tsx
T
Bill 9c75cdc7d4 perf(renderer): field-level settings subscriptions, file list virtualization, per-panel error boundary
- 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
2026-10-07 22:06:31 +08:00

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>
)
}