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
This commit is contained in:
Bill committed 2026-10-07 22:06:31 +08:00
1 parent 6c04f0e8c1
commit 9c75cdc7d4
13 files changed
+547 -257

No files matched your search

+42
View File
@@ -65,3 +65,45 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
return this.props.children return this.props.children
} }
} }
interface PanelErrorBoundaryProps {
children: ReactNode
/** Shown above the message so the user knows which pane failed. */
label?: string
}
/**
* Per-panel boundary. Without it a render crash inside one dockview pane took
* the whole workspace down to the root boundary's reload screen, losing every
* other live session. A crashed pane is replaced in place and can be remounted
* on retry; the rest of the workspace is untouched.
*/
export class PanelErrorBoundary extends Component<PanelErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[PanelErrorBoundary]', this.props.label, error, info.componentStack)
}
/** Clear the error and let React mount the subtree again from scratch. */
private retry = (): void => {
this.setState({ error: null })
}
render(): ReactNode {
if (!this.state.error) return this.props.children
return (
<div className="panel-error">
<div className="panel-error-title">{this.props.label ?? t('workspace.error.panelTitle')}</div>
<pre className="panel-error-message">{this.state.error.message}</pre>
<button type="button" className="panel-error-retry" onClick={this.retry}>
{t('workspace.error.panelRetry')}
</button>
</div>
)
}
}
+45
View File
@@ -105,3 +105,48 @@ button {
opacity: 0.75; opacity: 0.75;
font-weight: 500; font-weight: 500;
} }
/* ---- per-panel error placeholder (PanelErrorBoundary) ---- */
.panel-error {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
box-sizing: border-box;
background: var(--chrome-bg);
color: var(--chrome-fg);
text-align: center;
}
.panel-error-title {
font-size: 13px;
opacity: 0.85;
}
.panel-error-message {
max-width: 100%;
max-height: 40%;
margin: 0;
overflow: auto;
white-space: pre-wrap;
font-size: 12px;
color: #ff6b6b;
}
.panel-error-retry {
padding: 4px 16px;
cursor: pointer;
color: var(--chrome-fg);
background: var(--chrome-hover);
border: 1px solid var(--chrome-border);
border-radius: 4px;
font-size: 12px;
}
.panel-error-retry:hover {
border-color: var(--chrome-accent);
}
+18 -5
View File
@@ -26,6 +26,17 @@ interface HistoryChartProps {
function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChartProps): React.JSX.Element { function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChartProps): React.JSX.Element {
const containerRef = useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null) const canvasRef = useRef<HTMLCanvasElement | null>(null)
// Latest sample + the live draw function. The observer and the canvas
// context are set up ONCE (their inputs are fixed at the call sites); a new
// sample only repaints. The previous version listed `data` in the setup
// effect, so every 3s sample tore down the observer and rebuilt the canvas.
const dataRef = useRef<number[]>(data)
const drawRef = useRef<(() => void) | null>(null)
useEffect(() => {
dataRef.current = data
drawRef.current?.()
}, [data])
useEffect(() => { useEffect(() => {
const container = containerRef.current const container = containerRef.current
@@ -44,7 +55,7 @@ function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChart
ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, width, height) ctx.clearRect(0, 0, width, height)
const pts = data const pts = dataRef.current
if (pts.length < 2) return if (pts.length < 2) return
const top = maxY ?? Math.max(1, Math.max(...pts) * 1.2) const top = maxY ?? Math.max(1, Math.max(...pts) * 1.2)
@@ -76,13 +87,15 @@ function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChart
ctx.stroke() ctx.stroke()
} }
drawRef.current = draw
draw() draw()
const ro = new ResizeObserver(draw) const ro = new ResizeObserver(draw)
ro.observe(container) ro.observe(container)
return () => ro.disconnect() return () => {
// `data` is a dep on purpose: without it the chart only ever drew at mount, ro.disconnect()
// when the history is still empty, and the sparklines never updated. drawRef.current = null
}, [data, height, maxY, lineColor, fillColor]) }
}, [height, maxY, lineColor, fillColor])
return ( return (
<div ref={containerRef} className="mm-chart"> <div ref={containerRef} className="mm-chart">
+9 -3
View File
@@ -2,7 +2,9 @@ import type { AppSettings, LockSettings, SystemSettings, TerminalSettings } from
import { DEFAULT_SETTINGS } from '@shared/settings' import { DEFAULT_SETTINGS } from '@shared/settings'
import type { TerminalTheme } from '@shared/theme' import type { TerminalTheme } from '@shared/theme'
import { getThemeById } from '@shared/theme' import { getThemeById } from '@shared/theme'
import { useMemo } from 'react'
import { create } from 'zustand' import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
export interface SettingsState { export interface SettingsState {
/** true once initial settings have been loaded from the main process */ /** true once initial settings have been loaded from the main process */
@@ -146,9 +148,13 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
/** /**
* Resolve the currently active TerminalTheme (builtin or custom) from the store. * Resolve the currently active TerminalTheme (builtin or custom) from the store.
* Subscribes to settings so consumers re-render when the active theme changes. * Subscribes to the two fields it actually reads (shallow), so an unrelated
* settings write — a font tweak, a lock preference — does not re-render every
* consumer. `getThemeById` rebuilds the array on every call, hence useMemo.
*/ */
export function useResolvedTheme(): TerminalTheme { export function useResolvedTheme(): TerminalTheme {
const settings = useSettingsStore((s) => s.settings) const { themeId, customThemes } = useSettingsStore(
return getThemeById(settings.terminal.themeId, settings.customThemes) useShallow((s) => ({ themeId: s.settings.terminal.themeId, customThemes: s.settings.customThemes }))
)
return useMemo(() => getThemeById(themeId, customThemes), [themeId, customThemes])
} }
+120 -37
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
ArrowUpOutlined, ArrowUpOutlined,
CopyOutlined, CopyOutlined,
@@ -114,6 +114,29 @@ function permColLabels(): string[] {
return [t('ssh.file.permColRead'), t('ssh.file.permColWrite'), t('ssh.file.permColExec')] return [t('ssh.file.permColRead'), t('ssh.file.permColWrite'), t('ssh.file.permColExec')]
} }
/** Row height in px — must match `.sftp-row` in sftp.css. */
const ROW_HEIGHT = 24
/** Rows rendered above and below the viewport so a fast scroll never blanks. */
const OVERSCAN = 8
/**
* Above this entry count the list switches to windowed rendering. Each row
* carries an antd Dropdown, so a node_modules-sized listing (tens of thousands
* of entries) mounted at once locked the window up entirely; below the
* threshold a plain map is cheaper than the bookkeeping.
*/
const VIRTUALIZE_THRESHOLD = 200
/** Slice of the entry list to mount for the current scroll position. */
interface WindowSlice {
start: number
end: number
}
/** First index to render for a given scroll offset and viewport height. */
function windowStart(scrollTop: number): number {
return Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN)
}
interface PermissionModalProps { interface PermissionModalProps {
open: boolean open: boolean
sessionId: string sessionId: string
@@ -316,7 +339,13 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
) )
} }
export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element { /**
* Memoized: `sessionId` is the only prop and it is stable for the panel's
* lifetime, so dragging the SSH divider (which re-renders SshBottomPanel on
* every pointermove) no longer re-renders the whole file browser. Switching to
* a different directory still works — it is internal state.
*/
export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
const { message, modal } = App.useApp() const { message, modal } = App.useApp()
const [dir, setDir] = useState('/') const [dir, setDir] = useState('/')
const [entries, setEntries] = useState<SftpEntry[]>([]) const [entries, setEntries] = useState<SftpEntry[]>([])
@@ -329,6 +358,46 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
const [permOpen, setPermOpen] = useState(false) const [permOpen, setPermOpen] = useState(false)
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
// Windowed list state. `viewportH` is measured off the scroll container and
// `firstVisible` only advances when the scroll offset crosses a row boundary,
// so dragging the scrollbar re-renders at most once per ROW_HEIGHT of travel.
const listRef = useRef<HTMLDivElement | null>(null)
const [viewportH, setViewportH] = useState(0)
const [firstVisible, setFirstVisible] = useState(0)
const virtualized = entries.length > VIRTUALIZE_THRESHOLD
useEffect(() => {
const el = listRef.current
if (!el) return
const measure = (): void => setViewportH(el.clientHeight)
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
// `error` swaps the scroll container out for the error banner, so the
// observer has to be re-attached whenever the container is replaced.
}, [error])
// A new directory starts at the top; the stale scroll offset would otherwise
// leave the window slice pointing past the (now shorter) listing.
useEffect(() => {
const el = listRef.current
if (el) el.scrollTop = 0
setFirstVisible(0)
}, [dir])
const onListScroll = useCallback(() => {
const el = listRef.current
if (!el) return
const start = windowStart(el.scrollTop)
setFirstVisible((prev) => (prev === start ? prev : start))
}, [])
const slice = useMemo((): WindowSlice => {
const visible = Math.ceil(viewportH / ROW_HEIGHT) + OVERSCAN * 2
return { start: firstVisible, end: Math.min(entries.length, firstVisible + visible) }
}, [viewportH, firstVisible, entries.length])
/** the entry the context menu is operating on (selected when right-clicked) */ /** the entry the context menu is operating on (selected when right-clicked) */
const menuTarget = menuEntry ?? selected const menuTarget = menuEntry ?? selected
@@ -624,6 +693,44 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [menuTarget, dir, entries, refresh, language]) }, [menuTarget, dir, entries, refresh, language])
/** One directory entry: icon, name, permissions, owner, size, mtime + menu. */
const renderRow = (e: SftpEntry): React.JSX.Element => (
<Dropdown
key={e.path}
menu={menu}
trigger={['contextMenu']}
getPopupContainer={() => document.body}
onOpenChange={(open) => {
if (!open) setMenuEntry(null)
}}
>
<div
className={`sftp-row${selected?.path === e.path ? ' sftp-row-selected' : ''}`}
onClick={() => setSelected(e)}
onContextMenu={() => {
setSelected(e)
setMenuEntry(e)
}}
onDoubleClick={() => (e.isDir ? void refresh(e.path) : doDownload(e))}
>
<span className="sftp-icon">{e.isDir ? <FolderOutlined className="sftp-icon-dir" /> : <FileOutlined />}</span>
<span className="sftp-name" title={e.name}>
{e.name}
</span>
<span className="sftp-mode" title={e.mode ?? t('ssh.file.modeUnavailable')}>
{e.mode ?? '----------'}
</span>
<span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
{e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'}
</span>
<span className="sftp-size" title={e.isDir ? '' : `${e.size} bytes`}>
{e.isDir ? '' : fmtSize(e.size)}
</span>
<span className="sftp-time">{fmtTime(e.mtime)}</span>
</div>
</Dropdown>
)
return ( return (
<div className="sftp-panel"> <div className="sftp-panel">
<div className="sftp-toolbar"> <div className="sftp-toolbar">
@@ -707,45 +814,21 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
{error ? ( {error ? (
<div className="sftp-error">{error}</div> <div className="sftp-error">{error}</div>
) : ( ) : (
<div className="sftp-list"> <div className="sftp-list" ref={listRef} onScroll={onListScroll}>
{loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.loading')}</div>} {loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.loading')}</div>}
{!loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.empty')}</div>} {!loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.empty')}</div>}
{entries.map((e) => ( {virtualized ? (
<Dropdown <div className="sftp-list-virtual" style={{ height: entries.length * ROW_HEIGHT }}>
key={e.path}
menu={menu}
trigger={['contextMenu']}
getPopupContainer={() => document.body}
onOpenChange={(open) => {
if (!open) setMenuEntry(null)
}}
>
<div <div
className={`sftp-row${selected?.path === e.path ? ' sftp-row-selected' : ''}`} className="sftp-list-window"
onClick={() => setSelected(e)} style={{ transform: `translateY(${slice.start * ROW_HEIGHT}px)` }}
onContextMenu={() => {
setSelected(e)
setMenuEntry(e)
}}
onDoubleClick={() => (e.isDir ? void refresh(e.path) : doDownload(e))}
> >
<span className="sftp-icon">{e.isDir ? <FolderOutlined className="sftp-icon-dir" /> : <FileOutlined />}</span> {entries.slice(slice.start, slice.end).map(renderRow)}
<span className="sftp-name" title={e.name}>
{e.name}
</span>
<span className="sftp-mode" title={e.mode ?? t('ssh.file.modeUnavailable')}>
{e.mode ?? '----------'}
</span>
<span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
{e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'}
</span>
<span className="sftp-size" title={e.isDir ? '' : `${e.size} bytes`}>
{e.isDir ? '' : fmtSize(e.size)}
</span>
<span className="sftp-time">{fmtTime(e.mtime)}</span>
</div> </div>
</Dropdown> </div>
))} ) : (
entries.map(renderRow)
)}
</div> </div>
)} )}
@@ -791,4 +874,4 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
</Modal> </Modal>
</div> </div>
) )
} })
+133 -98
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { CloseOutlined } from '@ant-design/icons' import { CloseOutlined } from '@ant-design/icons'
import type { TransferKind, TransferProgressEvent, TransferState } from '@shared/sftp' import type { TransferKind, TransferProgressEvent, TransferState } from '@shared/sftp'
import { t } from '@shared/i18n' import { t } from '@shared/i18n'
@@ -14,12 +14,89 @@ interface TransferEntry {
error?: string error?: string
} }
interface TransferRowProps {
id: string
entry: TransferEntry
onDismiss: (id: string) => void
}
/**
* Memoized on the entry object: the progress channel replaces only the entry a
* transfer owns, so a busy multi-transfer overlay re-renders the advancing row
* and leaves the others alone.
*/
const TransferRow = memo(function TransferRow({ id, entry, onDismiss }: TransferRowProps): React.JSX.Element {
const pct =
entry.totalBytes > 0
? Math.min(100, Math.round((entry.bytes / entry.totalBytes) * 100))
: entry.state === 'done'
? 100
: 0
return (
<div className={`sftp-transfer sftp-transfer-${entry.state}`}>
<div className="sftp-transfer-head">
<span className="sftp-transfer-title">
{entry.state === 'error'
? t('panels.transfer.failed')
: entry.state === 'cancelled'
? t('panels.transfer.cancelled')
: entry.state === 'done'
? t('panels.transfer.done')
: entry.kind === 'upload'
? t('panels.transfer.uploading')
: entry.kind === 'download'
? t('panels.transfer.downloading')
: entry.kind === 'zmodem-upload'
? t('panels.transfer.zmodemUploading')
: t('panels.transfer.zmodemDownloading')}
</span>
{/* Only the SFTP engine's own transfers can be cancelled; zmodem
runs in a separate engine that ignores cancelTransfer. */}
{entry.state === 'running' && (entry.kind === 'upload' || entry.kind === 'download') && (
<button
type="button"
className="sftp-transfer-cancel"
onClick={() => window.api.cancelTransfer(id)}
>
{t('common.cancel')}
</button>
)}
{/* Cancelled rows dismiss themselves after 3s, but one that also
carries an error (a cancel racing a failed file) skips that
path, so every terminal state keeps a manual close. */}
{(entry.state === 'error' || entry.state === 'done' || entry.state === 'cancelled') && (
<button
type="button"
className="sftp-transfer-close"
onClick={() => onDismiss(id)}
>
<CloseOutlined />
</button>
)}
</div>
<div className="sftp-transfer-file">{entry.file || entry.error || ''}</div>
{entry.state === 'running' && entry.totalBytes > 0 && (
<div className="sftp-transfer-bar">
<div className="sftp-transfer-fill" style={{ width: `${pct}%` }} />
</div>
)}
{entry.error && <div className="sftp-transfer-err">{entry.error}</div>}
</div>
)
})
/** /**
* Global transfer overlay (bottom-right). Aggregates progress events by * Global transfer overlay (bottom-right). Aggregates progress events by
* transferId; done transfers fade out, errors stay until dismissed. * transferId; done transfers fade out, errors stay until dismissed.
*
* The entries live in a ref and only a version counter reaches React: a
* progress event used to rebuild the whole Map per event, which is O(transfers)
* allocation on the hottest IPC stream in the app. Mutation stays outside
* render, so React never observes a half-updated map.
*/ */
export function TransferPanel(): React.JSX.Element | null { export function TransferPanel(): React.JSX.Element | null {
const [transfers, setTransfers] = useState<Map<string, TransferEntry>>(new Map()) const entriesRef = useRef<Map<string, TransferEntry>>(new Map())
const [version, setVersion] = useState(0)
/** auto-dismiss timers, keyed by transfer id (never more than one per run) */ /** auto-dismiss timers, keyed by transfer id (never more than one per run) */
const timersRef = useRef<Map<string, number>>(new Map()) const timersRef = useRef<Map<string, number>>(new Map())
@@ -29,57 +106,66 @@ export function TransferPanel(): React.JSX.Element | null {
window.clearTimeout(timer) window.clearTimeout(timer)
timersRef.current.delete(id) timersRef.current.delete(id)
} }
setTransfers((prev) => { if (!entriesRef.current.has(id)) return
if (!prev.has(id)) return prev entriesRef.current.delete(id)
const next = new Map(prev) setVersion((v) => v + 1)
next.delete(id)
return next
})
}, []) }, [])
useEffect(() => { useEffect(() => {
const off = window.api.onTransferProgress((e: TransferProgressEvent) => { const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
setTransfers((prev) => { const entries = entriesRef.current
const next = new Map(prev) const existing = entries.get(e.transferId)
const existing = next.get(e.transferId) let next: TransferEntry
if (e.file) { if (e.file) {
next.set(e.transferId, { next = {
kind: e.kind, kind: e.kind,
file: e.file, file: e.file,
bytes: e.bytes, bytes: e.bytes,
totalBytes: e.totalBytes, totalBytes: e.totalBytes,
state: e.state, state: e.state,
error: e.error error: e.error
})
} else {
// Terminal event for the whole transfer. It can arrive without any
// per-file progress first (e.g. the remote open was denied), so a
// missing entry is created rather than dropped — otherwise the
// failure would be visible nowhere.
const base: TransferEntry = existing ?? {
kind: e.kind,
file: '',
bytes: e.bytes,
totalBytes: e.totalBytes,
state: 'running'
}
if (e.state === 'error') {
next.set(e.transferId, { ...base, state: 'error', error: e.error })
} else if (e.state === 'cancelled') {
next.set(e.transferId, { ...base, state: 'cancelled' })
} else {
next.set(e.transferId, { ...base, state: 'done' })
}
} }
return next } else {
}) // Terminal event for the whole transfer. It can arrive without any
// per-file progress first (e.g. the remote open was denied), so a
// missing entry is created rather than dropped — otherwise the
// failure would be visible nowhere.
const base: TransferEntry = existing ?? {
kind: e.kind,
file: '',
bytes: e.bytes,
totalBytes: e.totalBytes,
state: 'running'
}
if (e.state === 'error') {
next = { ...base, state: 'error', error: e.error }
} else if (e.state === 'cancelled') {
next = { ...base, state: 'cancelled' }
} else {
next = { ...base, state: 'done' }
}
}
// Skip the render for a progress tick that changed nothing visible
// (a repeat of the same file at the same byte count is common).
if (
existing &&
existing.file === next.file &&
existing.bytes === next.bytes &&
existing.totalBytes === next.totalBytes &&
existing.state === next.state &&
existing.error === next.error
) {
return
}
entries.set(e.transferId, next)
setVersion((v) => v + 1)
}) })
return off return off
}, []) }, [])
// auto-dismiss finished entries after 3s // auto-dismiss finished entries after 3s
useEffect(() => { useEffect(() => {
for (const [id, tr] of transfers) { for (const [id, tr] of entriesRef.current) {
if ((tr.state === 'done' || tr.state === 'cancelled') && !tr.error && !timersRef.current.has(id)) { if ((tr.state === 'done' || tr.state === 'cancelled') && !tr.error && !timersRef.current.has(id)) {
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
timersRef.current.delete(id) timersRef.current.delete(id)
@@ -88,7 +174,7 @@ export function TransferPanel(): React.JSX.Element | null {
timersRef.current.set(id, timer) timersRef.current.set(id, timer)
} }
} }
}, [transfers, dismiss]) }, [version, dismiss])
useEffect(() => { useEffect(() => {
const timers = timersRef.current const timers = timersRef.current
@@ -98,65 +184,14 @@ export function TransferPanel(): React.JSX.Element | null {
} }
}, []) }, [])
const list = [...transfers.entries()] const list = [...entriesRef.current.entries()]
if (list.length === 0) return null if (list.length === 0) return null
return ( return (
<div className="sftp-transfer-panel"> <div className="sftp-transfer-panel">
{list.map(([id, tr]) => { {list.map(([id, entry]) => (
const pct = tr.totalBytes > 0 ? Math.min(100, Math.round((tr.bytes / tr.totalBytes) * 100)) : tr.state === 'done' ? 100 : 0 <TransferRow key={id} id={id} entry={entry} onDismiss={dismiss} />
return ( ))}
<div key={id} className={`sftp-transfer sftp-transfer-${tr.state}`}>
<div className="sftp-transfer-head">
<span className="sftp-transfer-title">
{tr.state === 'error'
? t('panels.transfer.failed')
: tr.state === 'cancelled'
? t('panels.transfer.cancelled')
: tr.state === 'done'
? t('panels.transfer.done')
: tr.kind === 'upload'
? t('panels.transfer.uploading')
: tr.kind === 'download'
? t('panels.transfer.downloading')
: tr.kind === 'zmodem-upload'
? t('panels.transfer.zmodemUploading')
: t('panels.transfer.zmodemDownloading')}
</span>
{/* Only the SFTP engine's own transfers can be cancelled; zmodem
runs in a separate engine that ignores cancelTransfer. */}
{tr.state === 'running' && (tr.kind === 'upload' || tr.kind === 'download') && (
<button
type="button"
className="sftp-transfer-cancel"
onClick={() => window.api.cancelTransfer(id)}
>
{t('common.cancel')}
</button>
)}
{/* Cancelled rows dismiss themselves after 3s, but one that also
carries an error (a cancel racing a failed file) skips that
path, so every terminal state keeps a manual close. */}
{(tr.state === 'error' || tr.state === 'done' || tr.state === 'cancelled') && (
<button
type="button"
className="sftp-transfer-close"
onClick={() => dismiss(id)}
>
<CloseOutlined />
</button>
)}
</div>
<div className="sftp-transfer-file">{tr.file || tr.error || ''}</div>
{tr.state === 'running' && tr.totalBytes > 0 && (
<div className="sftp-transfer-bar">
<div className="sftp-transfer-fill" style={{ width: `${pct}%` }} />
</div>
)}
{tr.error && <div className="sftp-transfer-err">{tr.error}</div>}
</div>
)
})}
</div> </div>
) )
} }
+24
View File
@@ -37,6 +37,24 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
/* Anchor for the windowed list: the spacer keeps the scrollbar honest and
the rendered window is absolutely positioned on top of it. */
position: relative;
}
/* Windowed list (node_modules-sized directories). Only the visible slice of
rows plus the overscan is mounted; the spacer carries the full scroll height. */
.sftp-list-virtual {
position: relative;
width: 100%;
}
.sftp-list-window {
position: absolute;
top: 0;
left: 0;
right: 0;
will-change: transform;
} }
/* Right-click menu wrapper: antd wraps each row in a span by default; /* Right-click menu wrapper: antd wraps each row in a span by default;
@@ -51,6 +69,12 @@
align-items: center; align-items: center;
column-gap: 8px; column-gap: 8px;
padding: 3px 8px; padding: 3px 8px;
/* Fixed outer height so the windowed list can offset rows arithmetically.
border-box is required — there is no global reset, so content-box would
add the 6px of vertical padding on top. Keep in sync with ROW_HEIGHT in
FilePanel.tsx. */
box-sizing: border-box;
height: 24px;
font-size: 13px; font-size: 13px;
color: var(--chrome-fg); color: var(--chrome-fg);
cursor: pointer; cursor: pointer;
+120 -92
View File
@@ -1,5 +1,4 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ForwardRefExoticComponent, Ref } from 'react'
import { Terminal } from '@xterm/xterm' import { Terminal } from '@xterm/xterm'
import type { ILink, ITheme } from '@xterm/xterm' import type { ILink, ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit'
@@ -14,6 +13,7 @@ import type { CommandItem } from '@shared/commands'
import { highlightModeOf, type TerminalSettings } from '@shared/settings' import { highlightModeOf, type TerminalSettings } from '@shared/settings'
import { rulesForProfile } from '@shared/highlightProfiles' import { rulesForProfile } from '@shared/highlightProfiles'
import { useResolvedTheme, useSettingsStore } from '@renderer/settings/store' import { useResolvedTheme, useSettingsStore } from '@renderer/settings/store'
import { useShallow } from 'zustand/react/shallow'
import { writeBroadcast } from '@renderer/workspace/broadcastStore' import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream, type RuleStat } from './highlightEngine' import { compileRules, HighlightStream, type RuleStat } from './highlightEngine'
import { publishHighlightStats } from './highlightStats' import { publishHighlightStats } from './highlightStats'
@@ -94,13 +94,6 @@ function toFileUrl(path: string): string {
return `otimg://bg/${encodeURIComponent(path)}` return `otimg://bg/${encodeURIComponent(path)}`
} }
export interface TerminalHandle {
focus(): void
clear(): void
/** currently attached pty session id */
sessionId(): string
}
export interface TerminalViewProps { export interface TerminalViewProps {
/** pty session created via window.api.createPty() */ /** pty session created via window.api.createPty() */
sessionId: string sessionId: string
@@ -268,12 +261,61 @@ function differenceWrite(sessionId: string, full: string, buf: string): void {
writeBroadcast(sessionId, full) writeBroadcast(sessionId, full)
} }
export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?: Ref<TerminalHandle> }> = export function TerminalView({
forwardRef<TerminalHandle, TerminalViewProps>(function TerminalViewInner( sessionId,
{ sessionId, onClose, className, isSsh = false, connectionId }, onClose,
ref className,
) { isSsh = false,
const settings = useSettingsStore((s) => s.settings) connectionId
}: TerminalViewProps): React.JSX.Element {
// Field-level subscriptions. A single `s.settings` subscription made every
// pane re-write every xterm option and refit on ANY settings write — the
// whole-`settings` object identity changes even when an unrelated group
// (lock, system, connections) is what got written. Each group below is one
// shallow subscription, so a write only reaches the panes whose own fields
// actually changed.
const fontOpts = useSettingsStore(
useShallow((s) => ({
fontSize: s.settings.terminal.fontSize,
fontFamily: s.settings.terminal.fontFamily,
fontWeight: s.settings.terminal.fontWeight,
boldFontWeight: s.settings.terminal.boldFontWeight,
letterSpacing: s.settings.terminal.letterSpacing,
lineHeight: s.settings.terminal.lineHeight,
scrollback: s.settings.terminal.scrollback,
cursorBlink: s.settings.terminal.cursorBlink,
cursorStyle: s.settings.terminal.cursorStyle,
cursorInactiveStyle: s.settings.terminal.cursorInactiveStyle
}))
)
const themeOpts = useSettingsStore(
useShallow((s) => ({ themeId: s.settings.terminal.themeId, customThemes: s.settings.customThemes }))
)
const bgOpts = useSettingsStore(
useShallow((s) => ({
image: s.settings.terminal.backgroundImage,
opacity: s.settings.terminal.backgroundImageOpacity,
dim: s.settings.terminal.backgroundImageDim
}))
)
const rendererMode = useSettingsStore((s) => s.settings.terminal.rendererMode)
const highlightOpts = useSettingsStore(
useShallow((s) => ({
mode: highlightModeOf(s.settings.terminal.highlightMode),
rules: s.settings.highlightRules,
profiles: s.settings.highlightProfiles,
perHost: s.settings.terminal.highlightPerHost,
themeColors: s.settings.terminal.highlightThemeColors,
stats: s.settings.terminal.highlightStats
}))
)
const toolbar = useSettingsStore(
useShallow((s) => ({
showRecButton: s.settings.terminal.showRecButton,
showOpenLogsButton: s.settings.terminal.showOpenLogsButton,
showOpenCwdButton: s.settings.terminal.showOpenCwdButton
}))
)
const hostRef = useRef<HTMLDivElement | null>(null) const hostRef = useRef<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null) const termRef = useRef<Terminal | null>(null)
@@ -321,14 +363,13 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// The mode empties the rule set instead of bypassing the stream: the stream // The mode empties the rule set instead of bypassing the stream: the stream
// holds a trailing text run back, and skipping it would drop those bytes. An // holds a trailing text run back, and skipping it would drop those bytes. An
// empty set is ~free — applyHighlights returns the chunk before it tokenizes. // empty set is ~free — applyHighlights returns the chunk before it tokenizes.
const highlightMode = highlightModeOf(settings.terminal.highlightMode)
const highlightTheme = useResolvedTheme() const highlightTheme = useResolvedTheme()
// Per-host profiles (opt-in): an SSH session bound to a profile runs only that // Per-host profiles (opt-in): an SSH session bound to a profile runs only that
// subset. The binding lives on the saved connection, so it is looked up once // subset. The binding lives on the saved connection, so it is looked up once
// per session — and only while the feature is switched on. // per session — and only while the feature is switched on.
const [boundProfileId, setBoundProfileId] = useState<string | undefined>(undefined) const [boundProfileId, setBoundProfileId] = useState<string | undefined>(undefined)
useEffect(() => { useEffect(() => {
if (connectionId === undefined || !settings.terminal.highlightPerHost) { if (connectionId === undefined || !highlightOpts.perHost) {
setBoundProfileId(undefined) setBoundProfileId(undefined)
return undefined return undefined
} }
@@ -344,31 +385,23 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
return () => { return () => {
cancelled = true cancelled = true
} }
}, [connectionId, settings.terminal.highlightPerHost]) }, [connectionId, highlightOpts.perHost])
const compiledRules = useMemo(() => { const compiledRules = useMemo(() => {
if (highlightMode === 'off') return [] if (highlightOpts.mode === 'off') return []
const active = const active =
highlightMode === 'basic' highlightOpts.mode === 'basic'
? settings.highlightRules.filter((rule) => rule.basic === true) ? highlightOpts.rules.filter((rule) => rule.basic === true)
: settings.highlightRules : highlightOpts.rules
const perHost = settings.terminal.highlightPerHost const perHost = highlightOpts.perHost
? rulesForProfile(active, boundProfileId, settings.highlightProfiles) ? rulesForProfile(active, boundProfileId, highlightOpts.profiles)
: active : active
// Palette-consistent highlighting (opt-in): the rule keeps its meaning, the // Palette-consistent highlighting (opt-in): the rule keeps its meaning, the
// colour comes from the theme the terminal is actually drawn with. // colour comes from the theme the terminal is actually drawn with.
return compileRules( return compileRules(
settings.terminal.highlightThemeColors ? applyThemeColors(perHost, highlightTheme) : perHost highlightOpts.themeColors ? applyThemeColors(perHost, highlightTheme) : perHost
) )
}, [ }, [highlightOpts, boundProfileId, highlightTheme])
highlightMode,
settings.highlightRules,
settings.highlightProfiles,
settings.terminal.highlightThemeColors,
settings.terminal.highlightPerHost,
boundProfileId,
highlightTheme
])
// Optional per-rule stats (settings → highlight → "count hits and time"). // Optional per-rule stats (settings → highlight → "count hits and time").
// Attached and detached here, so toggling the switch never rebuilds the // Attached and detached here, so toggling the switch never rebuilds the
@@ -376,7 +409,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// re-render the settings page once per chunk. // re-render the settings page once per chunk.
const statsRef = useRef<Map<string, RuleStat> | null>(null) const statsRef = useRef<Map<string, RuleStat> | null>(null)
useEffect(() => { useEffect(() => {
const sink = settings.terminal.highlightStats const sink = highlightOpts.stats
? (statsRef.current ?? new Map<string, RuleStat>()) ? (statsRef.current ?? new Map<string, RuleStat>())
: null : null
statsRef.current = sink statsRef.current = sink
@@ -385,7 +418,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
publishHighlightStats(new Map(sink)) publishHighlightStats(new Map(sink))
const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000) const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000)
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [settings.terminal.highlightStats]) }, [highlightOpts.stats])
// Push the latest compiled rules into the stream from an effect — assigning // Push the latest compiled rules into the stream from an effect — assigning
// during render would be a side effect in the render body. // during render would be a side effect in the render body.
useEffect(() => { useEffect(() => {
@@ -420,7 +453,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// Inline command-completion overlays built from history/library (M5). // Inline command-completion overlays built from history/library (M5).
const [suggestions, setSuggestions] = useState<CommandItem[]>([]) const [suggestions, setSuggestions] = useState<CommandItem[]>([])
const [suggestionIndex, setSuggestionIndex] = useState(0) const [suggestionIndex, setSuggestionIndex] = useState(0)
const tSettings = settings.terminal
// [M5] Mirror completion selection into state's single source of truth. Kept // [M5] Mirror completion selection into state's single source of truth. Kept
// as a pair of effects so the construction-time key handler (below) always // as a pair of effects so the construction-time key handler (below) always
@@ -763,17 +795,17 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// ---- terminal instance: built once per session ---- // ---- terminal instance: built once per session ----
useLayoutEffect(() => { useLayoutEffect(() => {
const term = new Terminal({ const term = new Terminal({
fontSize: tSettings.fontSize, fontSize: fontOpts.fontSize,
fontFamily: tSettings.fontFamily, fontFamily: fontOpts.fontFamily,
fontWeight: toFontWeight(tSettings.fontWeight), fontWeight: toFontWeight(fontOpts.fontWeight),
fontWeightBold: toFontWeight(tSettings.boldFontWeight), fontWeightBold: toFontWeight(fontOpts.boldFontWeight),
letterSpacing: tSettings.letterSpacing, letterSpacing: fontOpts.letterSpacing,
lineHeight: tSettings.lineHeight, lineHeight: fontOpts.lineHeight,
scrollback: tSettings.scrollback, scrollback: fontOpts.scrollback,
cursorBlink: tSettings.cursorBlink, cursorBlink: fontOpts.cursorBlink,
cursorStyle: tSettings.cursorStyle, cursorStyle: fontOpts.cursorStyle,
cursorInactiveStyle: tSettings.cursorInactiveStyle, cursorInactiveStyle: fontOpts.cursorInactiveStyle,
theme: withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme, Boolean(tSettings.backgroundImage)), theme: withChromeColors(getThemeById(themeOpts.themeId, themeOpts.customThemes).colors as ITheme, bgOpts.image !== ''),
allowProposedApi: true, allowProposedApi: true,
allowTransparency: true, allowTransparency: true,
overviewRuler: { width: 9, showTopBorder: false, showBottomBorder: false }, overviewRuler: { width: 9, showTopBorder: false, showBottomBorder: false },
@@ -782,7 +814,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// xterm's contrast cache treats as black; a raised contrast floor lifts // xterm's contrast cache treats as black; a raised contrast floor lifts
// dark foreground colors (light themes) enough to stay readable over // dark foreground colors (light themes) enough to stay readable over
// the wallpaper. Without an image keep 1 (off) to honor theme colors. // the wallpaper. Without an image keep 1 (off) to honor theme colors.
minimumContrastRatio: tSettings.backgroundImage ? 4.5 : 1, minimumContrastRatio: bgOpts.image ? 4.5 : 1,
automaticFontFallback: true, automaticFontFallback: true,
convertEol: false, convertEol: false,
windowsPty: { backend: 'conpty', buildNumber: 0 } windowsPty: { backend: 'conpty', buildNumber: 0 }
@@ -850,7 +882,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
recordingRef.current = false recordingRef.current = false
setRecording(false) setRecording(false)
} }
applyRenderers(tSettings.rendererMode) applyRenderers(rendererMode)
scheduleFit() scheduleFit()
// xterm event hooks return IDisposable objects, api subscriptions return // xterm event hooks return IDisposable objects, api subscriptions return
@@ -1101,29 +1133,41 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
}, [sessionId]) }, [sessionId])
// ---- options reactivity (no instance rebuild) ---- // ---- options reactivity (no instance rebuild) ----
// Split by concern: font/cursor metrics refit on change, the palette (theme +
// background-image mode, which decides transparency and the contrast floor)
// repaints without a refit. Keeping them apart stops each group from
// re-triggering the other's work on every keystroke in the settings dialog.
useEffect(() => { useEffect(() => {
const term = termRef.current const term = termRef.current
if (!term) return if (!term) return
term.options.fontSize = tSettings.fontSize term.options.fontSize = fontOpts.fontSize
term.options.fontFamily = tSettings.fontFamily term.options.fontFamily = fontOpts.fontFamily
term.options.fontWeight = toFontWeight(tSettings.fontWeight) term.options.fontWeight = toFontWeight(fontOpts.fontWeight)
term.options.fontWeightBold = toFontWeight(tSettings.boldFontWeight) term.options.fontWeightBold = toFontWeight(fontOpts.boldFontWeight)
term.options.letterSpacing = tSettings.letterSpacing term.options.letterSpacing = fontOpts.letterSpacing
term.options.lineHeight = tSettings.lineHeight term.options.lineHeight = fontOpts.lineHeight
term.options.cursorBlink = tSettings.cursorBlink term.options.cursorBlink = fontOpts.cursorBlink
term.options.cursorStyle = tSettings.cursorStyle term.options.cursorStyle = fontOpts.cursorStyle
term.options.cursorInactiveStyle = tSettings.cursorInactiveStyle term.options.cursorInactiveStyle = fontOpts.cursorInactiveStyle
// Live-appliable in xterm 6: without this the setting only took effect for // Live-appliable in xterm 6: without this the setting only took effect for
// terminals opened after the change, which reads as "the setting is broken". // terminals opened after the change, which reads as "the setting is broken".
term.options.scrollback = tSettings.scrollback term.options.scrollback = fontOpts.scrollback
term.options.theme = withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme, Boolean(tSettings.backgroundImage))
term.options.minimumContrastRatio = tSettings.backgroundImage ? 4.5 : 1
scheduleFit() scheduleFit()
}, [tSettings, settings.customThemes, scheduleFit]) }, [fontOpts, scheduleFit])
useEffect(() => {
const term = termRef.current
if (!term) return
term.options.theme = withChromeColors(
getThemeById(themeOpts.themeId, themeOpts.customThemes).colors as ITheme,
bgOpts.image !== ''
)
term.options.minimumContrastRatio = bgOpts.image ? 4.5 : 1
}, [themeOpts, bgOpts])
// ---- renderer mode changes: keep the instance, swap renderer, refit ---- // ---- renderer mode changes: keep the instance, swap renderer, refit ----
useEffect(() => { useEffect(() => {
const next = tSettings.rendererMode const next = rendererMode
// The construction effect above already applied the mode on mount (this // The construction effect above already applied the mode on mount (this
// effect runs on mount too); only an actual switch should re-run it. // effect runs on mount too); only an actual switch should re-run it.
if (next === rendererModeRef.current) return if (next === rendererModeRef.current) return
@@ -1133,35 +1177,19 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
applyRenderers(next) applyRenderers(next)
} }
scheduleFit() scheduleFit()
}, [tSettings.rendererMode, applyRenderers, scheduleFit]) }, [rendererMode, applyRenderers, scheduleFit])
// keep onClose fresh for the dead mask // keep onClose fresh for the dead mask
useEffect(() => { useEffect(() => {
onCloseRef.current = onClose onCloseRef.current = onClose
}, [onClose]) }, [onClose])
useImperativeHandle(
ref,
() => ({
focus() {
termRef.current?.focus()
},
clear() {
termRef.current?.clear()
},
sessionId() {
return sessionId
}
}),
[sessionId]
)
const terminalClassName = useMemo( const terminalClassName = useMemo(
() => () =>
['terminal-view', settings.terminal.backgroundImage !== '' && 'has-bg-image', className] ['terminal-view', bgOpts.image !== '' && 'has-bg-image', className]
.filter(Boolean) .filter(Boolean)
.join(' '), .join(' '),
[className, settings.terminal.backgroundImage] [className, bgOpts.image]
) )
/** /**
@@ -1295,7 +1323,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
/> />
)} )}
<div className="term-recbar"> <div className="term-recbar">
{settings.terminal.showRecButton && ( {toolbar.showRecButton && (
<button <button
type="button" type="button"
className={`term-rec-btn${recording ? ' is-recording' : ''}`} className={`term-rec-btn${recording ? ' is-recording' : ''}`}
@@ -1309,7 +1337,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
{recording ? <PauseOutlined /> : <SoundOutlined />} {recording ? <PauseOutlined /> : <SoundOutlined />}
</button> </button>
)} )}
{settings.terminal.showOpenLogsButton && ( {toolbar.showOpenLogsButton && (
<button <button
type="button" type="button"
className="term-rec-btn" className="term-rec-btn"
@@ -1320,7 +1348,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<FolderOpenOutlined /> <FolderOpenOutlined />
</button> </button>
)} )}
{settings.terminal.showOpenCwdButton && !isSsh && ( {toolbar.showOpenCwdButton && !isSsh && (
<button <button
type="button" type="button"
className="term-rec-btn" className="term-rec-btn"
@@ -1391,20 +1419,20 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
</div> </div>
)} )}
<div className="terminal-view-dock" ref={hostRef}> <div className="terminal-view-dock" ref={hostRef}>
{settings.terminal.backgroundImage !== '' && ( {bgOpts.image !== '' && (
<div <div
className="term-bg-image" className="term-bg-image"
style={{ style={{
backgroundImage: `url("${toFileUrl(settings.terminal.backgroundImage)}")`, backgroundImage: `url("${toFileUrl(bgOpts.image)}")`,
opacity: Math.min(100, Math.max(10, settings.terminal.backgroundImageOpacity)) / 100 opacity: Math.min(100, Math.max(10, bgOpts.opacity)) / 100
}} }}
/> />
)} )}
{settings.terminal.backgroundImage !== '' && settings.terminal.backgroundImageDim > 0 && ( {bgOpts.image !== '' && bgOpts.dim > 0 && (
<div <div
className="term-bg-dim" className="term-bg-dim"
style={{ style={{
opacity: Math.min(90, Math.max(0, settings.terminal.backgroundImageDim)) / 100 opacity: Math.min(90, Math.max(0, bgOpts.dim)) / 100
}} }}
/> />
)} )}
@@ -1418,4 +1446,4 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
</div> </div>
</div> </div>
) )
}) }
+24 -18
View File
@@ -4,6 +4,7 @@ import { Button } from 'antd'
import { AreaChartOutlined } from '@ant-design/icons' import { AreaChartOutlined } from '@ant-design/icons'
import { t } from '@shared/i18n' import { t } from '@shared/i18n'
import { PanelErrorBoundary } from '../ErrorBoundary'
import { TerminalView } from '../terminal/TerminalView' import { TerminalView } from '../terminal/TerminalView'
import { MonitorPanel } from '../monitor/MonitorPanel' import { MonitorPanel } from '../monitor/MonitorPanel'
import { SshBottomPanel } from './SshBottomPanel' import { SshBottomPanel } from './SshBottomPanel'
@@ -78,27 +79,32 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
/> />
) )
// 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 ( return (
<div className="workspace-terminal-panel"> <div className="workspace-terminal-panel">
{isSsh && ( <PanelErrorBoundary label={t('workspace.error.panelTitle')}>
<Button {isSsh && (
type="text" <Button
size="small" type="text"
className="workspace-terminal-monitor-toggle" size="small"
icon={<AreaChartOutlined />} className="workspace-terminal-monitor-toggle"
aria-label={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')} icon={<AreaChartOutlined />}
title={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')} aria-label={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')}
onClick={() => setSideOpen((prev) => !prev)} 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> <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> </div>
) )
} }
+3 -1
View File
@@ -57,7 +57,9 @@ const workspace: Record<string, string> = {
'workspace.connect.connectingNamed': 'Connecting to {name}', 'workspace.connect.connectingNamed': 'Connecting to {name}',
'workspace.connect.action': 'Connect', 'workspace.connect.action': 'Connect',
'workspace.error.title': 'The interface hit an error', 'workspace.error.title': 'The interface hit an error',
'workspace.error.reload': 'Reload' 'workspace.error.reload': 'Reload',
'workspace.error.panelTitle': 'This panel hit an error',
'workspace.error.panelRetry': 'Retry'
} }
export default workspace export default workspace
+3 -1
View File
@@ -58,7 +58,9 @@ const workspace: Record<string, string> = {
'workspace.connect.connectingNamed': '{name} に接続しています', 'workspace.connect.connectingNamed': '{name} に接続しています',
'workspace.connect.action': '接続', 'workspace.connect.action': '接続',
'workspace.error.title': '画面でエラーが発生しました', 'workspace.error.title': '画面でエラーが発生しました',
'workspace.error.reload': '再読み込み' 'workspace.error.reload': '再読み込み',
'workspace.error.panelTitle': 'このパネルでエラーが発生しました',
'workspace.error.panelRetry': '再試行'
} }
export default workspace export default workspace
+3 -1
View File
@@ -56,7 +56,9 @@ const workspace: Record<string, string> = {
'workspace.connect.connectingNamed': '正在连接 {name}', 'workspace.connect.connectingNamed': '正在连接 {name}',
'workspace.connect.action': '连接', 'workspace.connect.action': '连接',
'workspace.error.title': '界面出现异常', 'workspace.error.title': '界面出现异常',
'workspace.error.reload': '重新加载' 'workspace.error.reload': '重新加载',
'workspace.error.panelTitle': '此面板出现异常',
'workspace.error.panelRetry': '重试'
} }
export default workspace export default workspace
+3 -1
View File
@@ -56,7 +56,9 @@ const workspace: Record<string, string> = {
'workspace.connect.connectingNamed': '正在連線 {name}', 'workspace.connect.connectingNamed': '正在連線 {name}',
'workspace.connect.action': '連線', 'workspace.connect.action': '連線',
'workspace.error.title': '介面發生異常', 'workspace.error.title': '介面發生異常',
'workspace.error.reload': '重新載入' 'workspace.error.reload': '重新載入',
'workspace.error.panelTitle': '此面板發生異常',
'workspace.error.panelRetry': '重試'
} }
export default workspace export default workspace