/ components / SettingsDialog.tsx
SettingsDialog.tsx
  1  import { useState, useEffect } from 'react';
  2  import { FolderOpen } from 'lucide-react';
  3  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
  4  import { Button } from '@/components/ui/button';
  5  import { CODE_THEMES, CODE_FONTS } from '@/lib/constants';
  6  import type { CodeTheme, CodeFont } from '@/lib/constants';
  7  
  8  interface Props {
  9    open: boolean;
 10    onOpenChange: (open: boolean) => void;
 11    onThemeChange?: (theme: string) => void;
 12  }
 13  
 14  export function applyCodeFont(fontId: string) {
 15    const font = CODE_FONTS.find((f) => f.id === fontId);
 16    if (font) {
 17      document.documentElement.style.setProperty('--font-mono', `${font.family}, ui-monospace, monospace`);
 18    }
 19  }
 20  
 21  export function SettingsDialog({ open, onOpenChange, onThemeChange }: Props) {
 22    const [codeTheme, setCodeTheme] = useState<CodeTheme>('aurora-x');
 23    const [codeFont, setCodeFont] = useState<CodeFont>('jetbrains-mono');
 24    const [enableTools, setEnableTools] = useState(false);
 25    const [autoReviewOnRequest, setAutoReviewOnRequest] = useState(false);
 26    const [notifications, setNotifications] = useState(true);
 27    const [claudePath, setClaudePath] = useState('');
 28    const [geminiPath, setGeminiPath] = useState('');
 29    const [claudeDetected, setClaudeDetected] = useState('');
 30    const [geminiDetected, setGeminiDetected] = useState('');
 31  
 32    useEffect(() => {
 33      if (!open) return;
 34      void window.electronAPI.loadPreferences().then((prefs) => {
 35        if (prefs.codeTheme) setCodeTheme(prefs.codeTheme as CodeTheme);
 36        if (prefs.codeFont) setCodeFont(prefs.codeFont as CodeFont);
 37        setEnableTools(prefs.enableTools);
 38        setAutoReviewOnRequest(prefs.autoReviewOnRequest ?? false);
 39        setNotifications(prefs.notifications);
 40        setClaudePath(prefs.claudePath || '');
 41        setGeminiPath(prefs.geminiPath || '');
 42      });
 43      void window.electronAPI.detectBinaryPath('claude').then(setClaudeDetected);
 44      void window.electronAPI.detectBinaryPath('gemini').then(setGeminiDetected);
 45    }, [open]);
 46  
 47    function saveField(overrides: Partial<Record<string, string | boolean>>) {
 48      void window.electronAPI.loadPreferences().then((prefs) => {
 49        void window.electronAPI.savePreferences({ ...prefs, ...overrides });
 50      });
 51    }
 52  
 53    function handleSelectTheme(theme: CodeTheme) {
 54      setCodeTheme(theme);
 55      saveField({ codeTheme: theme });
 56      onThemeChange?.(theme);
 57    }
 58  
 59    function handleSelectFont(font: CodeFont) {
 60      setCodeFont(font);
 61      saveField({ codeFont: font });
 62      applyCodeFont(font);
 63    }
 64  
 65    return (
 66      <Dialog open={open} onOpenChange={onOpenChange}>
 67        <DialogContent className="bg-card sm:max-w-md">
 68          <DialogHeader>
 69            <DialogTitle>Settings</DialogTitle>
 70            <DialogDescription>Configure your preferences</DialogDescription>
 71          </DialogHeader>
 72  
 73          <div className="flex flex-col gap-4">
 74            <div className="flex flex-col gap-1.5">
 75              <label className="text-sm font-medium">Code font</label>
 76              <div className="flex flex-wrap gap-2">
 77                {CODE_FONTS.map((f) => (
 78                  <button
 79                    key={f.id}
 80                    type="button"
 81                    onClick={() => handleSelectFont(f.id)}
 82                    className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
 83                      codeFont === f.id
 84                        ? 'border-primary bg-primary text-primary-foreground'
 85                        : 'border-input bg-transparent text-muted-foreground hover:text-foreground hover:border-foreground/30'
 86                    }`}
 87                    style={{ fontFamily: `${f.family}, monospace` }}
 88                  >
 89                    {f.label}
 90                  </button>
 91                ))}
 92              </div>
 93            </div>
 94  
 95            <div className="flex flex-col gap-1.5">
 96              <label className="text-sm font-medium">Code theme</label>
 97              <div className="flex flex-wrap gap-2">
 98                {CODE_THEMES.map((t) => (
 99                  <button
100                    key={t.id}
101                    type="button"
102                    onClick={() => handleSelectTheme(t.id)}
103                    className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
104                      codeTheme === t.id
105                        ? 'border-primary bg-primary text-primary-foreground'
106                        : 'border-input bg-transparent text-muted-foreground hover:text-foreground hover:border-foreground/30'
107                    }`}
108                  >
109                    {t.label}
110                  </button>
111                ))}
112              </div>
113            </div>
114  
115            <div className="flex items-center justify-between gap-2">
116              <div className="flex flex-col gap-0.5">
117                <label className="text-sm font-medium">Enable AI tools</label>
118                <p className="text-xs text-muted-foreground">
119                  Allow the AI to search the web and fetch GitHub context (slower but more thorough)
120                </p>
121              </div>
122              <button
123                type="button"
124                role="switch"
125                aria-checked={enableTools}
126                onClick={() => {
127                  const next = !enableTools;
128                  setEnableTools(next);
129                  saveField({ enableTools: next });
130                }}
131                className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
132                  enableTools ? 'bg-primary' : 'bg-muted'
133                }`}
134              >
135                <span
136                  className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
137                    enableTools ? 'translate-x-4' : 'translate-x-0'
138                  }`}
139                />
140              </button>
141            </div>
142  
143            <div className="flex items-center justify-between gap-2">
144              <div className="flex flex-col gap-0.5">
145                <label className="text-sm font-medium">Auto-review when assigned</label>
146                <p className="text-xs text-muted-foreground">
147                  Automatically run an AI review when you are added as a reviewer on a PR. You will get a notification when it&apos;s ready.
148                </p>
149              </div>
150              <button
151                type="button"
152                role="switch"
153                aria-checked={autoReviewOnRequest}
154                onClick={() => {
155                  const next = !autoReviewOnRequest;
156                  setAutoReviewOnRequest(next);
157                  saveField({ autoReviewOnRequest: next });
158                }}
159                className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
160                  autoReviewOnRequest ? 'bg-primary' : 'bg-muted'
161                }`}
162              >
163                <span
164                  className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
165                    autoReviewOnRequest ? 'translate-x-4' : 'translate-x-0'
166                  }`}
167                />
168              </button>
169            </div>
170  
171            <div className="flex items-center justify-between gap-2">
172              <div className="flex flex-col gap-0.5">
173                <label className="text-sm font-medium">Desktop notifications</label>
174                <p className="text-xs text-muted-foreground">Notify when a review completes</p>
175              </div>
176              <button
177                type="button"
178                role="switch"
179                aria-checked={notifications}
180                onClick={() => {
181                  const next = !notifications;
182                  setNotifications(next);
183                  saveField({ notifications: next });
184                }}
185                className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
186                  notifications ? 'bg-primary' : 'bg-muted'
187                }`}
188              >
189                <span
190                  className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
191                    notifications ? 'translate-x-4' : 'translate-x-0'
192                  }`}
193                />
194              </button>
195            </div>
196  
197            <div className="flex flex-col gap-1.5">
198              <label className="text-sm font-medium">Claude CLI path</label>
199              <input
200                type="text"
201                value={claudePath}
202                placeholder={claudeDetected || 'auto-detect'}
203                onChange={(e) => setClaudePath(e.target.value)}
204                onBlur={() => saveField({ claudePath })}
205                className="rounded-md border border-input bg-transparent px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
206              />
207              <p className="text-xs text-muted-foreground">Leave empty to auto-detect</p>
208            </div>
209  
210            <div className="flex flex-col gap-1.5">
211              <label className="text-sm font-medium">Gemini CLI path</label>
212              <input
213                type="text"
214                value={geminiPath}
215                placeholder={geminiDetected || 'auto-detect'}
216                onChange={(e) => setGeminiPath(e.target.value)}
217                onBlur={() => saveField({ geminiPath })}
218                className="rounded-md border border-input bg-transparent px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
219              />
220              <p className="text-xs text-muted-foreground">Leave empty to auto-detect</p>
221            </div>
222  
223            <div className="border-t border-border pt-4">
224              <Button
225                variant="outline"
226                size="sm"
227                className="gap-1.5"
228                onClick={() => void window.electronAPI.openLogsDirectory()}
229              >
230                <FolderOpen className="h-3.5 w-3.5" />
231                Open logs
232              </Button>
233            </div>
234          </div>
235        </DialogContent>
236      </Dialog>
237    );
238  }