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:
1 parent
6c04f0e8c1
commit
9c75cdc7d4
13 files changed
+547
-257
No files matched your search
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user