From 830ec74789369ffae3d398d9785cafe9e0f4411b Mon Sep 17 00:00:00 2001 From: AzureLandin Date: Wed, 21 Oct 2026 16:52:09 +0800 Subject: [PATCH 1/9] chore(ui): add Base UI dependency Pin @base-ui/react 1.9.0 as the headless component foundation for the incremental UI migration. --- package-lock.json | 73 +++++++++++++++++++++++++++++++++++++++++++++-- package.json | 1 + 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4f2fcf0..c107b28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.1.0", "license": "MIT", "dependencies": { + "@base-ui/react": "^1.9.0", "@codemirror/commands": "^6.10.4", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", @@ -380,6 +381,66 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.9.0.tgz", + "integrity": "sha512-S8CSYosASBKtpL8SWUfS78rFElfLijiCgA7oHOK22jCJ95FULsYyKwBNFOgudt0RA+/7yNc7F81bOvQnRgbBiQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@base-ui/utils": "0.5.0", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "use-sync-external-store": "^1.7.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-dYGGcEttjkYiMoa5nHm8vLaJ2UcuTWxo3BFAwOYXeRg/q7UVVvA6cacEIXEs3FgBfK1CGtW1ptElqs7wsx+t9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.3.0", + "use-sync-external-store": "^1.7.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmmirror.com/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -7057,6 +7118,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "dev": true, @@ -8017,9 +8084,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index aba6fec..52fd3fc 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:watch": "vitest" }, "dependencies": { + "@base-ui/react": "^1.9.0", "@codemirror/commands": "^6.10.4", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", From 26aea773b9a6d1762cb0774a32ed9b9092f53e9d Mon Sep 17 00:00:00 2001 From: AzureLandin Date: Wed, 21 Oct 2026 17:33:39 +0800 Subject: [PATCH 2/9] perf(ui): remove glass blur and shadow transitions Use solid panel and elevated surfaces, remove backdrop-filter tokens and fallbacks, and add a root stacking context for future portals. --- src/renderer/src/App.css | 68 ++++++++++++-------------------------- src/renderer/src/theme.css | 4 --- 2 files changed, 21 insertions(+), 51 deletions(-) diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index ef3b79e..b833525 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -12,6 +12,13 @@ body, overflow: hidden; } +/* Root stacking context: keeps portalled overlays (Base UI / Radix popups, + modals, menus) layered predictably above the app chrome. Portals render + into document.body, so they stay outside this context and above it. */ +#root { + isolation: isolate; +} + body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 14px; @@ -61,20 +68,15 @@ select { font: inherit; } +/* Solid surface baseline. + Large backdrop blurs were intentionally removed: on WebKitGTK (and other + desktop renderers) a blurred backdrop forces an expensive offscreen snapshot + on every frame, which dominates paint time for panels, modals and popovers. + Surfaces are now opaque, which is both cheaper and easier to reason about. */ .glass { - background: var(--glass-bg); + background: var(--bg-panel); border: 1px solid var(--glass-border); box-shadow: var(--glass-highlight); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -} - -@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { - .glass { - background: var(--bg-panel); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } } .app { @@ -137,8 +139,7 @@ button.icon-rail-btn[aria-pressed='true'] { transition: width var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), margin-right var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), - border-color var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), - box-shadow var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)); + border-color var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)); } .sidebar.is-collapsed { @@ -919,6 +920,9 @@ button.sftp-item-main { /* Password modal */ +/* Overlay is a solid dim layer — no backdrop blur (see the note above + `.glass`): blurring the whole viewport is the single most expensive paint + operation in the app. */ .modal-overlay { position: fixed; inset: 0; @@ -927,8 +931,6 @@ button.sftp-item-main { align-items: center; justify-content: center; z-index: 1100; - -webkit-backdrop-filter: blur(18px) saturate(140%); - backdrop-filter: blur(18px) saturate(140%); } .confirm-modal-overlay { @@ -942,25 +944,19 @@ button.sftp-item-main { /* ModalShell staged open/close — legacy overlays keep solid dim above */ .modal-overlay--animated { background: rgba(0, 0, 0, 0); - -webkit-backdrop-filter: none; - backdrop-filter: none; transition: background-color var(--dur-med) var(--ease-spring); } .modal-overlay--animated.is-open { background: var(--bg-overlay); - -webkit-backdrop-filter: blur(18px) saturate(140%); - backdrop-filter: blur(18px) saturate(140%); } .modal-overlay--animated.is-closing { background: rgba(0, 0, 0, 0); - -webkit-backdrop-filter: none; - backdrop-filter: none; } .modal { - background: var(--glass-bg); + background: var(--bg-elevated); border: 1px solid var(--glass-border); border-radius: var(--radius-sheet); padding: 20px 22px 18px; @@ -969,8 +965,6 @@ button.sftp-item-main { box-sizing: border-box; overflow-x: hidden; box-shadow: var(--shadow-modal), var(--glass-highlight); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); } .modal.password-modal, @@ -983,22 +977,6 @@ button.sftp-item-main { width: min(460px, calc(100vw - 40px)); } -@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { - .modal, - .modal-overlay, - .modal-overlay--animated.is-open, - .app-select-content { - background: var(--bg-panel); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } - - .modal-overlay, - .modal-overlay--animated.is-open { - background: var(--bg-overlay); - } -} - .modal.modal--animated { opacity: 0; transform: scale(0.96); @@ -2034,8 +2012,7 @@ button.model-id-editor-add-btn:disabled { transition: width var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), margin-left var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), - border-color var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)), - box-shadow var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)); + border-color var(--dur-sidebar, 240ms) var(--ease-sidebar, cubic-bezier(0.2, 0.8, 0.2, 1)); } .agent-dock.is-collapsed { @@ -4053,12 +4030,10 @@ button.agent-head-more:hover:not(:disabled) { .app-select-content { z-index: 1300; overflow: hidden; - background: var(--glass-bg); + background: var(--bg-elevated); border: 1px solid var(--glass-border); border-radius: var(--radius-card); box-shadow: var(--shadow-modal); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); width: var(--radix-select-trigger-width); max-height: min(280px, var(--radix-select-content-available-height)); } @@ -4410,13 +4385,12 @@ button.session-transfer-toggle.is-active .session-transfer-badge { max-height: 480px; display: flex; flex-direction: column; - background: var(--bg-surface); + background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-modal, 14px); box-shadow: var(--shadow-modal); z-index: 10000; overflow: hidden; - backdrop-filter: blur(20px); } .transfer-center-header { diff --git a/src/renderer/src/theme.css b/src/renderer/src/theme.css index 7e0b096..f02a3ac 100644 --- a/src/renderer/src/theme.css +++ b/src/renderer/src/theme.css @@ -13,8 +13,6 @@ --dur-med: 320ms; --dur-sidebar: 240ms; --dur-sidebar-collapse: 200ms; - --glass-blur: 28px; - --glass-saturate: 160%; --chrome-gap: 8px; } @@ -29,7 +27,6 @@ --bg-hover: #2a2a2a; --bg-hover-strong: #3a3a3a; --bg-overlay: rgba(0, 0, 0, 0.32); - --glass-bg: rgba(28, 28, 30, 0.62); --glass-border: rgba(255, 255, 255, 0.14); --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.12); --bg-danger: #4a2d2d; @@ -122,7 +119,6 @@ --bg-hover: #e9edf3; --bg-hover-strong: #dde3eb; --bg-overlay: rgba(15, 20, 28, 0.28); - --glass-bg: rgba(255, 255, 255, 0.78); --glass-border: rgba(255, 255, 255, 0.55); --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.85); --bg-danger: #fef1f1; From e2f14c003ea1e40c82c0d5733994f9a1fb61ac52 Mon Sep 17 00:00:00 2001 From: AzureLandin Date: Wed, 21 Oct 2026 17:33:39 +0800 Subject: [PATCH 3/9] refactor(ui): migrate dialogs to Base UI Replace the custom modal focus trap, escape and overlay handling with Base UI Dialog and AlertDialog while preserving visual classes and animation modes. --- src/renderer/src/components/AboutModal.tsx | 5 +- src/renderer/src/components/ConfirmModal.tsx | 113 +++++---- src/renderer/src/components/ModalShell.tsx | 219 +++++++++--------- .../src/components/PermissionModal.tsx | 78 +++---- src/renderer/src/ui/alert-dialog.tsx | 178 ++++++++++++++ src/renderer/src/ui/dialog.css | 22 ++ src/renderer/src/ui/dialog.tsx | 211 +++++++++++++++++ tests/ui/modal-shell-motion.test.tsx | 89 ++++++- tests/ui/sftp-edit.test.tsx | 66 ++++++ 9 files changed, 774 insertions(+), 207 deletions(-) create mode 100644 src/renderer/src/ui/alert-dialog.tsx create mode 100644 src/renderer/src/ui/dialog.css create mode 100644 src/renderer/src/ui/dialog.tsx diff --git a/src/renderer/src/components/AboutModal.tsx b/src/renderer/src/components/AboutModal.tsx index 3979ae9..4b37f5f 100644 --- a/src/renderer/src/components/AboutModal.tsx +++ b/src/renderer/src/components/AboutModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ModalShell, useModalClose } from './ModalShell' +import { DialogTitle } from '../ui/dialog' import logoUrl from '../assets/logo.png' interface AboutModalProps { @@ -23,9 +24,9 @@ function AboutModalBody({ onBack }: { onBack?: () => void }): React.JSX.Element return ( <>
-

