/ components / SettingsDialog.tsx
SettingsDialog.tsx
1 import { useState, useEffect } from 'react'; 2 import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; 3 import { CODE_THEMES, CODE_FONTS } from '@/lib/constants'; 4 import type { CodeTheme, CodeFont } from '@/lib/constants'; 5 import { applyTheme, type ThemeChoice } from '@/lib/theme'; 6 7 interface Props { 8 open: boolean; 9 onOpenChange: (open: boolean) => void; 10 onThemeChange?: (theme: string) => void; 11 // Resets the first-run welcome, the keyboard hint, and any 12 // localStorage onboarding flags so the user can re-experience the 13 // first-time path. Owned by HomePage because HomePage holds the 14 // firstRunOpen / hasEverHadPendingReviews / keyboardHintDismissed 15 // state slots that need to be reset together. 16 onReplayOnboarding?: () => void; 17 } 18 19 export function applyCodeFont(fontId: string) { 20 const font = CODE_FONTS.find((f) => f.id === fontId); 21 if (font) { 22 document.documentElement.style.setProperty('--font-mono', `${font.family}, ui-monospace, monospace`); 23 } 24 } 25 26 // Quiet toggle switch — drops the bg-primary fill (now ink) and the 27 // shadow-sm thumb in favor of a warm-amber active state and a flat 28 // thumb. Used across all the on/off settings. 29 function Toggle({ 30 checked, 31 onChange, 32 ariaLabel, 33 }: { 34 checked: boolean; 35 onChange: () => void; 36 ariaLabel?: string; 37 }) { 38 return ( 39 <button 40 type="button" 41 role="switch" 42 aria-checked={checked} 43 aria-label={ariaLabel} 44 onClick={onChange} 45 className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border transition-colors ${ 46 checked ? 'bg-[var(--ring)] border-[var(--ring)]' : 'bg-transparent border-border' 47 }`} 48 > 49 <span 50 className={`pointer-events-none block h-3.5 w-3.5 rounded-full transition-transform translate-y-px ${ 51 checked ? 'bg-background translate-x-[1.125rem]' : 'bg-muted-foreground translate-x-[2px]' 52 }`} 53 /> 54 </button> 55 ); 56 } 57 58 // Quiet text-only chip — same vocabulary as DiffLayoutToggle. Active 59 // option gets a hairline brand-amber underline; nothing else. 60 function Chip({ 61 active, 62 onClick, 63 children, 64 style, 65 }: { 66 active: boolean; 67 onClick: () => void; 68 children: React.ReactNode; 69 style?: React.CSSProperties; 70 }) { 71 return ( 72 <button 73 type="button" 74 onClick={onClick} 75 style={style} 76 className={`text-sm pb-0.5 border-b transition-colors ${ 77 active ? 'text-foreground border-[var(--ring)]' : 'border-transparent text-muted-foreground hover:text-foreground' 78 }`} 79 > 80 {children} 81 </button> 82 ); 83 } 84 85 // Single setting row — label + description on the left, control on the right. 86 function SettingRow({ 87 label, 88 description, 89 children, 90 }: { 91 label: string; 92 description?: string; 93 children: React.ReactNode; 94 }) { 95 return ( 96 <div className="flex items-center justify-between gap-4 py-1"> 97 <div className="flex flex-col gap-0.5 min-w-0"> 98 <span className="text-sm font-medium text-foreground">{label}</span> 99 {description && <span className="slide-meta">{description}</span>} 100 </div> 101 <div className="shrink-0">{children}</div> 102 </div> 103 ); 104 } 105 106 export function SettingsDialog({ open, onOpenChange, onThemeChange, onReplayOnboarding }: Props) { 107 const [appTheme, setAppTheme] = useState<ThemeChoice>('system'); 108 const [codeTheme, setCodeTheme] = useState<CodeTheme>('aurora-x'); 109 const [codeFont, setCodeFont] = useState<CodeFont>('jetbrains-mono'); 110 const [enableTools, setEnableTools] = useState(false); 111 const [autoReviewOnRequest, setAutoReviewOnRequest] = useState(false); 112 const [notifications, setNotifications] = useState(true); 113 const [claudePath, setClaudePath] = useState(''); 114 const [geminiPath, setGeminiPath] = useState(''); 115 const [claudeDetected, setClaudeDetected] = useState(''); 116 const [geminiDetected, setGeminiDetected] = useState(''); 117 118 useEffect(() => { 119 if (!open) return; 120 void window.electronAPI.loadPreferences().then((prefs) => { 121 setAppTheme(prefs.theme); 122 if (prefs.codeTheme) setCodeTheme(prefs.codeTheme as CodeTheme); 123 if (prefs.codeFont) setCodeFont(prefs.codeFont as CodeFont); 124 setEnableTools(prefs.enableTools); 125 setAutoReviewOnRequest(prefs.autoReviewOnRequest); 126 setNotifications(prefs.notifications); 127 setClaudePath(prefs.claudePath || ''); 128 setGeminiPath(prefs.geminiPath || ''); 129 }); 130 void window.electronAPI.detectBinaryPath('claude').then(setClaudeDetected); 131 void window.electronAPI.detectBinaryPath('gemini').then(setGeminiDetected); 132 }, [open]); 133 134 function handleSelectAppTheme(theme: ThemeChoice) { 135 setAppTheme(theme); 136 saveField({ theme }); 137 applyTheme(theme); 138 } 139 140 function saveField(overrides: Partial<Record<string, string | boolean>>) { 141 void window.electronAPI.loadPreferences().then((prefs) => { 142 void window.electronAPI.savePreferences({ ...prefs, ...overrides }); 143 }); 144 } 145 146 function handleSelectTheme(theme: CodeTheme) { 147 setCodeTheme(theme); 148 saveField({ codeTheme: theme }); 149 onThemeChange?.(theme); 150 } 151 152 function handleSelectFont(font: CodeFont) { 153 setCodeFont(font); 154 saveField({ codeFont: font }); 155 applyCodeFont(font); 156 } 157 158 return ( 159 <Dialog open={open} onOpenChange={onOpenChange}> 160 <DialogContent className="bg-card sm:max-w-md"> 161 <DialogHeader> 162 <DialogTitle className="editorial-heading">Settings</DialogTitle> 163 <DialogDescription className="slide-meta">Configure your preferences</DialogDescription> 164 </DialogHeader> 165 166 <div className="flex flex-col gap-6 mt-2"> 167 <section className="flex flex-col gap-3"> 168 <label className="text-sm font-medium text-foreground">Theme</label> 169 <div className="flex flex-wrap gap-x-4 gap-y-2"> 170 {(['light', 'dark', 'system'] as const).map((t) => ( 171 <Chip key={t} active={appTheme === t} onClick={() => handleSelectAppTheme(t)}> 172 {t === 'light' ? 'Paper' : t === 'dark' ? 'Study' : 'Match system'} 173 </Chip> 174 ))} 175 </div> 176 </section> 177 178 <section className="flex flex-col gap-3"> 179 <label className="text-sm font-medium text-foreground">Code font</label> 180 <div className="flex flex-wrap gap-x-4 gap-y-2"> 181 {CODE_FONTS.map((f) => ( 182 <Chip 183 key={f.id} 184 active={codeFont === f.id} 185 onClick={() => handleSelectFont(f.id)} 186 style={{ fontFamily: `${f.family}, monospace` }} 187 > 188 {f.label} 189 </Chip> 190 ))} 191 </div> 192 </section> 193 194 <section className="flex flex-col gap-3"> 195 <label className="text-sm font-medium text-foreground">Code theme</label> 196 <div className="flex flex-wrap gap-x-4 gap-y-2"> 197 {CODE_THEMES.map((t) => ( 198 <Chip key={t.id} active={codeTheme === t.id} onClick={() => handleSelectTheme(t.id)}> 199 {t.label} 200 </Chip> 201 ))} 202 </div> 203 </section> 204 205 <section className="flex flex-col gap-3 border-t border-border pt-5"> 206 <SettingRow 207 label="Web search and context" 208 description="Let the model search the web and fetch GitHub context during generation. More thorough, but slower." 209 > 210 <Toggle 211 checked={enableTools} 212 ariaLabel="Enable AI tools" 213 onChange={() => { 214 const next = !enableTools; 215 setEnableTools(next); 216 saveField({ enableTools: next }); 217 }} 218 /> 219 </SettingRow> 220 221 <SettingRow 222 label="Auto-review when assigned" 223 description="Automatically run a review when you're added as a reviewer; you'll be notified when it's ready." 224 > 225 <Toggle 226 checked={autoReviewOnRequest} 227 ariaLabel="Auto-review when assigned" 228 onChange={() => { 229 const next = !autoReviewOnRequest; 230 setAutoReviewOnRequest(next); 231 saveField({ autoReviewOnRequest: next }); 232 }} 233 /> 234 </SettingRow> 235 236 <SettingRow label="Desktop notifications" description="Notify when a review completes"> 237 <Toggle 238 checked={notifications} 239 ariaLabel="Desktop notifications" 240 onChange={() => { 241 const next = !notifications; 242 setNotifications(next); 243 saveField({ notifications: next }); 244 }} 245 /> 246 </SettingRow> 247 </section> 248 249 <section className="flex flex-col gap-4 border-t border-border pt-5"> 250 <div className="flex flex-col gap-1.5"> 251 <label className="text-sm font-medium text-foreground">Claude CLI path</label> 252 <input 253 type="text" 254 value={claudePath} 255 placeholder={claudeDetected || 'auto-detect'} 256 onChange={(e) => setClaudePath(e.target.value)} 257 onBlur={() => saveField({ claudePath })} 258 className="bg-transparent border-0 border-b border-border px-0 py-1 text-sm text-foreground placeholder:text-muted-foreground/60 transition-colors" 259 /> 260 <p className="slide-meta">Leave empty to auto-detect.</p> 261 </div> 262 263 <div className="flex flex-col gap-1.5"> 264 <label className="text-sm font-medium text-foreground">Gemini CLI path</label> 265 <input 266 type="text" 267 value={geminiPath} 268 placeholder={geminiDetected || 'auto-detect'} 269 onChange={(e) => setGeminiPath(e.target.value)} 270 onBlur={() => saveField({ geminiPath })} 271 className="bg-transparent border-0 border-b border-border px-0 py-1 text-sm text-foreground placeholder:text-muted-foreground/60 transition-colors" 272 /> 273 <p className="slide-meta">Leave empty to auto-detect.</p> 274 </div> 275 </section> 276 277 <section className="border-t border-border pt-5 flex flex-col gap-2 items-start"> 278 {onReplayOnboarding && ( 279 <button 280 type="button" 281 onClick={() => { 282 onReplayOnboarding(); 283 onOpenChange(false); 284 }} 285 className="slide-meta hover:text-foreground transition-colors" 286 > 287 Replay first-time welcome → 288 </button> 289 )} 290 <button 291 type="button" 292 onClick={() => void window.electronAPI.openLogsDirectory()} 293 className="slide-meta hover:text-foreground transition-colors" 294 > 295 Open logs directory → 296 </button> 297 </section> 298 </div> 299 </DialogContent> 300 </Dialog> 301 ); 302 }