From 0dfe445b0e664e4fe343c4a67012530f97e03ac3 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 12 Feb 2026 15:56:27 +0900 Subject: [PATCH] feat(ui): implement ConfirmDialog for enhanced user confirmations - Introduced a reusable `ConfirmDialog` component to replace native `window.confirm()`, providing a styled modal that aligns with the app's theme. - Integrated the `ConfirmDialog` into the `App` component for global access. - Updated the `WorkspaceSection` to utilize the new confirmation dialog for profile deletion, enhancing user experience with a more consistent UI. - Added state management for selected profiles in the `ConnectionSection`, improving user interaction when selecting saved profiles. This commit enhances the application's UI by providing a more cohesive and user-friendly confirmation experience. --- src/renderer/App.tsx | 2 + .../components/common/ConfirmDialog.tsx | 175 ++++++++++++++++++ .../settings/sections/ConnectionSection.tsx | 56 ++++-- .../settings/sections/WorkspaceSection.tsx | 8 +- 4 files changed, 221 insertions(+), 20 deletions(-) create mode 100644 src/renderer/components/common/ConfirmDialog.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index aaa32127..1f307d94 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,6 @@ import React, { useEffect } from 'react'; +import { ConfirmDialog } from './components/common/ConfirmDialog'; import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay'; import { ErrorBoundary } from './components/common/ErrorBoundary'; import { TabbedLayout } from './components/layout/TabbedLayout'; @@ -44,6 +45,7 @@ export const App = (): React.JSX.Element => { + ); }; diff --git a/src/renderer/components/common/ConfirmDialog.tsx b/src/renderer/components/common/ConfirmDialog.tsx new file mode 100644 index 00000000..0da90a5a --- /dev/null +++ b/src/renderer/components/common/ConfirmDialog.tsx @@ -0,0 +1,175 @@ +/** + * ConfirmDialog - Reusable themed confirmation dialog. + * + * Replaces native window.confirm() with a styled modal that matches the app theme. + * Controlled via useConfirmDialog() hook for imperative usage. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { AlertTriangle } from 'lucide-react'; + +interface ConfirmDialogState { + isOpen: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'default' | 'danger'; +} + +type ConfirmResolver = ((confirmed: boolean) => void) | null; + +const initialState: ConfirmDialogState = { + isOpen: false, + title: '', + message: '', +}; + +// Singleton state — one dialog at a time +let globalSetState: ((state: ConfirmDialogState) => void) | null = null; +let globalResolver: ConfirmResolver = null; + +/** + * Imperatively show a themed confirm dialog. Returns a promise that resolves + * to true (confirmed) or false (cancelled). + * + * Usage: + * const confirmed = await confirm({ title: 'Delete?', message: 'This cannot be undone.' }); + */ +export async function confirm(opts: { + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'default' | 'danger'; +}): Promise { + return new Promise((resolve) => { + // If a previous dialog is open, resolve it as cancelled + if (globalResolver) { + globalResolver(false); + } + + globalResolver = resolve; + globalSetState?.({ + isOpen: true, + title: opts.title, + message: opts.message, + confirmLabel: opts.confirmLabel, + cancelLabel: opts.cancelLabel, + variant: opts.variant, + }); + }); +} + +/** + * ConfirmDialog component. Mount once at the app root (e.g. in App.tsx). + */ +export const ConfirmDialog = (): React.JSX.Element | null => { + const [state, setState] = useState(initialState); + const dialogRef = useRef(null); + + // Register singleton setter + useEffect(() => { + globalSetState = setState; + return () => { + globalSetState = null; + }; + }, []); + + const close = useCallback((confirmed: boolean) => { + if (globalResolver) { + globalResolver(confirmed); + globalResolver = null; + } + setState(initialState); + }, []); + + // Escape key closes + useEffect(() => { + if (!state.isOpen) return; + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') close(false); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [state.isOpen, close]); + + // Auto-focus confirm button + useEffect(() => { + if (state.isOpen && dialogRef.current) { + const btn = dialogRef.current.querySelector('[data-confirm-btn]'); + btn?.focus(); + } + }, [state.isOpen]); + + if (!state.isOpen) return null; + + const isDanger = state.variant === 'danger'; + + return ( +
+ {/* Backdrop */} + + +
+ + + ); +}; diff --git a/src/renderer/components/settings/sections/ConnectionSection.tsx b/src/renderer/components/settings/sections/ConnectionSection.tsx index eec4c089..7ba88a0a 100644 --- a/src/renderer/components/settings/sections/ConnectionSection.tsx +++ b/src/renderer/components/settings/sections/ConnectionSection.tsx @@ -61,6 +61,7 @@ export const ConnectionSection = (): React.JSX.Element => { // Saved profiles const [savedProfiles, setSavedProfiles] = useState([]); + const [selectedProfileId, setSelectedProfileId] = useState(null); const loadProfiles = useCallback(async () => { try { @@ -123,6 +124,8 @@ export const ConnectionSection = (): React.JSX.Element => { ); }, [host, sshConfigHosts]); + const clearProfileSelection = (): void => setSelectedProfileId(null); + const handleSelectConfigHost = (entry: SshConfigHostEntry): void => { setHost(entry.alias); if (entry.port) setPort(String(entry.port)); @@ -130,6 +133,7 @@ export const ConnectionSection = (): React.JSX.Element => { setAuthMethod('auto'); setShowDropdown(false); setTestResult(null); + clearProfileSelection(); }; const handleSelectProfile = (profile: SshConnectionProfile): void => { @@ -140,6 +144,7 @@ export const ConnectionSection = (): React.JSX.Element => { if (profile.privateKeyPath) setPrivateKeyPath(profile.privateKeyPath); setPassword(''); setTestResult(null); + setSelectedProfileId(profile.id); }; const buildConfig = (): SshConnectionConfig => ({ @@ -241,24 +246,33 @@ export const ConnectionSection = (): React.JSX.Element => { Saved Profiles
- {savedProfiles.map((profile) => ( - - ))} + {savedProfiles.map((profile) => { + const isSelected = selectedProfileId === profile.id; + return ( + + ); + })}
)} @@ -284,6 +298,7 @@ export const ConnectionSection = (): React.JSX.Element => { setHost(e.target.value); setShowDropdown(true); setTestResult(null); + clearProfileSelection(); }} onFocus={() => setShowDropdown(true)} placeholder="hostname or ssh config alias" @@ -348,7 +363,10 @@ export const ConnectionSection = (): React.JSX.Element => { setUsername(e.target.value)} + onChange={(e) => { + setUsername(e.target.value); + clearProfileSelection(); + }} placeholder="user" className={inputClass} style={inputStyle} diff --git a/src/renderer/components/settings/sections/WorkspaceSection.tsx b/src/renderer/components/settings/sections/WorkspaceSection.tsx index 9ba78ad8..5959e0f5 100644 --- a/src/renderer/components/settings/sections/WorkspaceSection.tsx +++ b/src/renderer/components/settings/sections/WorkspaceSection.tsx @@ -13,6 +13,7 @@ import { useCallback, useEffect, useState } from 'react'; import { api } from '@renderer/api'; +import { confirm } from '@renderer/components/common/ConfirmDialog'; import { useStore } from '@renderer/store'; import { Edit2, Loader2, Plus, Save, Server, Trash2, X } from 'lucide-react'; @@ -143,7 +144,12 @@ export const WorkspaceSection = (): React.JSX.Element => { const profile = profiles.find((p) => p.id === id); if (!profile) return; - const confirmed = window.confirm(`Delete profile "${profile.name}"?`); + const confirmed = await confirm({ + title: 'Delete Profile', + message: `Are you sure you want to delete "${profile.name}"? This cannot be undone.`, + confirmLabel: 'Delete', + variant: 'danger', + }); if (!confirmed) return; const filtered = profiles.filter((p) => p.id !== id);