+ {t('about.title')} -

+ - -
- - + + + + + + + {title} + + + {message} + +
+ + {cancelLabel ?? t('common.cancel')} + + +
+
+
+
+
+
) } diff --git a/src/renderer/src/components/ModalShell.tsx b/src/renderer/src/components/ModalShell.tsx index 42d8dc3..55a2f73 100644 --- a/src/renderer/src/components/ModalShell.tsx +++ b/src/renderer/src/components/ModalShell.tsx @@ -1,12 +1,13 @@ -import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' -import { createPortal } from 'react-dom' - -const CLOSE_FALLBACK_MS = 400 -const CLOSE_FALLBACK_SIMPLE_MS = 180 - -type Phase = 'preenter' | 'open' | 'closing' - -export type ModalMotion = 'default' | 'simple' | 'none' +import { createContext, useCallback, useContext, useLayoutEffect, useRef, useState } from 'react' +import { + DialogBackdrop, + DialogPopup, + DialogPortal, + DialogRoot, + DialogViewport, + type DialogChangeDetails, + type DialogPartState +} from '../ui/dialog' const ModalCloseContext = createContext<() => void>(() => {}) @@ -14,10 +15,14 @@ export function useModalClose(): () => void { return useContext(ModalCloseContext) } +export type ModalMotion = 'default' | 'simple' | 'none' + interface ModalShellProps { onClose: () => void children: React.ReactNode dialogClassName?: string + /** Extra class for the dimmed overlay (e.g. a higher stacking order). */ + overlayClassName?: string labelledBy?: string /** Default true. Set false while a nested form owns Escape, or during busy connect. */ closeOnEscape?: boolean @@ -25,142 +30,130 @@ interface ModalShellProps { closeOnOverlayClick?: boolean /** Modal animation variant: 'default' (scale+fade), 'simple' (fade only), 'none' (instant). */ motion?: ModalMotion + /** Element focused when the modal opens. Defaults to the first focusable child. */ + initialFocus?: React.RefObject } function prefersReducedMotion(): boolean { return window.matchMedia('(prefers-reduced-motion: reduce)').matches } +/** + * Maps the Base UI transition phase onto the legacy visual states that + * App.css animates: `is-open` while the dialog is fully open, `is-closing` + * while it plays the exit transition and nothing while it enters. + */ +function transitionClass(state: DialogPartState): string { + if (state.transitionStatus === 'ending') return 'is-closing' + if (state.transitionStatus === 'starting') return '' + return 'is-open' +} + export function ModalShell({ onClose, children, dialogClassName = '', + overlayClassName = '', labelledBy, closeOnEscape = true, closeOnOverlayClick = true, - motion = 'default' + motion = 'default', + initialFocus }: ModalShellProps): React.JSX.Element { - const isNoneMotion = motion === 'none' || prefersReducedMotion() - const [phase, setPhase] = useState(() => (isNoneMotion ? 'open' : 'preenter')) - const closingRef = useRef(false) + // Reduced motion users get the dialog without an enter transition, everyone + // else mounts closed and opens on the next layout tick so Base UI runs the + // `data-starting-style` phase. + const [open, setOpen] = useState(() => prefersReducedMotion()) const onCloseRef = useRef(onClose) - const timeoutRef = useRef(null) const closeOnEscapeRef = useRef(closeOnEscape) + const closeOnOverlayClickRef = useRef(closeOnOverlayClick) onCloseRef.current = onClose closeOnEscapeRef.current = closeOnEscape + closeOnOverlayClickRef.current = closeOnOverlayClick - useEffect(() => { - if (isNoneMotion) { - setPhase('open') - return - } - const id = requestAnimationFrame(() => { - requestAnimationFrame(() => setPhase('open')) - }) - return () => cancelAnimationFrame(id) - }, [isNoneMotion]) - - const finishClose = useCallback(() => { - if (!closingRef.current) return - closingRef.current = false - if (timeoutRef.current != null) { - window.clearTimeout(timeoutRef.current) - timeoutRef.current = null - } - onCloseRef.current() + useLayoutEffect(() => { + setOpen(true) }, []) const requestClose = useCallback(() => { - if (closingRef.current) return - closingRef.current = true - if (isNoneMotion) { - onCloseRef.current() - closingRef.current = false + setOpen(false) + }, []) + + const handleOpenChange = useCallback((nextOpen: boolean, details: DialogChangeDetails) => { + if (nextOpen) { + setOpen(true) return } - setPhase('closing') - const fallback = motion === 'simple' ? CLOSE_FALLBACK_SIMPLE_MS : CLOSE_FALLBACK_MS - timeoutRef.current = window.setTimeout(finishClose, fallback) - }, [finishClose, isNoneMotion, motion]) - - useEffect(() => { - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape' && closeOnEscapeRef.current) requestClose() + if (details.reason === 'escape-key' && !closeOnEscapeRef.current) { + // A nested form owns Escape (SFTP editor, host picker form): keep the + // key event alive so the window-level handler still sees it. + details.cancel() + details.allowPropagation() + return } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [requestClose]) - - useEffect(() => { - return () => { - if (timeoutRef.current != null) window.clearTimeout(timeoutRef.current) + if (details.reason === 'outside-press' && !closeOnOverlayClickRef.current) { + details.cancel() + return } + setOpen(false) }, []) - const onDialogTransitionEnd = (e: React.TransitionEvent): void => { - if (e.target !== e.currentTarget) return - if (phase !== 'closing') return - if (e.propertyName !== 'opacity') return - finishClose() - } - - const overlayClass = [ - 'modal-overlay', - motion === 'default' ? 'modal-overlay--animated' : '', - motion === 'simple' ? 'modal-overlay--simple' : '', - phase === 'open' ? 'is-open' : '', - phase === 'closing' ? 'is-closing' : '' - ] - .filter(Boolean) - .join(' ') - - const dialogClass = [ - 'modal', - motion === 'default' ? 'modal--animated' : '', - motion === 'simple' ? 'modal-motion-simple' : '', - dialogClassName, - phase === 'open' ? 'is-open' : '', - phase === 'closing' ? 'is-closing' : '' - ] - .filter(Boolean) - .join(' ') - - const overlayPointerDownRef = useRef(false) + const handleOpenChangeComplete = useCallback((nextOpen: boolean) => { + // The exit transition finished — now hand control back to the owner. + if (!nextOpen) onCloseRef.current() + }, []) - const onOverlayPointerDown = (e: React.PointerEvent): void => { - // Only count presses that start on the dimmed backdrop, not the dialog. - overlayPointerDownRef.current = e.target === e.currentTarget - } + const overlayClass = useCallback( + (state: DialogPartState) => + [ + 'modal-overlay', + overlayClassName, + motion === 'default' ? 'modal-overlay--animated' : '', + motion === 'simple' ? 'modal-overlay--simple' : '', + transitionClass(state) + ] + .filter(Boolean) + .join(' '), + [motion, overlayClassName] + ) - const onOverlayClick = (e: React.MouseEvent): void => { - if (!closeOnOverlayClick) return - // Ignore click if the gesture started inside the dialog (e.g. text selection - // drag that ends on the backdrop) — that used to close the host picker. - if (!overlayPointerDownRef.current) return - if (e.target !== e.currentTarget) return - requestClose() - } + const dialogClass = useCallback( + (state: DialogPartState) => + [ + 'modal', + motion === 'default' ? 'modal--animated' : '', + motion === 'simple' ? 'modal-motion-simple' : '', + dialogClassName, + transitionClass(state) + ] + .filter(Boolean) + .join(' '), + [dialogClassName, motion] + ) - return createPortal( + return ( -
-
e.stopPropagation()} - onTransitionEnd={onDialogTransitionEnd} - > - {children} -
-
-
, - document.body + + + + + {children} + + + + + + ) } diff --git a/src/renderer/src/components/PermissionModal.tsx b/src/renderer/src/components/PermissionModal.tsx index 7d0bbbb..1ef3282 100644 --- a/src/renderer/src/components/PermissionModal.tsx +++ b/src/renderer/src/components/PermissionModal.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef } from 'react' +import { useRef } from 'react' import { useTranslation } from 'react-i18next' import type { PermissionAskEvent, PermissionDecision } from '../../../shared/types' +import { ModalShell } from './ModalShell' interface PermissionModalProps { request: PermissionAskEvent @@ -32,40 +33,15 @@ export function PermissionModal({ variant = 'modal' }: PermissionModalProps): React.JSX.Element { const { t } = useTranslation() - const overlayPointerDownRef = useRef(false) const inline = variant === 'inline' - - useEffect(() => { - if (inline) return - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape') onDecide('deny') - } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [inline, onDecide]) + const denyRef = useRef(null) const source = request.source === 'mcp' ? t('permission.sourceMcp') : t('permission.sourceAgent') const btnClass = inline ? 'btn-sm' : undefined - const dialog = ( -
e.stopPropagation()} - onKeyDown={ - inline - ? (e) => { - if (e.key !== 'Escape') return - e.preventDefault() - e.stopPropagation() - onDecide('deny') - } - : undefined - } - > + const body = ( + <>

{t('permission.title')}

@@ -86,6 +62,7 @@ export function PermissionModal({
- + ) - if (inline) return dialog + if (inline) { + return ( +
{ + if (e.key !== 'Escape') return + e.preventDefault() + e.stopPropagation() + onDecide('deny') + }} + > + {body} +
+ ) + } return ( -
{ - overlayPointerDownRef.current = e.target === e.currentTarget - }} - onClick={(e) => { - if (!overlayPointerDownRef.current) return - if (e.target !== e.currentTarget) return - onDecide('deny') - }} + onDecide('deny')} + dialogClassName="confirm-modal permission-modal" + overlayClassName="permission-modal-overlay" + labelledBy="permission-modal-title" + motion="none" + initialFocus={denyRef} > - {dialog} -
+ {body} + ) } diff --git a/src/renderer/src/ui/alert-dialog.tsx b/src/renderer/src/ui/alert-dialog.tsx new file mode 100644 index 0000000..f808cda --- /dev/null +++ b/src/renderer/src/ui/alert-dialog.tsx @@ -0,0 +1,178 @@ +import { AlertDialog as BaseAlertDialog } from '@base-ui/react/alert-dialog' +import type { MouseEventHandler, PointerEventHandler, ReactNode, Ref } from 'react' +import type { DialogChangeDetails, DialogClassName, DialogFocusTarget } from './dialog' +import './dialog.css' + +/** + * Thin wrappers around `@base-ui/react/alert-dialog`. + * + * An alert dialog asks the user to confirm an action: it never closes on an + * outside press, so backdrop handling stays with the caller. + */ + +export interface AlertDialogRootProps { + /** Whether the alert dialog is open. */ + open: boolean + /** Called whenever the alert dialog wants to open or close. */ + onOpenChange: (open: boolean, details: DialogChangeDetails) => void + /** Called once the enter/exit transition of the alert dialog finished. */ + onOpenChangeComplete?: (open: boolean) => void + children?: ReactNode +} + +export function AlertDialogRoot({ + open, + onOpenChange, + onOpenChangeComplete, + children +}: AlertDialogRootProps): React.JSX.Element { + return ( + + {children} + + ) +} + +export interface AlertDialogPortalProps { + children?: ReactNode +} + +export function AlertDialogPortal({ children }: AlertDialogPortalProps): React.JSX.Element { + return {children} +} + +export interface AlertDialogBackdropProps { + className?: DialogClassName + /** Render the backdrop even when the alert dialog is nested inside another one. */ + forceRender?: boolean + onClick?: MouseEventHandler + onPointerDown?: PointerEventHandler + children?: ReactNode +} + +export function AlertDialogBackdrop({ + className, + forceRender = false, + onClick, + onPointerDown, + children +}: AlertDialogBackdropProps): React.JSX.Element { + return ( + + {children} + + ) +} + +export interface AlertDialogViewportProps { + className?: DialogClassName + children?: ReactNode +} + +export function AlertDialogViewport({ + className, + children +}: AlertDialogViewportProps): React.JSX.Element { + return {children} +} + +export interface AlertDialogPopupProps { + className?: DialogClassName + children?: ReactNode + id?: string + ref?: Ref + 'aria-labelledby'?: string + 'aria-modal'?: boolean | 'true' | 'false' + /** Element focused when the alert dialog opens. Defaults to the first focusable child. */ + initialFocus?: DialogFocusTarget + /** Element focused when the alert dialog closes. Defaults to the previously focused element. */ + finalFocus?: DialogFocusTarget +} + +export function AlertDialogPopup({ + className, + children, + id, + ref, + initialFocus, + finalFocus, + 'aria-labelledby': ariaLabelledBy, + 'aria-modal': ariaModal +}: AlertDialogPopupProps): React.JSX.Element { + return ( + + {children} + + ) +} + +export interface AlertDialogTitleProps { + className?: string + id?: string + children?: ReactNode +} + +export function AlertDialogTitle({ + className, + id, + children +}: AlertDialogTitleProps): React.JSX.Element { + return ( + }> + {children} + + ) +} + +export interface AlertDialogDescriptionProps { + className?: string + id?: string + children?: ReactNode +} + +export function AlertDialogDescription({ + className, + id, + children +}: AlertDialogDescriptionProps): React.JSX.Element { + return ( + + {children} + + ) +} + +export interface AlertDialogCloseProps { + className?: string + children?: ReactNode + onClick?: MouseEventHandler +} + +export function AlertDialogClose({ + className, + children, + onClick +}: AlertDialogCloseProps): React.JSX.Element { + return ( + + {children} + + ) +} diff --git a/src/renderer/src/ui/dialog.css b/src/renderer/src/ui/dialog.css new file mode 100644 index 0000000..f3e9801 --- /dev/null +++ b/src/renderer/src/ui/dialog.css @@ -0,0 +1,22 @@ +/* + * Scoped styles for the Base UI dialog markup. + * + * Base UI splits a dialog into Backdrop → Viewport → Popup, while the app + * styles the overlay (`.modal-overlay`) and the sheet (`.modal`) from + * App.css. The viewport is the new element in between: it only positions the + * popup inside the overlay, so it stays unstyled apart from that. + * + * The enter/exit animation is not styled here: Base UI reports the phase + * (`data-starting-style` / `data-ending-style`) through its part state, and + * ModalShell maps that phase onto the legacy `.is-open` / `.is-closing` + * classes that App.css animates. App.css therefore stays the single source + * of truth for the motion, and no duplicate rules are needed. + */ + +.modal-viewport { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/renderer/src/ui/dialog.tsx b/src/renderer/src/ui/dialog.tsx new file mode 100644 index 0000000..31bdf68 --- /dev/null +++ b/src/renderer/src/ui/dialog.tsx @@ -0,0 +1,211 @@ +import { Dialog as BaseDialog } from '@base-ui/react/dialog' +import type { MouseEventHandler, PointerEventHandler, ReactNode, Ref, RefObject } from 'react' +import './dialog.css' + +/** + * Thin wrappers around `@base-ui/react/dialog`. + * + * Only the parts the app needs are re-exported, and every prop is described + * with app-local types so Base UI types never leak into the component tree. + */ + +/** Interaction that opened or closed a dialog. */ +export type DialogInteractionType = 'mouse' | 'touch' | 'pen' | 'keyboard' | '' + +/** Transition phase Base UI reports while a dialog enters or leaves. */ +export type DialogTransitionStatus = 'starting' | 'ending' | 'idle' | undefined + +/** Minimal view of a Base UI part state — enough to derive CSS classes. */ +export interface DialogPartState { + open: boolean + transitionStatus: DialogTransitionStatus +} + +/** CSS class of a part: a literal string or a function of the part state. */ +export type DialogClassName = string | ((state: DialogPartState) => string | undefined) + +/** Element (or decision) Base UI should move focus to. */ +export type DialogFocusTarget = + | boolean + | RefObject + | ((interaction: DialogInteractionType) => boolean | HTMLElement | null | void) + +/** Why a dialog asked to close. */ +export type DialogCloseReason = + | 'trigger-press' + | 'outside-press' + | 'escape-key' + | 'close-press' + | 'focus-out' + | 'imperative-action' + | 'none' + +/** Narrow view of Base UI's change details. */ +export interface DialogChangeDetails { + /** Reason the dialog wants to close. */ + reason: DialogCloseReason + /** Rejects the close request and keeps the dialog open. */ + cancel: () => void + /** Keeps the originating event propagating (e.g. to window-level handlers). */ + allowPropagation: () => void +} + +export interface DialogRootProps { + /** Whether the dialog is open. */ + open: boolean + /** Called whenever the dialog wants to open or close. */ + onOpenChange: (open: boolean, details: DialogChangeDetails) => void + /** Called once the enter/exit transition of the dialog finished. */ + onOpenChangeComplete?: (open: boolean) => void + /** Ignore backdrop presses (outside clicks). */ + disablePointerDismissal?: boolean + children?: ReactNode +} + +export function DialogRoot({ + open, + onOpenChange, + onOpenChangeComplete, + disablePointerDismissal = false, + children +}: DialogRootProps): React.JSX.Element { + return ( + + {children} + + ) +} + +export interface DialogPortalProps { + children?: ReactNode +} + +export function DialogPortal({ children }: DialogPortalProps): React.JSX.Element { + return {children} +} + +export interface DialogBackdropProps { + className?: DialogClassName + /** Render the backdrop even when the dialog is nested inside another one. */ + forceRender?: boolean + onClick?: MouseEventHandler + onPointerDown?: PointerEventHandler + children?: ReactNode +} + +export function DialogBackdrop({ + className, + forceRender = false, + onClick, + onPointerDown, + children +}: DialogBackdropProps): React.JSX.Element { + return ( + + {children} + + ) +} + +export interface DialogViewportProps { + className?: DialogClassName + children?: ReactNode +} + +export function DialogViewport({ className, children }: DialogViewportProps): React.JSX.Element { + return {children} +} + +export interface DialogPopupProps { + className?: DialogClassName + children?: ReactNode + id?: string + ref?: Ref + 'aria-labelledby'?: string + 'aria-modal'?: boolean | 'true' | 'false' + /** Element focused when the dialog opens. Defaults to the first focusable child. */ + initialFocus?: DialogFocusTarget + /** Element focused when the dialog closes. Defaults to the previously focused element. */ + finalFocus?: DialogFocusTarget +} + +export function DialogPopup({ + className, + children, + id, + ref, + initialFocus, + finalFocus, + 'aria-labelledby': ariaLabelledBy, + 'aria-modal': ariaModal +}: DialogPopupProps): React.JSX.Element { + return ( + + {children} + + ) +} + +export interface DialogTitleProps { + className?: string + id?: string + children?: ReactNode +} + +export function DialogTitle({ className, id, children }: DialogTitleProps): React.JSX.Element { + return ( + }> + {children} + + ) +} + +export interface DialogDescriptionProps { + className?: string + id?: string + children?: ReactNode +} + +export function DialogDescription({ + className, + id, + children +}: DialogDescriptionProps): React.JSX.Element { + return ( + + {children} + + ) +} + +export interface DialogCloseProps { + className?: string + children?: ReactNode + onClick?: MouseEventHandler +} + +export function DialogClose({ className, children, onClick }: DialogCloseProps): React.JSX.Element { + return ( + + {children} + + ) +} diff --git a/tests/ui/modal-shell-motion.test.tsx b/tests/ui/modal-shell-motion.test.tsx index 5054e74..8dc0714 100644 --- a/tests/ui/modal-shell-motion.test.tsx +++ b/tests/ui/modal-shell-motion.test.tsx @@ -1,9 +1,15 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ModalShell } from '../../src/renderer/src/components/ModalShell' +function overlayOf(dialog: HTMLElement): HTMLElement { + const overlay = dialog.closest('.modal-overlay') + if (!(overlay instanceof HTMLElement)) throw new Error('modal overlay not found') + return overlay +} + describe('ModalShell motion isolation', () => { it('applies default animated classes when motion is default', () => { const onClose = vi.fn() @@ -18,7 +24,7 @@ describe('ModalShell motion isolation', () => { expect(dialog).not.toHaveClass('modal-motion-simple') expect(dialog).toHaveClass('test-modal') - const overlay = dialog.parentElement + const overlay = overlayOf(dialog) expect(overlay).toHaveClass('modal-overlay--animated') expect(overlay).not.toHaveClass('modal-overlay--simple') }) @@ -36,7 +42,7 @@ describe('ModalShell motion isolation', () => { expect(dialog).not.toHaveClass('modal--animated') expect(dialog).toHaveClass('settings-modal') - const overlay = dialog.parentElement + const overlay = overlayOf(dialog) expect(overlay).toHaveClass('modal-overlay--simple') expect(overlay).not.toHaveClass('modal-overlay--animated') }) @@ -54,11 +60,26 @@ describe('ModalShell motion isolation', () => { expect(dialog).not.toHaveClass('modal-motion-simple') expect(dialog).toHaveClass('plain-modal') - const overlay = dialog.parentElement + const overlay = overlayOf(dialog) expect(overlay).not.toHaveClass('modal-overlay--animated') expect(overlay).not.toHaveClass('modal-overlay--simple') }) + it('marks the sheet as an open modal once mounted', () => { + const onClose = vi.fn() + render( + +

Shell title

+
+ ) + + const dialog = screen.getByRole('dialog', { name: 'Shell title' }) + expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(dialog).toHaveAttribute('aria-labelledby', 'shell-title') + expect(dialog).toHaveClass('is-open') + expect(overlayOf(dialog)).toHaveClass('is-open') + }) + it('closes on Escape key press in simple motion mode', async () => { const onClose = vi.fn() render( @@ -67,8 +88,21 @@ describe('ModalShell motion isolation', () => { ) - fireEvent.keyDown(window, { key: 'Escape' }) - expect(onClose).toHaveBeenCalledTimes(1) + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + }) + + it('keeps the modal open on Escape when closeOnEscape is false', async () => { + const onClose = vi.fn() + render( + +
Content
+
+ ) + + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + expect(onClose).not.toHaveBeenCalled() }) it('closes on overlay backdrop click in simple motion mode', async () => { @@ -80,12 +114,29 @@ describe('ModalShell motion isolation', () => { ) const dialog = screen.getByRole('dialog') - const overlay = dialog.parentElement as HTMLElement + const overlay = overlayOf(dialog) // Pointer down and click on overlay fireEvent.pointerDown(overlay) fireEvent.click(overlay) - expect(onClose).toHaveBeenCalledTimes(1) + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + }) + + it('keeps the modal open on backdrop click when closeOnOverlayClick is false', async () => { + const onClose = vi.fn() + render( + +
Content
+
+ ) + + const dialog = screen.getByRole('dialog') + const overlay = overlayOf(dialog) + + fireEvent.pointerDown(overlay) + fireEvent.click(overlay) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + expect(onClose).not.toHaveBeenCalled() }) it('does not close when clicking inside dialog content', async () => { @@ -101,4 +152,26 @@ describe('ModalShell motion isolation', () => { await user.click(button) expect(onClose).not.toHaveBeenCalled() }) + + it('moves focus into the dialog and back to the opener on close', async () => { + const onClose = vi.fn() + const outside = document.createElement('button') + outside.textContent = 'Outside' + document.body.appendChild(outside) + outside.focus() + + render( + + + + ) + + await waitFor(() => expect(screen.getByRole('button', { name: 'Inside Button' })).toHaveFocus()) + + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(outside).toHaveFocus()) + + outside.remove() + }) }) diff --git a/tests/ui/sftp-edit.test.tsx b/tests/ui/sftp-edit.test.tsx index 93a9e78..1b61559 100644 --- a/tests/ui/sftp-edit.test.tsx +++ b/tests/ui/sftp-edit.test.tsx @@ -210,6 +210,72 @@ describe('SftpTextEditorModal', () => { await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) }) + it('prompts before discarding unsaved changes on Escape', async () => { + const fake = installFakeApi() + fake.mocks.sftp.readText.mockResolvedValue({ + path: '/home/user/a.py', + content: 'x' + }) + const onClose = vi.fn() + + renderWithI18n( + + ) + + const textarea = await screen.findByDisplayValue('x') + fireEvent.change(textarea, { target: { value: 'xy' } }) + + // The editor owns Escape: the sheet stays open and the confirm prompt shows. + fireEvent.keyDown(document, { key: 'Escape' }) + + expect( + await screen.findByText('You have unsaved changes. Close without saving?') + ).toBeInTheDocument() + expect(onClose).not.toHaveBeenCalled() + }) + + it('cancels the discard prompt when its backdrop is clicked', async () => { + const fake = installFakeApi() + fake.mocks.sftp.readText.mockResolvedValue({ + path: '/home/user/a.py', + content: 'x' + }) + const onClose = vi.fn() + const user = userEvent.setup() + + renderWithI18n( + + ) + + const textarea = await screen.findByDisplayValue('x') + fireEvent.change(textarea, { target: { value: 'xy' } }) + + await user.click(screen.getByRole('button', { name: 'Cancel' })) + expect( + await screen.findByText('You have unsaved changes. Close without saving?') + ).toBeInTheDocument() + + const backdrop = document.querySelector('.confirm-modal-overlay') as HTMLElement + expect(backdrop).not.toBeNull() + fireEvent.pointerDown(backdrop) + fireEvent.click(backdrop) + + await waitFor(() => + expect( + screen.queryByText('You have unsaved changes. Close without saving?') + ).not.toBeInTheDocument() + ) + expect(onClose).not.toHaveBeenCalled() + }) + it('saves with Ctrl/Meta+S', async () => { const fake = installFakeApi() fake.mocks.sftp.readText.mockResolvedValue({ From 4e8711a49bba84ed0cf4012cf3f2356b99265268 Mon Sep 17 00:00:00 2001 From: AzureLandin Date: Wed, 21 Oct 2026 17:33:39 +0800 Subject: [PATCH 4/9] refactor(ui): migrate menus toast and popover to Base UI Use Base UI context menus, menu items, toast and popover primitives for SFTP/terminal context menus, notifications and the transfer center. --- .../src/components/SftpContextMenu.tsx | 93 +++---- .../src/components/TerminalContextMenu.tsx | 80 +++--- src/renderer/src/components/Toast.tsx | 91 ++++++- .../src/components/TransferCenter.tsx | 245 +++++++----------- src/renderer/src/ui/context-menu.tsx | 70 +++++ src/renderer/src/ui/menu.tsx | 154 +++++++++++ src/renderer/src/ui/popover.tsx | 127 +++++++++ src/renderer/src/ui/toast.tsx | 103 ++++++++ src/renderer/src/ui/ui.css | 119 +++++++++ tests/ui/terminal-context-menu.test.tsx | 96 ++++--- tests/ui/transfer-center.test.tsx | 14 +- 11 files changed, 887 insertions(+), 305 deletions(-) create mode 100644 src/renderer/src/ui/context-menu.tsx create mode 100644 src/renderer/src/ui/menu.tsx create mode 100644 src/renderer/src/ui/popover.tsx create mode 100644 src/renderer/src/ui/toast.tsx create mode 100644 src/renderer/src/ui/ui.css diff --git a/src/renderer/src/components/SftpContextMenu.tsx b/src/renderer/src/components/SftpContextMenu.tsx index 2c4fe68..b2f91ad 100644 --- a/src/renderer/src/components/SftpContextMenu.tsx +++ b/src/renderer/src/components/SftpContextMenu.tsx @@ -1,6 +1,7 @@ -import type { ReactNode } from 'react' -import { createPortal } from 'react-dom' +import { useCallback, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' +import { ContextMenuSurface } from '../ui/context-menu' +import { MenuItem, MenuSeparator } from '../ui/menu' export interface SftpContextMenuProps { x: number @@ -14,16 +15,13 @@ export interface SftpContextMenuProps { onClose: () => void } -const MENU_WIDTH = 160 -const ITEM_HEIGHT = 28 -const SEPARATOR_HEIGHT = 9 -const MENU_PAD = 8 - -function menuHeight(canEdit: boolean, canDownload: boolean): number { - const items = (canEdit ? 1 : 0) + (canDownload ? 1 : 0) + 2 - return MENU_PAD + items * ITEM_HEIGHT + SEPARATOR_HEIGHT -} - +/** + * Right-click menu of a file row. + * + * The menu is mounted only while it is open; Base UI owns the positioning at + * the pointer, the roving focus, the Escape/outside-press dismissal and the + * close-on-select behaviour. + */ export function SftpContextMenu({ x, y, @@ -36,61 +34,44 @@ export function SftpContextMenu({ onClose }: SftpContextMenuProps): ReactNode { const { t } = useTranslation() - const height = menuHeight(canEdit, canDownload) - const left = Math.min(x, Math.max(0, window.innerWidth - MENU_WIDTH)) - const top = Math.min(y, Math.max(0, window.innerHeight - height)) + const [open, setOpen] = useState(true) - const run = (action: () => void): void => { - onClose() - action() - } + const handleOpenChange = useCallback( + (next: boolean): void => { + setOpen(next) + if (!next) { + onClose() + } + }, + [onClose] + ) - return createPortal( -
e.preventDefault()} - data-testid="sftp-context-menu" + testId="sftp-context-menu" > {canEdit ? ( - + ) : null} {canDownload ? ( - + ) : null} - -
- -
, - document.body + + ) } diff --git a/src/renderer/src/components/TerminalContextMenu.tsx b/src/renderer/src/components/TerminalContextMenu.tsx index fcae756..5993dca 100644 --- a/src/renderer/src/components/TerminalContextMenu.tsx +++ b/src/renderer/src/components/TerminalContextMenu.tsx @@ -1,5 +1,7 @@ -import type { ReactNode } from 'react' +import { useCallback, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' +import { ContextMenuSurface } from '../ui/context-menu' +import { MenuItem, MenuSeparator } from '../ui/menu' export interface TerminalContextMenuProps { x: number @@ -11,9 +13,13 @@ export interface TerminalContextMenuProps { onClose: () => void } -const MENU_WIDTH = 160 -const MENU_HEIGHT = 96 - +/** + * Right-click menu of the terminal. + * + * The menu is mounted only while it is open; Base UI owns the positioning at + * the pointer, the roving focus, the Escape/outside-press dismissal and the + * close-on-select behaviour. + */ export function TerminalContextMenu({ x, y, @@ -24,51 +30,37 @@ export function TerminalContextMenu({ onClose }: TerminalContextMenuProps): ReactNode { const { t } = useTranslation() - const left = Math.min(x, Math.max(0, window.innerWidth - MENU_WIDTH)) - const top = Math.min(y, Math.max(0, window.innerHeight - MENU_HEIGHT)) + const [open, setOpen] = useState(true) + + const handleOpenChange = useCallback( + (next: boolean): void => { + setOpen(next) + if (!next) { + onClose() + } + }, + [onClose] + ) + return ( -
e.preventDefault()} - data-testid="terminal-context-menu" + testId="terminal-context-menu" > - - -
- -
+ + ) } diff --git a/src/renderer/src/components/Toast.tsx b/src/renderer/src/components/Toast.tsx index bb72468..5700cd7 100644 --- a/src/renderer/src/components/Toast.tsx +++ b/src/renderer/src/components/Toast.tsx @@ -1,25 +1,90 @@ +import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { Toast as BaseToast } from '@base-ui/react/toast' +import { ToastProvider, ToastSurface, ToastViewport, type ToastData } from '../ui/toast' interface ToastProps { message: string | null onClose: () => void + /** + * Optional variant, forwarded to Base UI as the toast type. It ends up as a + * `data-type` attribute on the toast surface. + */ + tone?: string + /** + * Auto dismiss delay in milliseconds. `0` keeps the toast on screen until it + * is dismissed, which is what the app has always done. + */ + timeout?: number } -export function Toast({ message, onClose }: ToastProps): React.JSX.Element | null { +/** Single toast id: a new message replaces the previous one in place. */ +const TOAST_ID = 'app-toast' + +/** + * Single toast surface of the app. + * + * The component owns the Base UI toast provider, so mounting it once (where + * the toast used to be rendered) is enough; `App.tsx` keeps using the same + * `message` / `onClose` contract. + */ +export function Toast({ message, onClose, tone, timeout = 0 }: ToastProps): React.JSX.Element { + return ( + + + + ) +} + +function ToastRegion({ + message, + onClose, + tone, + timeout +}: { + message: string | null + onClose: () => void + tone?: string + timeout: number +}): React.JSX.Element { const { t } = useTranslation() - if (!message) return null + const { add, close, toasts } = BaseToast.useToastManager() + + // Keep the latest callback without re-running the effect on every render: + // `App.tsx` passes a new arrow function each time. + const onCloseRef = useRef(onClose) + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + + // A single toast id keeps the "one message at a time" contract: a new message + // replaces the previous one in place instead of stacking a new toast. + useEffect(() => { + if (!message) { + close(TOAST_ID) + return + } + add({ + id: TOAST_ID, + title: message, + type: tone, + timeout, + onClose: () => onCloseRef.current() + }) + }, [message, tone, timeout, add, close]) return ( -
- {message} - -
+ + {toasts.map((toast) => ( + + ))} + ) } diff --git a/src/renderer/src/components/TransferCenter.tsx b/src/renderer/src/components/TransferCenter.tsx index ab0d7e1..ba76367 100644 --- a/src/renderer/src/components/TransferCenter.tsx +++ b/src/renderer/src/components/TransferCenter.tsx @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { createPortal } from 'react-dom' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { @@ -20,175 +19,115 @@ import { useTransferTasks, type TransferTaskWithMetrics } from '../hooks/useTransferTasks' +import { PopoverClose, PopoverRoot, PopoverSurface, PopoverTrigger } from '../ui/popover' export function TransferCenter(): React.JSX.Element { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) const [isHovered, setIsHovered] = useState(false) - const [popoverStyle, setPopoverStyle] = useState({}) - const panelRef = useRef(null) - const triggerRef = useRef(null) const { tasks, activeCount, cancellingIds, cancel, retry, clear, clearCompleted } = useTransferTasks(isOpen || isHovered) - const updatePosition = useCallback(() => { - if (!triggerRef.current) return - const rect = triggerRef.current.getBoundingClientRect() - const top = rect.bottom + 6 - const right = Math.max(12, window.innerWidth - rect.right) - const maxWidth = Math.min(420, window.innerWidth - 24) - setPopoverStyle({ - position: 'fixed', - top: `${top}px`, - right: `${right}px`, - maxWidth: `${maxWidth}px`, - zIndex: 10000 - }) + const handleOpenChange = useCallback((open: boolean): void => { + setIsOpen(open) }, []) - // Reposition on window resize or scroll - useEffect(() => { - if (!isOpen) return - updatePosition() - window.addEventListener('resize', updatePosition) - window.addEventListener('scroll', updatePosition, true) - return () => { - window.removeEventListener('resize', updatePosition) - window.removeEventListener('scroll', updatePosition, true) - } - }, [isOpen, updatePosition]) - - // Close on Escape or click outside - useEffect(() => { - if (!isOpen) return - - const handleKeyDown = (e: KeyboardEvent): void => { - if (e.key === 'Escape') { - setIsOpen(false) - triggerRef.current?.focus() - } - } - - const handleClickOutside = (e: MouseEvent): void => { - const target = e.target as Node - if ( - panelRef.current && - !panelRef.current.contains(target) && - triggerRef.current && - !triggerRef.current.contains(target) - ) { - setIsOpen(false) - } - } - - document.addEventListener('keydown', handleKeyDown) - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('keydown', handleKeyDown) - document.removeEventListener('mousedown', handleClickOutside) - } - }, [isOpen]) - const hasCompleted = tasks.some( - (task) => - task.state === 'succeeded' || task.state === 'failed' || task.state === 'cancelled' + (task) => task.state === 'succeeded' || task.state === 'failed' || task.state === 'cancelled' ) return ( -
- + +
+ + + {t('transfer.toggle')} + {activeCount > 0 && ( + + {activeCount > 99 ? '99+' : activeCount} + + )} + +
- {isOpen && - createPortal( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - onFocus={() => setIsHovered(true)} - onBlur={(e) => { - if (!panelRef.current?.contains(e.relatedTarget as Node)) { - setIsHovered(false) - } - }} - > -
-
- {t('transfer.title')} - {activeCount > 0 && ( - - {t('transfer.activeCount', { count: activeCount })} - - )} -
-
- {hasCompleted && ( - - )} - -
-
+ setIsHovered(true), + onMouseLeave: () => setIsHovered(false), + onFocus: () => setIsHovered(true), + onBlur: (event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setIsHovered(false) + } + } + }} + > +
+
+ {t('transfer.title')} + {activeCount > 0 && ( + + {t('transfer.activeCount', { count: activeCount })} + + )} +
+
+ {hasCompleted && ( + + )} + + + +
+
-
- {tasks.length === 0 ? ( -
- -

{t('transfer.empty')}

-
- ) : ( -
    - {tasks.map((task) => ( - void cancel(task.taskId)} - onRetry={() => void retry(task.taskId)} - onClear={() => void clear(task.taskId)} - /> - ))} -
- )} +
+ {tasks.length === 0 ? ( +
+ +

{t('transfer.empty')}

-
, - document.body - )} -
+ ) : ( +
    + {tasks.map((task) => ( + void cancel(task.taskId)} + onRetry={() => void retry(task.taskId)} + onClear={() => void clear(task.taskId)} + /> + ))} +
+ )} +
+ +
) } diff --git a/src/renderer/src/ui/context-menu.tsx b/src/renderer/src/ui/context-menu.tsx new file mode 100644 index 0000000..1fb03e3 --- /dev/null +++ b/src/renderer/src/ui/context-menu.tsx @@ -0,0 +1,70 @@ +import { useMemo, type ReactNode } from 'react' +import { ContextMenu } from '@base-ui/react/context-menu' +import './ui.css' + +/** + * Thin wrapper around Base UI's context menu. + * + * The app opens the menu from a right-click on a terminal or a file row, so the + * pointer position is already known when the menu mounts. The wrapper turns + * that position into the virtual anchor Base UI positions the popup against, + * which keeps the "menu appears at the cursor" behaviour while Base UI owns + * the collision handling, the keyboard navigation and the dismissal. + */ + +export interface ContextMenuSurfaceProps { + /** Viewport X coordinate of the pointer that opened the menu. */ + x: number + /** Viewport Y coordinate of the pointer that opened the menu. */ + y: number + /** Whether the menu is open. */ + open: boolean + /** Called when Base UI asks to open or close the menu. */ + onOpenChange: (open: boolean) => void + /** Class name of the popup element (the element carrying `role="menu"`). */ + className: string + /** Test id forwarded to the popup. */ + testId?: string + children: ReactNode +} + +/** Root + portal + positioner + popup of a context menu. */ +export function ContextMenuSurface({ + x, + y, + open, + onOpenChange, + className, + testId, + children +}: ContextMenuSurfaceProps): React.JSX.Element { + const anchor = useMemo( + () => ({ + getBoundingClientRect: () => DOMRect.fromRect({ x, y, width: 0, height: 0 }) + }), + [x, y] + ) + + return ( + onOpenChange(next)}> + + + + {children} + + + + + ) +} diff --git a/src/renderer/src/ui/menu.tsx b/src/renderer/src/ui/menu.tsx new file mode 100644 index 0000000..afeb7fd --- /dev/null +++ b/src/renderer/src/ui/menu.tsx @@ -0,0 +1,154 @@ +import type { ReactNode } from 'react' +import { Menu, type MenuPositionerProps } from '@base-ui/react/menu' +import './ui.css' + +/** + * Thin wrappers around Base UI's menu primitives. + * + * The wrappers only carry the class names the app already uses, so `App.css` + * keeps styling the surfaces untouched, plus a `data-bui-*` hook that the + * scoped rules in `ui.css` target. Positioning, keyboard navigation, + * dismissal, focus handling and close-on-select are Base UI's job. + */ + +/** Anchor accepted by Base UI's positioner (element, ref or virtual element). */ +export type MenuAnchor = MenuPositionerProps['anchor'] + +export interface MenuRootProps { + /** Whether the menu is open. */ + open: boolean + /** Called when Base UI asks to open or close the menu. */ + onOpenChange: (open: boolean) => void + children: ReactNode +} + +/** Groups the parts of a menu. Renders no element of its own. */ +export function MenuRoot({ open, onOpenChange, children }: MenuRootProps): React.JSX.Element { + return ( + onOpenChange(next)}> + {children} + + ) +} + +export interface MenuTriggerProps { + /** Class name for the trigger button. */ + className: string + children: ReactNode +} + +/** Button that opens the menu it belongs to. */ +export function MenuTrigger({ className, children }: MenuTriggerProps): React.JSX.Element { + return ( + + {children} + + ) +} + +export interface MenuSurfaceProps { + /** Class name of the popup element (the element carrying `role="menu"`). */ + className: string + /** Class name of the element Base UI positions. */ + positionerClassName?: string + /** Anchor to position against. Defaults to the menu trigger. */ + anchor?: MenuAnchor + /** Preferred side of the anchor. */ + side?: MenuPositionerProps['side'] + /** Alignment relative to the anchor. */ + align?: MenuPositionerProps['align'] + /** Distance between the anchor and the popup, in pixels. */ + sideOffset?: number + /** Extra offset along the alignment axis, in pixels. */ + alignOffset?: number + /** Minimum distance kept between the popup and the collision boundary. */ + collisionPadding?: MenuPositionerProps['collisionPadding'] + /** Test id forwarded to the popup. */ + testId?: string + children: ReactNode +} + +/** + * Portal + positioner + popup of a menu. + * + * The popup is the element that carries `role="menu"`, so the class names used + * by the app keep pointing at the same surface as before. + */ +export function MenuSurface({ + className, + positionerClassName, + anchor, + side, + align, + sideOffset, + alignOffset, + collisionPadding, + testId, + children +}: MenuSurfaceProps): React.JSX.Element { + return ( + + + + {children} + + + + ) +} + +export interface MenuItemProps { + /** Class name for the item. */ + className: string + /** Disables the item. Base UI marks it with `aria-disabled`/`data-disabled`. */ + disabled?: boolean + /** Invoked when the item is activated. Never called while disabled. */ + onClick?: () => void + children: ReactNode +} + +/** + * A single menu entry. + * + * It keeps the native `
- + )}
@@ -215,20 +211,11 @@ export function HostForm({ initial, onSubmit, onCancel }: HostFormProps): React.
-
diff --git a/src/renderer/src/components/ModelIdEditor.tsx b/src/renderer/src/components/ModelIdEditor.tsx index faa750b..789e1a6 100644 --- a/src/renderer/src/components/ModelIdEditor.tsx +++ b/src/renderer/src/components/ModelIdEditor.tsx @@ -2,6 +2,9 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faPlus, faXmark } from '@fortawesome/free-solid-svg-icons' +import { Button } from '../ui/button' +import { FormFieldRoot } from '../ui/field' +import { Input } from '../ui/input' export const DEFAULT_MAX_ITEMS = 32 export const DEFAULT_MAX_ITEM_LENGTH = 512 @@ -115,39 +118,39 @@ export function ModelIdEditor({ const isLimitReached = value.length >= maxItems return ( -
-
+
+ {value.length > 0 && (
{value.map((model, index) => ( - + {model} - + ))}
)}
- { @@ -159,8 +162,7 @@ export function ModelIdEditor({ onPaste={handlePaste} spellCheck={false} /> - +
-
+
diff --git a/src/renderer/src/components/Select.tsx b/src/renderer/src/components/Select.tsx index b672c39..0fbf69d 100644 --- a/src/renderer/src/components/Select.tsx +++ b/src/renderer/src/components/Select.tsx @@ -1,78 +1,2 @@ -import type { ReactNode } from 'react' -import * as RadixSelect from '@radix-ui/react-select' - -export interface SelectOption { - value: string - label: string -} - -export interface SelectGroup { - label: string - options: SelectOption[] -} - -interface SelectProps { - value: string - options?: SelectOption[] - groups?: SelectGroup[] - onChange: (value: string) => void - disabled?: boolean - placeholder?: string - 'aria-label'?: string - id?: string - className?: string -} - -function renderOptions(options: SelectOption[]): ReactNode { - return options.map((opt) => ( - - {opt.label} - - )) -} - -export function Select({ - value, - options = [], - groups, - onChange, - disabled = false, - placeholder, - id, - className, - 'aria-label': ariaLabel -}: SelectProps): React.JSX.Element { - const triggerClass = className ? `app-select-trigger ${className}` : 'app-select-trigger' - return ( - - - - - - - - - - e.stopPropagation()} - > - ▴ - - {groups && groups.length > 0 - ? groups.map((group, i) => ( - - {group.label} - {renderOptions(group.options)} - - )) - : renderOptions(options)} - - ▾ - - - - ) -} +export { Select } from '../ui/select' +export type { SelectGroup, SelectOption, SelectProps } from '../ui/select' diff --git a/src/renderer/src/components/SettingsModal.tsx b/src/renderer/src/components/SettingsModal.tsx index b7d8acb..4ced746 100644 --- a/src/renderer/src/components/SettingsModal.tsx +++ b/src/renderer/src/components/SettingsModal.tsx @@ -1,59 +1,16 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import type { - AgentConfigStatus, - AgentContextCompression, - AgentProviderStatus, - AgentThinkingStrength, - AppSettings, LanguageCode, - McpManualConfig, McpPermissionMode, - McpRegistrationTargetStatus, - McpSnippetFormat, PermissionPolicy, ThemePreference } from '../../../shared/types' -import { ModelIdEditor } from './ModelIdEditor' import { ModalShell, useModalClose } from './ModalShell' -import { Select } from './Select' - -const MCP_SNIPPET_FORMATS: McpSnippetFormat[] = ['standard', 'vscode', 'opencode', 'codex'] - -/** Lower bound of the agent context budget in tokens (0 = unlimited). */ -const AGENT_CONTEXT_WINDOW_MIN = 4096 -/** Upper bound of the agent context budget in tokens. */ -const AGENT_CONTEXT_WINDOW_MAX = 1048576 - -/** - * Clamp the estimated context budget the same way the settings store does: - * 0 stays "unlimited", everything else is clamped into - * [AGENT_CONTEXT_WINDOW_MIN, AGENT_CONTEXT_WINDOW_MAX]. - */ -function clampAgentContextWindow(value: number): number { - if (!Number.isFinite(value)) return 0 - const rounded = Math.round(value) - if (rounded === 0) return 0 - return Math.min(AGENT_CONTEXT_WINDOW_MAX, Math.max(AGENT_CONTEXT_WINDOW_MIN, rounded)) -} - -function normalizeThinkingStrength(value: string | undefined): AgentThinkingStrength { - if (value === 'low' || value === 'medium' || value === 'high') return value - return 'auto' -} - -function normalizeContextCompression(value: string | undefined): AgentContextCompression { - return value === 'auto' ? 'auto' : 'off' -} - -function formatMcpLaunchCommand(command: string, args: string[]): string { - const quote = (s: string): string => { - if (s === '') return '""' - if (/[\s"]/.test(s)) return `"${s.replace(/"/g, '\\"')}"` - return s - } - return [command, ...args].map(quote).join(' ') -} +import { AgentRuntimeSettingsSection } from './settings/AgentRuntimeSettingsSection' +import { AgentSettingsSection } from './settings/AgentSettingsSection' +import { GeneralSettingsSection } from './settings/GeneralSettingsSection' +import { McpSettingsSection } from './settings/McpSettingsSection' interface SettingsModalProps { language: LanguageCode @@ -75,873 +32,8 @@ interface SettingsModalProps { onClose: () => void } -type ProviderDraft = { - key: string - id: string - name: string - baseUrl: string - models: string[] - apiKey: string - hasKey: boolean -} - -let draftSeq = 0 -function nextDraftKey(): string { - draftSeq += 1 - return `draft-${draftSeq}` -} - -function draftsFromStatus(status: AgentConfigStatus): ProviderDraft[] { - return status.providers.map((p) => ({ - key: p.id, - id: p.id, - name: p.name, - baseUrl: p.baseUrl, - models: [...p.models], - apiKey: '', - hasKey: p.hasKey - })) -} - -function applyStatusToDrafts(prev: ProviderDraft[], status: AgentConfigStatus): ProviderDraft[] { - const byId = new Map(status.providers.map((p) => [p.id, p])) - const next: ProviderDraft[] = [] - const seen = new Set() - for (const draft of prev) { - if (!draft.id) { - next.push(draft) - continue - } - const p = byId.get(draft.id) - if (!p) continue - seen.add(p.id) - next.push({ - ...draft, - name: p.name, - baseUrl: p.baseUrl, - models: [...p.models], - apiKey: '', - hasKey: p.hasKey - }) - } - for (const p of status.providers) { - if (seen.has(p.id)) continue - next.push({ - key: p.id, - id: p.id, - name: p.name, - baseUrl: p.baseUrl, - models: [...p.models], - apiKey: '', - hasKey: p.hasKey - }) - } - return next -} - -/** - * Agent provider settings. API keys are write-only: the backend stores them - * in the OS keyring and reports back only whether one exists, so the renderer - * never holds the secret and the field starts empty on every open. The - * section is skipped entirely when the running bridge has no agent. - */ -function AgentSettingsSection(): React.JSX.Element | null { - const { t } = useTranslation() - const agent = window.api.agent - const [drafts, setDrafts] = useState([]) - const [expanded, setExpanded] = useState([]) - const [busy, setBusy] = useState(false) - const [ready, setReady] = useState(false) - const [message, setMessage] = useState(null) - - const patchDraft = (key: string, patch: Partial): void => { - setDrafts((prev) => prev.map((d) => (d.key === key ? { ...d, ...patch } : d))) - } - - const toggleExpanded = (key: string): void => { - setExpanded((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])) - } - - useEffect(() => { - if (!agent) return - void (async () => { - try { - const status = await agent.status() - setDrafts(draftsFromStatus(status)) - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - } finally { - setReady(true) - } - })() - }, [agent]) - - if (!agent) return null - - const save = async (draft: ProviderDraft): Promise => { - setBusy(true) - setMessage(null) - try { - let status = await agent.upsertProvider({ - ...(draft.id ? { id: draft.id } : {}), - name: draft.name, - baseUrl: draft.baseUrl, - models: draft.models - }) - const saved = - draft.id !== '' - ? status.providers.find((p) => p.id === draft.id) - : newestProvider(status.providers, drafts) - if (draft.apiKey !== '' && saved) { - status = await agent.setProviderKey(saved.id, draft.apiKey) - } - setDrafts((prev) => applyStatusToDrafts(prev.filter((d) => d.key !== draft.key || d.id !== ''), status)) - setExpanded((prev) => prev.filter((k) => k !== draft.key && k !== saved?.id)) - setMessage(t('settings.agentSaved')) - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - } finally { - setBusy(false) - } - } - - const clearKey = async (draft: ProviderDraft): Promise => { - if (!draft.id) return - setBusy(true) - setMessage(null) - try { - const status = await agent.setProviderKey(draft.id, '') - setDrafts((prev) => applyStatusToDrafts(prev, status)) - setMessage(t('settings.agentKeyCleared')) - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - } finally { - setBusy(false) - } - } - - const remove = async (draft: ProviderDraft): Promise => { - setBusy(true) - setMessage(null) - try { - if (!draft.id) { - setDrafts((prev) => prev.filter((d) => d.key !== draft.key)) - setExpanded((prev) => prev.filter((k) => k !== draft.key)) - } else { - const status = await agent.deleteProvider(draft.id) - setDrafts((prev) => applyStatusToDrafts(prev, status)) - setExpanded((prev) => prev.filter((k) => k !== draft.key && k !== draft.id)) - setMessage(t('settings.agentProviderDeleted')) - } - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - } finally { - setBusy(false) - } - } - - const addProvider = (): void => { - const key = nextDraftKey() - setDrafts((prev) => [ - ...prev, - { - key, - id: '', - name: '', - baseUrl: '', - models: [], - apiKey: '', - hasKey: false - } - ]) - setExpanded((prev) => [...prev, key]) - } - - return ( -
- {t('settings.agent')} -

{t('settings.agentHint')}

- - {drafts.map((draft, index) => { - const open = expanded.includes(draft.key) - const title = draft.name.trim() || t('settings.agentNewProvider') - return ( -
- -
-
-
-
- {t('settings.agentName')} - patchDraft(draft.key, { name: e.target.value })} - /> -
-
- {t('settings.agentBaseUrl')} - patchDraft(draft.key, { baseUrl: e.target.value })} - /> -
-
- {t('settings.agentModels')} - patchDraft(draft.key, { models })} - /> -
-
- {t('settings.agentApiKey')} - patchDraft(draft.key, { apiKey: e.target.value })} - /> -
-
- - - -
-
-
-
-
- ) - })} - -
- -
- {message &&

{message}

} -
- ) -} - -function newestProvider( - providers: AgentProviderStatus[], - drafts: ProviderDraft[] -): AgentProviderStatus | undefined { - const known = new Set(drafts.map((d) => d.id).filter(Boolean)) - return providers.find((p) => !known.has(p.id)) ?? providers[providers.length - 1] -} - -/** - * Agent runtime settings. These are global (not per provider), so they live - * in settings.json and are read and written through the existing settings - * API — the provider CRUD below stays untouched. - */ -function AgentRuntimeSettingsSection(): React.JSX.Element { - const { t } = useTranslation() - const [contextWindow, setContextWindow] = useState('0') - const [savedContextWindow, setSavedContextWindow] = useState(0) - const [thinkingStrength, setThinkingStrength] = useState('auto') - const [compression, setCompression] = useState('off') - const [ready, setReady] = useState(false) - const [busy, setBusy] = useState(false) - const [message, setMessage] = useState(null) - - useEffect(() => { - void (async () => { - try { - const settings = await window.api.settings.get() - const tokens = settings?.agentContextWindow ?? 0 - setSavedContextWindow(tokens) - setContextWindow(String(tokens)) - setThinkingStrength(normalizeThinkingStrength(settings?.agentThinkingStrength)) - setCompression(normalizeContextCompression(settings?.agentContextCompression)) - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - } finally { - setReady(true) - } - })() - }, []) - - /** Persist one field; returns false when the backend rejected the write. */ - const persist = async (patch: Partial): Promise => { - setBusy(true) - setMessage(null) - try { - await window.api.settings.set(patch) - setMessage(t('settings.agentRuntimeSaved')) - return true - } catch (err) { - setMessage(err instanceof Error ? err.message : String(err)) - return false - } finally { - setBusy(false) - } - } - - const commitContextWindow = async (): Promise => { - const raw = contextWindow.trim() - const parsed = raw === '' ? Number.NaN : Number(raw) - if (!Number.isFinite(parsed)) { - setContextWindow(String(savedContextWindow)) - return - } - const next = clampAgentContextWindow(parsed) - if (next === savedContextWindow) { - setContextWindow(String(next)) - return - } - const saved = await persist({ agentContextWindow: next }) - setSavedContextWindow(saved ? next : savedContextWindow) - setContextWindow(String(saved ? next : savedContextWindow)) - } - - return ( -
- {t('settings.agentRuntime')} -

{t('settings.agentRuntimeHint')}

- -
- {t('settings.agentContextWindow')} - setContextWindow(e.target.value)} - onBlur={() => void commitContextWindow()} - onKeyDown={(e) => { - if (e.key === 'Enter') void commitContextWindow() - }} - /> -
-

{t('settings.agentContextWindowHint')}

- -
- {t('settings.agentThinkingStrength')} - { - const next = normalizeContextCompression(v) - setCompression(next) - void persist({ agentContextCompression: next }) - }} - options={[ - { value: 'off', label: t('settings.agentCompressionOff') }, - { value: 'auto', label: t('settings.agentCompressionAuto') } - ]} - /> -
- - {message &&

{message}

} -
- ) -} - type SettingsPage = 'index' | 'general' | 'agent' | 'mcp' -function GeneralSettingsSection({ - language, - themePreference, - terminalFontFamily, - terminalFontSize, - permissionPolicy, - onLanguageChange, - onThemePreferenceChange, - onTerminalFontFamilyChange, - onTerminalFontSizeChange, - onPermissionPolicyChange -}: Pick< - SettingsModalProps, - | 'language' - | 'themePreference' - | 'terminalFontFamily' - | 'terminalFontSize' - | 'permissionPolicy' - | 'onLanguageChange' - | 'onThemePreferenceChange' - | 'onTerminalFontFamilyChange' - | 'onTerminalFontSizeChange' - | 'onPermissionPolicyChange' ->): React.JSX.Element { - const { t } = useTranslation() - const [fonts, setFonts] = useState([]) - - useEffect(() => { - void (async () => { - try { - const list = await window.api.fonts.list() - setFonts(list) - } catch { - setFonts([]) - } - })() - }, []) - - const fontOptions = - fonts.includes(terminalFontFamily) || !terminalFontFamily - ? fonts - : [terminalFontFamily, ...fonts] - const fontSelectOptions = - fontOptions.length === 0 && terminalFontFamily - ? [{ value: terminalFontFamily, label: terminalFontFamily }] - : fontOptions.map((name) => ({ value: name, label: name })) - const fontSize = Math.min(24, Math.max(10, Math.round(terminalFontSize) || 14)) - const fontSizeOptions = Array.from({ length: 15 }, (_, i) => { - const size = 10 + i - return { value: String(size), label: String(size) } - }) - - return ( -
-
- {t('common.language')} - onThemePreferenceChange(v as ThemePreference)} - aria-label={t('settings.theme')} - options={[ - { value: 'system', label: t('settings.themeSystem') }, - { value: 'light', label: t('settings.themeLight') }, - { value: 'dark', label: t('settings.themeDark') } - ]} - /> -
- - -
- {t('settings.terminal')} -
- {t('settings.fontFamily')} - { - const n = Number(v) - if (!Number.isFinite(n)) return - onTerminalFontSizeChange(n) - }} - aria-label={t('settings.fontSize')} - options={fontSizeOptions} - /> -
-
- -
- {t('settings.permissions')} -

