diff --git a/src/renderer/src/ErrorBoundary.tsx b/src/renderer/src/ErrorBoundary.tsx index 1333cd7..e806fc9 100644 --- a/src/renderer/src/ErrorBoundary.tsx +++ b/src/renderer/src/ErrorBoundary.tsx @@ -65,3 +65,45 @@ export class ErrorBoundary extends Component { + 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 ( +
+
{this.props.label ?? t('workspace.error.panelTitle')}
+
{this.state.error.message}
+ +
+ ) + } +} diff --git a/src/renderer/src/global.css b/src/renderer/src/global.css index 563fca0..10db1f2 100644 --- a/src/renderer/src/global.css +++ b/src/renderer/src/global.css @@ -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); +} diff --git a/src/renderer/src/monitor/MonitorPanel.tsx b/src/renderer/src/monitor/MonitorPanel.tsx index 7abcffb..fbd544d 100644 --- a/src/renderer/src/monitor/MonitorPanel.tsx +++ b/src/renderer/src/monitor/MonitorPanel.tsx @@ -26,6 +26,17 @@ interface HistoryChartProps { function HistoryChart({ data, lineColor, fillColor, height, maxY }: HistoryChartProps): React.JSX.Element { const containerRef = useRef(null) const canvasRef = useRef(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(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 (
diff --git a/src/renderer/src/settings/store.ts b/src/renderer/src/settings/store.ts index 0655ed6..db9106f 100644 --- a/src/renderer/src/settings/store.ts +++ b/src/renderer/src/settings/store.ts @@ -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((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]) } \ No newline at end of file diff --git a/src/renderer/src/sftp/FilePanel.tsx b/src/renderer/src/sftp/FilePanel.tsx index e0fb5c7..c5c252b 100644 --- a/src/renderer/src/sftp/FilePanel.tsx +++ b/src/renderer/src/sftp/FilePanel.tsx @@ -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([]) @@ -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(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 => ( + document.body} + onOpenChange={(open) => { + if (!open) setMenuEntry(null) + }} + > +
setSelected(e)} + onContextMenu={() => { + setSelected(e) + setMenuEntry(e) + }} + onDoubleClick={() => (e.isDir ? void refresh(e.path) : doDownload(e))} + > + {e.isDir ? : } + + {e.name} + + + {e.mode ?? '----------'} + + + {e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'} + + + {e.isDir ? '' : fmtSize(e.size)} + + {fmtTime(e.mtime)} +
+
+ ) + return (
@@ -707,45 +814,21 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element { {error ? (
{error}
) : ( -
+
{loading && entries.length === 0 &&
{t('ssh.file.loading')}
} {!loading && entries.length === 0 &&
{t('ssh.file.empty')}
} - {entries.map((e) => ( - document.body} - onOpenChange={(open) => { - if (!open) setMenuEntry(null) - }} - > + {virtualized ? ( +
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)` }} > - {e.isDir ? : } - - {e.name} - - - {e.mode ?? '----------'} - - - {e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'} - - - {e.isDir ? '' : fmtSize(e.size)} - - {fmtTime(e.mtime)} + {entries.slice(slice.start, slice.end).map(renderRow)}
- - ))} +
+ ) : ( + entries.map(renderRow) + )}
)} @@ -791,4 +874,4 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
) -} +}) diff --git a/src/renderer/src/sftp/TransferPanel.tsx b/src/renderer/src/sftp/TransferPanel.tsx index dc461ce..1b9e326 100644 --- a/src/renderer/src/sftp/TransferPanel.tsx +++ b/src/renderer/src/sftp/TransferPanel.tsx @@ -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 ( +
+
+ + {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')} + + {/* 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') && ( + + )} + {/* 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') && ( + + )} +
+
{entry.file || entry.error || ''}
+ {entry.state === 'running' && entry.totalBytes > 0 && ( +
+
+
+ )} + {entry.error &&
{entry.error}
} +
+ ) +}) + /** * 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>(new Map()) + const entriesRef = useRef>(new Map()) + const [version, setVersion] = useState(0) /** auto-dismiss timers, keyed by transfer id (never more than one per run) */ const timersRef = useRef>(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 (
- {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 ( -
-
- - {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')} - - {/* 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') && ( - - )} - {/* 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') && ( - - )} -
-
{tr.file || tr.error || ''}
- {tr.state === 'running' && tr.totalBytes > 0 && ( -
-
-
- )} - {tr.error &&
{tr.error}
} -
- ) - })} + {list.map(([id, entry]) => ( + + ))}
) } diff --git a/src/renderer/src/sftp/sftp.css b/src/renderer/src/sftp/sftp.css index a6c794e..e7a9c77 100644 --- a/src/renderer/src/sftp/sftp.css +++ b/src/renderer/src/sftp/sftp.css @@ -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; diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index 871f0a3..ae15ea3 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -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 }> = - forwardRef(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(null) const termRef = useRef(null) @@ -321,14 +363,13 @@ export const TerminalView: ForwardRefExoticComponent(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 { 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 | null>(null) useEffect(() => { - const sink = settings.terminal.highlightStats + const sink = highlightOpts.stats ? (statsRef.current ?? new Map()) : null statsRef.current = sink @@ -385,7 +418,7 @@ export const TerminalView: ForwardRefExoticComponent 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([]) 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 { 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 { 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 { 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 )}
- {settings.terminal.showRecButton && ( + {toolbar.showRecButton && ( )} - {settings.terminal.showOpenLogsButton && ( + {toolbar.showOpenLogsButton && ( )} - {settings.terminal.showOpenCwdButton && !isSsh && ( + {toolbar.showOpenCwdButton && !isSsh && (
+
+ {isSsh ? : terminal} + {isSsh && sideOpen && ( +
+ +
+ )} +
+
) } diff --git a/src/shared/i18n/dicts/en/workspace.ts b/src/shared/i18n/dicts/en/workspace.ts index 9e70d39..9ced72b 100644 --- a/src/shared/i18n/dicts/en/workspace.ts +++ b/src/shared/i18n/dicts/en/workspace.ts @@ -57,7 +57,9 @@ const workspace: Record = { '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 diff --git a/src/shared/i18n/dicts/ja/workspace.ts b/src/shared/i18n/dicts/ja/workspace.ts index 2dde8e1..be3f428 100644 --- a/src/shared/i18n/dicts/ja/workspace.ts +++ b/src/shared/i18n/dicts/ja/workspace.ts @@ -58,7 +58,9 @@ const workspace: Record = { '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 diff --git a/src/shared/i18n/dicts/zh-CN/workspace.ts b/src/shared/i18n/dicts/zh-CN/workspace.ts index 5ee5de1..c08243a 100644 --- a/src/shared/i18n/dicts/zh-CN/workspace.ts +++ b/src/shared/i18n/dicts/zh-CN/workspace.ts @@ -56,7 +56,9 @@ const workspace: Record = { '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 diff --git a/src/shared/i18n/dicts/zh-TW/workspace.ts b/src/shared/i18n/dicts/zh-TW/workspace.ts index bedc8e2..29e854a 100644 --- a/src/shared/i18n/dicts/zh-TW/workspace.ts +++ b/src/shared/i18n/dicts/zh-TW/workspace.ts @@ -56,7 +56,9 @@ const workspace: Record = { '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