/ 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  }