{t('settings.permissionsHint')}

-
- {t('settings.permissionPolicy')} - onMcpPermissionModeChange(v as McpPermissionMode)} - aria-label={t('settings.mcpPermissionMode')} - options={[ - { value: 'external', label: t('settings.mcpPermissionExternal') }, - { value: 'local', label: t('settings.mcpPermissionLocal') } - ]} - /> -
-

- {mcpPermissionMode === 'local' - ? t('settings.mcpPermissionLocalHint') - : t('settings.mcpPermissionExternalHint')} -

-
-
- {t('settings.mcpIdleTimeout')} - { - const n = Number(v) - if (!Number.isFinite(n)) return - onMcpMaxSessionsChange(n) - }} - aria-label={t('settings.mcpMaxSessions')} - options={maxSessionOptions} - /> -
-
- -
-
{t('settings.mcpRegisterTitle')}
-

{t('settings.mcpRegisterHint')}

-
- -
-
    - {mcpTargets.map((row) => { - const statusLabel = row.registered - ? t('settings.mcpStatusRegistered') - : row.stale - ? t('settings.mcpStatusStale') - : t('settings.mcpStatusMissing') - return ( -
  • -
    - {row.label} - {row.configPath ? ( - - {row.configPath} - - ) : null} - - {statusLabel} - -
    - -
  • - ) - })} -
-
- -
-
{t('settings.mcpOtherTitle')}
-

{t('settings.mcpOtherHint')}

-
- - -
-
- {MCP_SNIPPET_FORMATS.map((format) => ( - - ))} -
-