diff --git a/bun_client/frontend/src/App.tsx b/bun_client/frontend/src/App.tsx index f159c18..f3e261d 100644 --- a/bun_client/frontend/src/App.tsx +++ b/bun_client/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import PRList from './components/PRList'; import Review from './components/Review'; import PluginOutput from './components/PluginOutput'; +import ConfigManager from './components/ConfigManager'; import { rpcCall } from './api'; import { useIsMobile } from './hooks/useMediaQuery'; import { @@ -29,6 +30,7 @@ function App() { const [view, setView] = useState<'LIST' | 'REVIEW' | 'PLUGIN_OUTPUT'>('LIST'); const [currentPR, setCurrentPR] = useState(null); const [showPrefs, setShowPrefs] = useState(false); + const [prefsTab, setPrefsTab] = useState<'appearance' | 'server'>('appearance'); const [navigating, setNavigating] = useState(false); const isMobile = useIsMobile(); const [theme, setTheme] = useState(() => { @@ -343,11 +345,51 @@ function App() { isOpen={showPrefs} onClose={() => setShowPrefs(false)} title="Preferences" - size="sm" + size="lg" >
+ {( + [ + ['appearance', 'Appearance'], + ['server', 'Server Configuration'], + ] as const + ).map(([tab, label]) => ( + + ))} +
+ + {prefsTab === 'server' && } + +
= { 'RPCHandler.GetPluginOutput': '/api/get-plugin-output', 'RPCHandler.RerunPlugins': '/api/rerun-plugins', 'RPCHandler.GetHunkContext': '/api/get-hunk-context', + 'RPCHandler.GetConfig': '/api/get-config', + 'RPCHandler.UpdateConfig': '/api/update-config', }; export interface GetHunkContextArgs { @@ -53,6 +57,40 @@ export async function getHunkContext(args: GetHunkContextArgs): Promise('RPCHandler.GetHunkContext', [args]); } +/** + * Fetches the server's TOML configuration along with the workflow type and + * filter registries the config editor builds its pickers from. + */ +export async function getConfig(): Promise { + return rpcCall('RPCHandler.GetConfig', [{}]); +} + +/** + * Saves a partial configuration change. Fields left out keep whatever is on + * disk; sending `Workflows` replaces the whole list. + * + * A config the server rejects comes back as `okay: false` with `errors` + * populated rather than as a thrown error — the file is left untouched. + */ +export async function updateConfig(args: UpdateConfigArgs): Promise { + return rpcCall('RPCHandler.UpdateConfig', [args]); +} + +export interface UpdateConfigArgs { + Repos?: string[]; + SleepDuration?: number; + JiraDomain?: string; + GithubUsername?: string; + RepoLocation?: string; + AutoWorktree?: boolean; + DesktopNotifications?: boolean; + SectionPriority?: Record; + SectionSorting?: Record; + Workflows?: WorkflowEntry[]; + ExperimentalLLMFileOrdering?: boolean; + ExperimentalLLMReviewEase?: boolean; +} + export async function rpcCall(method: string, params: any[]): Promise { const id = Date.now(); const specializedPath = SPECIALIZED_ENDPOINTS[method]; diff --git a/bun_client/frontend/src/components/ConfigManager.tsx b/bun_client/frontend/src/components/ConfigManager.tsx new file mode 100644 index 0000000..7bd96a2 --- /dev/null +++ b/bun_client/frontend/src/components/ConfigManager.tsx @@ -0,0 +1,841 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { MouseEvent, ReactNode } from 'react'; +import { getConfig, updateConfig } from '../api'; +import { + cleanDraft, + draftFromConfig, + emptyWorkflow, + joinFilter, + joinList, + problemsByWorkflow, + splitFilter, + splitList, + validateDraft, + NOTIFICATION_OPTIONS, + PR_STATE_OPTIONS, +} from '../config_utils'; +import type { + ConfigDraft, + ConfigValidationError, + FilterInfo, + WorkflowEntry, + WorkflowTypeInfo, +} from '../config_utils'; +import { + Button, + Input, + Select, + TextArea, + colors, + spacing, + borderRadius, + fontSize, +} from '../design'; + +/** + * Editor for the server's TOML configuration (~/.config/codereviewserver.toml). + * + * The workflow type and filter pickers are built from the registries the + * server sends with the config, so they always offer what this server can + * actually run. Validation happens twice: here for immediate feedback, and + * again on the server, which refuses to write a config it can't run. + */ +export default function ConfigManager() { + const [draft, setDraft] = useState(null); + const [saved, setSaved] = useState(null); + const [workflowTypes, setWorkflowTypes] = useState([]); + const [filters, setFilters] = useState([]); + const [path, setPath] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [loadError, setLoadError] = useState(''); + const [serverProblems, setServerProblems] = useState([]); + const [status, setStatus] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null); + // Validation messages stay hidden until the first save attempt, so a + // half-filled new workflow isn't shouting at the user while they type. + const [showProblems, setShowProblems] = useState(false); + const [expanded, setExpanded] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(''); + try { + const reply = await getConfig(); + const loaded = draftFromConfig(reply.config); + setDraft(loaded); + setSaved(loaded); + setWorkflowTypes(reply.workflow_types ?? []); + setFilters(reply.filters ?? []); + setPath(reply.path); + setServerProblems([]); + setShowProblems(false); + setStatus(reply.okay ? null : { kind: 'error', text: reply.message }); + } catch (e: unknown) { + setLoadError(errorMessage(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const clientProblems = useMemo( + () => (draft ? validateDraft(draft, workflowTypes, filters) : []), + [draft, workflowTypes, filters] + ); + // Server problems are cleared as soon as the draft changes, so a stale + // rejection doesn't linger next to a field the user already fixed. + const problems = clientProblems.length > 0 ? clientProblems : serverProblems; + const grouped = useMemo(() => problemsByWorkflow(problems), [problems]); + const dirty = useMemo( + () => !!draft && !!saved && JSON.stringify(draft) !== JSON.stringify(saved), + [draft, saved] + ); + + const updateDraft = (change: Partial) => { + setDraft(current => (current ? { ...current, ...change } : current)); + setServerProblems([]); + setStatus(null); + }; + + const updateWorkflow = (index: number, change: Partial) => { + setDraft(current => { + if (!current) return current; + const workflows = current.Workflows.map((workflow, i) => + i === index ? { ...workflow, ...change } : workflow + ); + return { ...current, Workflows: workflows }; + }); + setServerProblems([]); + setStatus(null); + }; + + const addWorkflow = () => { + if (!draft) return; + const next = [...draft.Workflows, emptyWorkflow(workflowTypes)]; + updateDraft({ Workflows: next }); + setExpanded(next.length - 1); + }; + + const removeWorkflow = (index: number) => { + if (!draft) return; + const workflow = draft.Workflows[index]; + const label = workflow.Name?.trim() || 'this workflow'; + if (!window.confirm(`Remove ${label}? Its PRs drop out of the list on the next sync.`)) { + return; + } + updateDraft({ Workflows: draft.Workflows.filter((_, i) => i !== index) }); + setExpanded(null); + }; + + const moveWorkflow = (index: number, delta: number) => { + if (!draft) return; + const target = index + delta; + if (target < 0 || target >= draft.Workflows.length) return; + const workflows = [...draft.Workflows]; + [workflows[index], workflows[target]] = [workflows[target], workflows[index]]; + updateDraft({ Workflows: workflows }); + setExpanded(target); + }; + + const save = async () => { + if (!draft) return; + setShowProblems(true); + if (clientProblems.length > 0) { + setStatus({ + kind: 'error', + text: `Fix ${clientProblems.length} problem${clientProblems.length === 1 ? '' : 's'} before saving.`, + }); + return; + } + + setSaving(true); + setStatus(null); + try { + const payload = cleanDraft(draft); + const reply = await updateConfig({ + Repos: payload.Repos, + SleepDuration: payload.SleepDuration, + GithubUsername: payload.GithubUsername, + RepoLocation: payload.RepoLocation, + JiraDomain: payload.JiraDomain, + AutoWorktree: payload.AutoWorktree, + DesktopNotifications: payload.DesktopNotifications, + Workflows: payload.Workflows, + }); + if (!reply.okay) { + setServerProblems(reply.errors ?? []); + setStatus({ + kind: 'error', + text: reply.message || 'The server rejected the configuration.', + }); + return; + } + const applied = draftFromConfig(reply.config); + setDraft(applied); + setSaved(applied); + setServerProblems([]); + setPath(reply.path); + setStatus({ + kind: 'ok', + text: `${reply.message}. Workflow changes take effect on the next sync.`, + }); + } catch (e: unknown) { + setStatus({ kind: 'error', text: errorMessage(e) }); + } finally { + setSaving(false); + } + }; + + if (loading) { + return
Loading configuration…
; + } + if (loadError || !draft) { + return ( +
+
+ Could not load the configuration: {loadError} +
+
+ +
+
+ ); + } + + const globalProblems = showProblems ? (grouped.get(-1) ?? []) : []; + + return ( +
+
+ Editing {path}. The previous file is kept alongside it as{' '} + .bak; comments in the file are not preserved. +
+ +
+ Global Settings +