diff --git a/src/renderer/src/monitor/monitor.css b/src/renderer/src/monitor/monitor.css index 80d69cf..3c5065a 100644 --- a/src/renderer/src/monitor/monitor.css +++ b/src/renderer/src/monitor/monitor.css @@ -1,11 +1,13 @@ /* M3 server sysinfo monitor panel (SSH sessions only). */ +/* Panel surface derived from the theme (a fixed dark value turned every + white-based child below unreadable on a light theme). */ .mm-root { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; - background: #1a1a1a; + background: color-mix(in srgb, var(--chrome-fg, #cccccc) 6%, var(--chrome-bg, #181818)); color: var(--chrome-fg); font-size: 12px; overflow-y: auto; @@ -32,14 +34,14 @@ gap: 1px; line-height: 1.3; padding-bottom: 8px; - border-bottom: 1px solid #262626; + border-bottom: 1px solid var(--chrome-border, #2d2d2d); margin-bottom: 2px; } .mm-host { font-size: 14px; font-weight: 600; - color: rgba(255, 255, 255, 0.92); + color: var(--chrome-fg, #cccccc); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -55,9 +57,9 @@ /* ---------- blocks ---------- */ .mm-block { - border: 1px solid #2a2a2a; + border: 1px solid var(--chrome-border, #2d2d2d); border-radius: 8px; - background: rgba(255, 255, 255, 0.018); + background: color-mix(in srgb, var(--chrome-fg, #cccccc) 4%, transparent); padding: 8px 10px; display: flex; flex-direction: column; @@ -108,7 +110,7 @@ height: 5px; border-radius: 3px; overflow: hidden; - background: #2c2c2c; + background: color-mix(in srgb, var(--chrome-fg, #cccccc) 12%, transparent); width: 100%; } @@ -121,7 +123,7 @@ /* ---------- memory ---------- */ .mm-mem-size { font-size: 12px; - color: rgba(255, 255, 255, 0.78); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 78%, transparent); font-variant-numeric: tabular-nums; } @@ -163,7 +165,7 @@ } .mm-mount { - color: rgba(255, 255, 255, 0.75); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 75%, transparent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -214,7 +216,7 @@ align-items: baseline; gap: 4px; font-variant-numeric: tabular-nums; - color: rgba(255, 255, 255, 0.82); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 82%, transparent); } .mm-net-unit { @@ -243,7 +245,7 @@ } .mm-uptime { - color: rgba(255, 255, 255, 0.85); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 85%, transparent); } /* ---------- empty / collecting state ---------- */ @@ -264,7 +266,7 @@ width: 6px; height: 6px; border-radius: 50%; - background: rgba(255, 255, 255, 0.4); + background: color-mix(in srgb, var(--chrome-fg, #cccccc) 55%, transparent); animation: mm-pulse 1.2s infinite ease-in-out; } diff --git a/src/renderer/src/settings/SettingsDialog.tsx b/src/renderer/src/settings/SettingsDialog.tsx index 95a7e21..9746259 100644 --- a/src/renderer/src/settings/SettingsDialog.tsx +++ b/src/renderer/src/settings/SettingsDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Button, Modal, Popconfirm, Tabs } from 'antd' import type { TabsProps } from 'antd' import { t } from '@shared/i18n' @@ -33,6 +33,16 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS // is open, a main-window resize re-derives that default in real time. const [size, setSize] = useState<{ w: number; h: number } | null>(null) const [winSize, setWinSize] = useState({ w: window.innerWidth, h: window.innerHeight }) + /** Tears down the drag in progress; null while no drag is active. */ + const resizeCleanupRef = useRef<(() => void) | null>(null) + + const endResize = useCallback((): void => { + const cleanup = resizeCleanupRef.current + if (!cleanup) return + resizeCleanupRef.current = null + cleanup() + }, []) + useEffect(() => { if (open) setSize(null) }, [open]) @@ -42,6 +52,13 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, [open]) + // A drag whose mouseup is never delivered (released outside the window) must + // not outlive the dialog: closing or unmounting drops its listeners and the + // body styles it installed. + useEffect(() => { + if (!open) endResize() + }, [open, endResize]) + useEffect(() => endResize, [endResize]) const clampToWindow = (w: number, h: number): { w: number; h: number } => ({ w: Math.max(720, Math.min(w, winSize.w - 96)), @@ -56,24 +73,33 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS const startResize = (e: React.MouseEvent): void => { e.preventDefault() e.stopPropagation() + if (resizeCleanupRef.current) return const startX = e.clientX const startY = e.clientY const base = dialogSize + const prevCursor = document.body.style.cursor + const prevUserSelect = document.body.style.userSelect const onMove = (ev: MouseEvent): void => { const w = Math.max(720, Math.min(window.innerWidth - 96, base.w + (ev.clientX - startX))) const h = Math.max(440, Math.min(window.innerHeight - 140, base.h + (ev.clientY - startY))) setSize({ w, h }) } - const onUp = (): void => { + // One teardown for every way the drag can end: mouseup, the window losing + // focus (the release lands on another window and never reaches us), the + // dialog closing, or unmount. + const cleanup = (): void => { window.removeEventListener('mousemove', onMove) - window.removeEventListener('mouseup', onUp) - document.body.style.cursor = '' - document.body.style.userSelect = '' + window.removeEventListener('mouseup', cleanup) + window.removeEventListener('blur', cleanup) + document.body.style.cursor = prevCursor + document.body.style.userSelect = prevUserSelect } + resizeCleanupRef.current = cleanup document.body.style.cursor = 'nwse-resize' document.body.style.userSelect = 'none' window.addEventListener('mousemove', onMove) - window.addEventListener('mouseup', onUp) + window.addEventListener('mouseup', cleanup) + window.addEventListener('blur', cleanup) } const tabItems: TabsProps['items'] = [ diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx index b9f1b0d..27d359d 100644 --- a/src/renderer/src/settings/SettingsTabs.tsx +++ b/src/renderer/src/settings/SettingsTabs.tsx @@ -253,16 +253,6 @@ export function RenderSettingsTab(): React.JSX.Element {
{rendererModes.find((o) => o.value === settings.terminal.rendererMode)?.hint}
- void updateTerminal({ autoWrap: checked })} - /> - } - /> s.updateTerminal) const setCustomThemes = useSettingsStore((s) => s.setCustomThemes) const activeTheme = useResolvedTheme() + const [opacityDraft, setOpacityDraft] = useState(null) const themeCards = useMemo( () => [...BUILTIN_THEMES, ...settings.customThemes], @@ -80,8 +81,12 @@ export function ThemeSettingsTab({ min={10} max={100} step={5} - value={settings.terminal.backgroundImageOpacity} - onChange={(v) => void updateTerminal({ backgroundImageOpacity: v })} + value={opacityDraft ?? settings.terminal.backgroundImageOpacity} + onChange={(v) => setOpacityDraft(v)} + onChangeComplete={(v) => { + setOpacityDraft(null) + void updateTerminal({ backgroundImageOpacity: v }) + }} /> )} diff --git a/src/renderer/src/sftp/TransferPanel.tsx b/src/renderer/src/sftp/TransferPanel.tsx index de16acd..dc461ce 100644 --- a/src/renderer/src/sftp/TransferPanel.tsx +++ b/src/renderer/src/sftp/TransferPanel.tsx @@ -123,7 +123,21 @@ export function TransferPanel(): React.JSX.Element | null { ? t('panels.transfer.zmodemUploading') : t('panels.transfer.zmodemDownloading')} - {(tr.state === 'error' || tr.state === 'done') && ( + {/* 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') && (