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
}
}
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;
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 {
const containerRef = useRef<HTMLDivElement | 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(() => {
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.clearRect(0, 0, width, height)
const pts = data
const pts = dataRef.current
if (pts.length < 2) return
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()
}
drawRef.current = draw
draw()
const ro = new ResizeObserver(draw)
ro.observe(container)
return () => ro.disconnect()
// `data` is a dep on purpose: without it the chart only ever drew at mount,
// when the history is still empty, and the sparklines never updated.
}, [data, height, maxY, lineColor, fillColor])
return () => {
ro.disconnect()
drawRef.current = null
}
}, [height, maxY, lineColor, fillColor])
return (
<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 type { TerminalTheme } from '@shared/theme'
import { getThemeById } from '@shared/theme'
import { useMemo } from 'react'
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
export interface SettingsState {
/** 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.
* 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 {
const settings = useSettingsStore((s) => s.settings)
return getThemeById(settings.terminal.themeId, settings.customThemes)
const { themeId, customThemes } = useSettingsStore(
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 {
ArrowUpOutlined,
CopyOutlined,
@@ -114,6 +114,29 @@ function permColLabels(): string[] {
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 {
open: boolean
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 [dir, setDir] = useState('/')
const [entries, setEntries] = useState<SftpEntry[]>([])
@@ -329,6 +358,46 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
const [permOpen, setPermOpen] = useState(false)
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) */
const menuTarget = menuEntry ?? selected
@@ -624,6 +693,44 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 (
<div className="sftp-panel">
<div className="sftp-toolbar">
@@ -707,45 +814,21 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
{error ? (
<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.empty')}</div>}
{entries.map((e) => (
<Dropdown
key={e.path}
menu={menu}
trigger={['contextMenu']}
getPopupContainer={() => document.body}
onOpenChange={(open) => {
if (!open) setMenuEntry(null)
}}
>
{virtualized ? (
<div className="sftp-list-virtual" style={{ height: entries.length * ROW_HEIGHT }}>
<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))}
className="sftp-list-window"
style={{ transform: `translateY(${slice.start * ROW_HEIGHT}px)` }}
>
<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>
{entries.slice(slice.start, slice.end).map(renderRow)}
</div>
</Dropdown>
))}
</div>
) : (
entries.map(renderRow)
)}
</div>
)}
@@ -791,4 +874,4 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
</Modal>
</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 type { TransferKind, TransferProgressEvent, TransferState } from '@shared/sftp'
import { t } from '@shared/i18n'
@@ -14,12 +14,89 @@ interface TransferEntry {
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
* 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 {
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) */
const timersRef = useRef<Map<string, number>>(new Map())
@@ -29,57 +106,66 @@ export function TransferPanel(): React.JSX.Element | null {
window.clearTimeout(timer)
timersRef.current.delete(id)
}
setTransfers((prev) => {
if (!prev.has(id)) return prev
const next = new Map(prev)
next.delete(id)
return next
})
if (!entriesRef.current.has(id)) return
entriesRef.current.delete(id)
setVersion((v) => v + 1)
}, [])
useEffect(() => {
const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
setTransfers((prev) => {
const next = new Map(prev)
const existing = next.get(e.transferId)
if (e.file) {
next.set(e.transferId, {
kind: e.kind,
file: e.file,
bytes: e.bytes,
totalBytes: e.totalBytes,
state: e.state,
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' })
}
const entries = entriesRef.current
const existing = entries.get(e.transferId)
let next: TransferEntry
if (e.file) {
next = {
kind: e.kind,
file: e.file,
bytes: e.bytes,
totalBytes: e.totalBytes,
state: e.state,
error: e.error
}
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
}, [])
// auto-dismiss finished entries after 3s
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)) {
const timer = window.setTimeout(() => {
timersRef.current.delete(id)
@@ -88,7 +174,7 @@ export function TransferPanel(): React.JSX.Element | null {
timersRef.current.set(id, timer)
}
}
}, [transfers, dismiss])
}, [version, dismiss])
useEffect(() => {
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
return (
<div className="sftp-transfer-panel">
{list.map(([id, tr]) => {
const pct = tr.totalBytes > 0 ? Math.min(100, Math.round((tr.bytes / tr.totalBytes) * 100)) : tr.state === 'done' ? 100 : 0
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>
)
})}
{list.map(([id, entry]) => (
<TransferRow key={id} id={id} entry={entry} onDismiss={dismiss} />
))}
</div>
)
}
+24
View File
@@ -37,6 +37,24 @@
flex: 1;
min-height: 0;
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;
@@ -51,6 +69,12 @@
align-items: center;
column-gap: 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;
color: var(--chrome-fg);
cursor: pointer;
+120 -92
View File
@@ -1,5 +1,4 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ForwardRefExoticComponent, Ref } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Terminal } from '@xterm/xterm'
import type { ILink, ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
@@ -14,6 +13,7 @@ import type { CommandItem } from '@shared/commands'
import { highlightModeOf, type TerminalSettings } from '@shared/settings'
import { rulesForProfile } from '@shared/highlightProfiles'
import { useResolvedTheme, useSettingsStore } from '@renderer/settings/store'
import { useShallow } from 'zustand/react/shallow'
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream, type RuleStat } from './highlightEngine'
import { publishHighlightStats } from './highlightStats'
@@ -94,13 +94,6 @@ function toFileUrl(path: string): string {
return `otimg://bg/${encodeURIComponent(path)}`
}
export interface TerminalHandle {
focus(): void
clear(): void
/** currently attached pty session id */
sessionId(): string
}
export interface TerminalViewProps {
/** pty session created via window.api.createPty() */
sessionId: string
@@ -268,12 +261,61 @@ function differenceWrite(sessionId: string, full: string, buf: string): void {
writeBroadcast(sessionId, full)
}
export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?: Ref<TerminalHandle> }> =
forwardRef<TerminalHandle, TerminalViewProps>(function TerminalViewInner(
{ sessionId, onClose, className, isSsh = false, connectionId },
ref
) {
const settings = useSettingsStore((s) => s.settings)
export function TerminalView({
sessionId,
onClose,
className,
isSsh = false,
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 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
// 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.
const highlightMode = highlightModeOf(settings.terminal.highlightMode)
const highlightTheme = useResolvedTheme()
// 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
// per session — and only while the feature is switched on.
const [boundProfileId, setBoundProfileId] = useState<string | undefined>(undefined)
useEffect(() => {
if (connectionId === undefined || !settings.terminal.highlightPerHost) {
if (connectionId === undefined || !highlightOpts.perHost) {
setBoundProfileId(undefined)
return undefined
}
@@ -344,31 +385,23 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
return () => {
cancelled = true
}
}, [connectionId, settings.terminal.highlightPerHost])
}, [connectionId, highlightOpts.perHost])
const compiledRules = useMemo(() => {
if (highlightMode === 'off') return []
if (highlightOpts.mode === 'off') return []
const active =
highlightMode === 'basic'
? settings.highlightRules.filter((rule) => rule.basic === true)
: settings.highlightRules
const perHost = settings.terminal.highlightPerHost
? rulesForProfile(active, boundProfileId, settings.highlightProfiles)
highlightOpts.mode === 'basic'
? highlightOpts.rules.filter((rule) => rule.basic === true)
: highlightOpts.rules
const perHost = highlightOpts.perHost
? rulesForProfile(active, boundProfileId, highlightOpts.profiles)
: active
// Palette-consistent highlighting (opt-in): the rule keeps its meaning, the
// colour comes from the theme the terminal is actually drawn with.
return compileRules(
settings.terminal.highlightThemeColors ? applyThemeColors(perHost, highlightTheme) : perHost
highlightOpts.themeColors ? applyThemeColors(perHost, highlightTheme) : perHost
)
}, [
highlightMode,
settings.highlightRules,
settings.highlightProfiles,
settings.terminal.highlightThemeColors,
settings.terminal.highlightPerHost,
boundProfileId,
highlightTheme
])
}, [highlightOpts, boundProfileId, highlightTheme])
// Optional per-rule stats (settings → highlight → "count hits and time").
// 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.
const statsRef = useRef<Map<string, RuleStat> | null>(null)
useEffect(() => {
const sink = settings.terminal.highlightStats
const sink = highlightOpts.stats
? (statsRef.current ?? new Map<string, RuleStat>())
: null
statsRef.current = sink
@@ -385,7 +418,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
publishHighlightStats(new Map(sink))
const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000)
return () => window.clearInterval(timer)
}, [settings.terminal.highlightStats])
}, [highlightOpts.stats])
// Push the latest compiled rules into the stream from an effect — assigning
// during render would be a side effect in the render body.
useEffect(() => {
@@ -420,7 +453,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// Inline command-completion overlays built from history/library (M5).
const [suggestions, setSuggestions] = useState<CommandItem[]>([])
const [suggestionIndex, setSuggestionIndex] = useState(0)
const tSettings = settings.terminal
// [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
@@ -763,17 +795,17 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// ---- terminal instance: built once per session ----
useLayoutEffect(() => {
const term = new Terminal({
fontSize: tSettings.fontSize,
fontFamily: tSettings.fontFamily,
fontWeight: toFontWeight(tSettings.fontWeight),
fontWeightBold: toFontWeight(tSettings.boldFontWeight),
letterSpacing: tSettings.letterSpacing,
lineHeight: tSettings.lineHeight,
scrollback: tSettings.scrollback,
cursorBlink: tSettings.cursorBlink,
cursorStyle: tSettings.cursorStyle,
cursorInactiveStyle: tSettings.cursorInactiveStyle,
theme: withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme, Boolean(tSettings.backgroundImage)),
fontSize: fontOpts.fontSize,
fontFamily: fontOpts.fontFamily,
fontWeight: toFontWeight(fontOpts.fontWeight),
fontWeightBold: toFontWeight(fontOpts.boldFontWeight),
letterSpacing: fontOpts.letterSpacing,
lineHeight: fontOpts.lineHeight,
scrollback: fontOpts.scrollback,
cursorBlink: fontOpts.cursorBlink,
cursorStyle: fontOpts.cursorStyle,
cursorInactiveStyle: fontOpts.cursorInactiveStyle,
theme: withChromeColors(getThemeById(themeOpts.themeId, themeOpts.customThemes).colors as ITheme, bgOpts.image !== ''),
allowProposedApi: true,
allowTransparency: true,
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
// dark foreground colors (light themes) enough to stay readable over
// 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,
convertEol: false,
windowsPty: { backend: 'conpty', buildNumber: 0 }
@@ -850,7 +882,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
recordingRef.current = false
setRecording(false)
}
applyRenderers(tSettings.rendererMode)
applyRenderers(rendererMode)
scheduleFit()
// xterm event hooks return IDisposable objects, api subscriptions return
@@ -1101,29 +1133,41 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
}, [sessionId])
// ---- 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(() => {
const term = termRef.current
if (!term) return
term.options.fontSize = tSettings.fontSize
term.options.fontFamily = tSettings.fontFamily
term.options.fontWeight = toFontWeight(tSettings.fontWeight)
term.options.fontWeightBold = toFontWeight(tSettings.boldFontWeight)
term.options.letterSpacing = tSettings.letterSpacing
term.options.lineHeight = tSettings.lineHeight
term.options.cursorBlink = tSettings.cursorBlink
term.options.cursorStyle = tSettings.cursorStyle
term.options.cursorInactiveStyle = tSettings.cursorInactiveStyle
term.options.fontSize = fontOpts.fontSize
term.options.fontFamily = fontOpts.fontFamily
term.options.fontWeight = toFontWeight(fontOpts.fontWeight)
term.options.fontWeightBold = toFontWeight(fontOpts.boldFontWeight)
term.options.letterSpacing = fontOpts.letterSpacing
term.options.lineHeight = fontOpts.lineHeight
term.options.cursorBlink = fontOpts.cursorBlink
term.options.cursorStyle = fontOpts.cursorStyle
term.options.cursorInactiveStyle = fontOpts.cursorInactiveStyle
// 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".
term.options.scrollback = tSettings.scrollback
term.options.theme = withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme, Boolean(tSettings.backgroundImage))
term.options.minimumContrastRatio = tSettings.backgroundImage ? 4.5 : 1
term.options.scrollback = fontOpts.scrollback
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 ----
useEffect(() => {
const next = tSettings.rendererMode
const next = rendererMode
// The construction effect above already applied the mode on mount (this
// effect runs on mount too); only an actual switch should re-run it.
if (next === rendererModeRef.current) return
@@ -1133,35 +1177,19 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
applyRenderers(next)
}
scheduleFit()
}, [tSettings.rendererMode, applyRenderers, scheduleFit])
}, [rendererMode, applyRenderers, scheduleFit])
// keep onClose fresh for the dead mask
useEffect(() => {
onCloseRef.current = onClose
}, [onClose])
useImperativeHandle(
ref,
() => ({
focus() {
termRef.current?.focus()
},
clear() {
termRef.current?.clear()
},
sessionId() {
return sessionId
}
}),
[sessionId]
)
const terminalClassName = useMemo(
() =>
['terminal-view', settings.terminal.backgroundImage !== '' && 'has-bg-image', className]
['terminal-view', bgOpts.image !== '' && 'has-bg-image', className]
.filter(Boolean)
.join(' '),
[className, settings.terminal.backgroundImage]
[className, bgOpts.image]
)
/**
@@ -1295,7 +1323,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
/>
)}
<div className="term-recbar">
{settings.terminal.showRecButton && (
{toolbar.showRecButton && (
<button
type="button"
className={`term-rec-btn${recording ? ' is-recording' : ''}`}
@@ -1309,7 +1337,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
{recording ? <PauseOutlined /> : <SoundOutlined />}
</button>
)}
{settings.terminal.showOpenLogsButton && (
{toolbar.showOpenLogsButton && (
<button
type="button"
className="term-rec-btn"
@@ -1320,7 +1348,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<FolderOpenOutlined />
</button>
)}
{settings.terminal.showOpenCwdButton && !isSsh && (
{toolbar.showOpenCwdButton && !isSsh && (
<button
type="button"
className="term-rec-btn"
@@ -1391,20 +1419,20 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
</div>
)}
<div className="terminal-view-dock" ref={hostRef}>
{settings.terminal.backgroundImage !== '' && (
{bgOpts.image !== '' && (
<div
className="term-bg-image"
style={{
backgroundImage: `url("${toFileUrl(settings.terminal.backgroundImage)}")`,
opacity: Math.min(100, Math.max(10, settings.terminal.backgroundImageOpacity)) / 100
backgroundImage: `url("${toFileUrl(bgOpts.image)}")`,
opacity: Math.min(100, Math.max(10, bgOpts.opacity)) / 100
}}
/>
)}
{settings.terminal.backgroundImage !== '' && settings.terminal.backgroundImageDim > 0 && (
{bgOpts.image !== '' && bgOpts.dim > 0 && (
<div
className="term-bg-dim"
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>
)
})
}
+24 -18
View File
@@ -4,6 +4,7 @@ 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'
@@ -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 (
<div className="workspace-terminal-panel">
{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>
<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>
<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>
)
}
+3 -1
View File
@@ -57,7 +57,9 @@ const workspace: Record<string, string> = {
'workspace.connect.connectingNamed': 'Connecting to {name}',
'workspace.connect.action': 'Connect',
'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
+3 -1
View File
@@ -58,7 +58,9 @@ const workspace: Record<string, string> = {
'workspace.connect.connectingNamed': '{name} に接続しています',
'workspace.connect.action': '接続',
'workspace.error.title': '画面でエラーが発生しました',
'workspace.error.reload': '再読み込み'
'workspace.error.reload': '再読み込み',
'workspace.error.panelTitle': 'このパネルでエラーが発生しました',
'workspace.error.panelRetry': '再試行'
}
export default workspace
+3 -1
View File
@@ -56,7 +56,9 @@ const workspace: Record<string, string> = {
'workspace.connect.connectingNamed': '正在连接 {name}',
'workspace.connect.action': '连接',
'workspace.error.title': '界面出现异常',
'workspace.error.reload': '重新加载'
'workspace.error.reload': '重新加载',
'workspace.error.panelTitle': '此面板出现异常',
'workspace.error.panelRetry': '重试'
}
export default workspace
+3 -1
View File
@@ -56,7 +56,9 @@ const workspace: Record<string, string> = {
'workspace.connect.connectingNamed': '正在連線 {name}',
'workspace.connect.action': '連線',
'workspace.error.title': '介面發生異常',
'workspace.error.reload': '重新載入'
'workspace.error.reload': '重新載入',
'workspace.error.panelTitle': '此面板發生異常',
'workspace.error.panelRetry': '重試'
}
export default workspace