312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { IconTerminal2 } from "@tabler/icons-react";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { Label } from "@kandev/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectLabel,
|
|
SelectSeparator,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@kandev/ui/select";
|
|
import { Separator } from "@kandev/ui/separator";
|
|
import { SettingsSection } from "@/components/settings/settings-section";
|
|
import { ShellSettingsCard } from "@/components/settings/shell-settings-card";
|
|
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
|
import { updateUserSettings } from "@/lib/api";
|
|
import { TERMINAL_FONT_PRESETS } from "@/lib/terminal/terminal-font";
|
|
import type { FontCategory } from "@/lib/terminal/terminal-font";
|
|
|
|
const CUSTOM_VALUE = "__custom__";
|
|
const CATEGORY_LABELS: Record<FontCategory, string> = {
|
|
icons: "Nerd Fonts",
|
|
ligatures: "Programming",
|
|
system: "System",
|
|
};
|
|
const CATEGORY_BADGES: Partial<Record<FontCategory, string>> = {
|
|
icons: "Icons",
|
|
ligatures: "Ligatures",
|
|
};
|
|
const FONT_GROUPS: Record<string, typeof TERMINAL_FONT_PRESETS> = TERMINAL_FONT_PRESETS.reduce(
|
|
(acc, p) => {
|
|
(acc[p.category] ??= []).push(p);
|
|
return acc;
|
|
},
|
|
{} as Record<string, typeof TERMINAL_FONT_PRESETS>,
|
|
);
|
|
const FONT_CATEGORIES: FontCategory[] = ["icons", "ligatures", "system"];
|
|
|
|
export function normalizeTerminalFontSize(value: number, fallback: number): number {
|
|
const base = Number.isFinite(value) ? value : fallback;
|
|
return Math.min(24, Math.max(8, base));
|
|
}
|
|
|
|
function FontGroupOptions() {
|
|
return FONT_CATEGORIES.map((category) => (
|
|
<SelectGroup key={category}>
|
|
<SelectLabel className="flex items-center gap-2">
|
|
{CATEGORY_LABELS[category]}
|
|
{CATEGORY_BADGES[category] && (
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
|
{CATEGORY_BADGES[category]}
|
|
</Badge>
|
|
)}
|
|
</SelectLabel>
|
|
{(FONT_GROUPS[category] ?? []).map((preset) => (
|
|
<SelectItem key={preset.value} value={preset.value}>
|
|
{preset.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
));
|
|
}
|
|
|
|
function TerminalFontSizeCard() {
|
|
const storeApi = useAppStoreApi();
|
|
const userSettings = useAppStore((state) => state.userSettings);
|
|
const setUserSettings = useAppStore((state) => state.setUserSettings);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [fontSize, setFontSize] = useState(() => userSettings.terminalFontSize ?? 13);
|
|
|
|
const saveFontSize = async (value: number) => {
|
|
if (isSaving) return;
|
|
if (!Number.isFinite(value)) return;
|
|
if (value < 8 || value > 24) return;
|
|
setIsSaving(true);
|
|
const current = storeApi.getState().userSettings;
|
|
const previous = current.terminalFontSize;
|
|
try {
|
|
setUserSettings({ ...current, terminalFontSize: value });
|
|
await updateUserSettings({
|
|
workspace_id: current.workspaceId || "",
|
|
repository_ids: current.repositoryIds || [],
|
|
terminal_font_size: value,
|
|
});
|
|
} catch {
|
|
const latest = storeApi.getState().userSettings;
|
|
if (latest.workspaceId === current.workspaceId && latest.terminalFontSize === value) {
|
|
setUserSettings({ ...latest, terminalFontSize: previous });
|
|
}
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleFontSizeBlur = () => {
|
|
const value = normalizeTerminalFontSize(fontSize, userSettings.terminalFontSize ?? 13);
|
|
setFontSize(value);
|
|
void saveFontSize(value);
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Terminal Font Size</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="terminal-font-size">Font Size</Label>
|
|
<div className="flex items-center gap-3">
|
|
<Input
|
|
id="terminal-font-size"
|
|
type="number"
|
|
min={8}
|
|
max={24}
|
|
value={fontSize}
|
|
onChange={(e) => setFontSize(Number(e.target.value))}
|
|
onBlur={handleFontSizeBlur}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleFontSizeBlur();
|
|
}}
|
|
className="w-20"
|
|
disabled={isSaving}
|
|
data-testid="terminal-font-size-input"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">px (8-24)</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Set the font size for the terminal. Default is 13px.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function TerminalFontCard() {
|
|
const storeApi = useAppStoreApi();
|
|
const userSettings = useAppStore((state) => state.userSettings);
|
|
const setUserSettings = useAppStore((state) => state.setUserSettings);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isCustom, setIsCustom] = useState(() => {
|
|
const current = userSettings.terminalFontFamily;
|
|
if (!current) return false;
|
|
return !TERMINAL_FONT_PRESETS.some((p) => p.value === current);
|
|
});
|
|
const [customValue, setCustomValue] = useState(
|
|
() => (isCustom ? userSettings.terminalFontFamily : "") ?? "",
|
|
);
|
|
|
|
const saveFontFamily = async (value: string) => {
|
|
if (isSaving) return;
|
|
setIsSaving(true);
|
|
const current = storeApi.getState().userSettings;
|
|
const previous = current.terminalFontFamily;
|
|
const nextValue = value || null;
|
|
try {
|
|
setUserSettings({ ...current, terminalFontFamily: nextValue });
|
|
await updateUserSettings({
|
|
workspace_id: current.workspaceId || "",
|
|
repository_ids: current.repositoryIds || [],
|
|
terminal_font_family: value,
|
|
});
|
|
} catch {
|
|
const latest = storeApi.getState().userSettings;
|
|
if (latest.workspaceId === current.workspaceId && latest.terminalFontFamily === nextValue) {
|
|
setUserSettings({ ...latest, terminalFontFamily: previous });
|
|
}
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleSelectChange = (value: string) => {
|
|
if (value === CUSTOM_VALUE) {
|
|
setIsCustom(true);
|
|
return;
|
|
}
|
|
setIsCustom(false);
|
|
setCustomValue("");
|
|
void saveFontFamily(value === "default" ? "" : value);
|
|
};
|
|
|
|
const handleCustomBlur = () => {
|
|
const trimmed = customValue.trim();
|
|
if (trimmed) void saveFontFamily(trimmed);
|
|
};
|
|
|
|
const selectValue = isCustom ? CUSTOM_VALUE : userSettings.terminalFontFamily || "default";
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Terminal Font</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
<Label htmlFor="terminal-font">Font Family</Label>
|
|
<Select value={selectValue} onValueChange={handleSelectChange} disabled={isSaving}>
|
|
<SelectTrigger id="terminal-font" data-testid="terminal-font-select">
|
|
<SelectValue placeholder="Default" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="default">Default (Menlo / Monaco)</SelectItem>
|
|
<FontGroupOptions />
|
|
<SelectSeparator />
|
|
<SelectItem value={CUSTOM_VALUE}>Custom...</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{isCustom && (
|
|
<Input
|
|
placeholder='e.g. "My Custom Font"'
|
|
value={customValue}
|
|
onChange={(e) => setCustomValue(e.target.value)}
|
|
onBlur={handleCustomBlur}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleCustomBlur();
|
|
}}
|
|
data-testid="terminal-font-custom-input"
|
|
/>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
Choose a monospace font for the terminal. Nerd Fonts include icons for CLI tools.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function TerminalLinksCard() {
|
|
const userSettings = useAppStore((state) => state.userSettings);
|
|
const setUserSettings = useAppStore((state) => state.setUserSettings);
|
|
const storeApi = useAppStoreApi();
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const handleChange = async (value: "new_tab" | "browser_panel") => {
|
|
if (isSaving) return;
|
|
setIsSaving(true);
|
|
const current = storeApi.getState().userSettings;
|
|
const previous = current.terminalLinkBehavior;
|
|
try {
|
|
setUserSettings({ ...current, terminalLinkBehavior: value });
|
|
await updateUserSettings({
|
|
workspace_id: current.workspaceId || "",
|
|
repository_ids: current.repositoryIds || [],
|
|
terminal_link_behavior: value,
|
|
});
|
|
} catch {
|
|
const latest = storeApi.getState().userSettings;
|
|
if (latest.workspaceId === current.workspaceId && latest.terminalLinkBehavior === value) {
|
|
setUserSettings({ ...latest, terminalLinkBehavior: previous });
|
|
}
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Terminal Links</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="terminal-link-behavior">Open links in</Label>
|
|
<Select
|
|
value={userSettings.terminalLinkBehavior}
|
|
onValueChange={(v) => void handleChange(v as "new_tab" | "browser_panel")}
|
|
disabled={isSaving}
|
|
>
|
|
<SelectTrigger id="terminal-link-behavior">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="new_tab">New browser tab</SelectItem>
|
|
<SelectItem value="browser_panel">Built-in browser panel</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">Click a URL in the terminal to open it.</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function TerminalSettings() {
|
|
return (
|
|
<div className="space-y-8">
|
|
<ShellSettingsCard />
|
|
|
|
<Separator />
|
|
|
|
<SettingsSection
|
|
icon={<IconTerminal2 className="h-5 w-5" />}
|
|
title="Terminal"
|
|
description="Configure terminal appearance and behavior"
|
|
>
|
|
<TerminalFontCard />
|
|
<TerminalFontSizeCard />
|
|
<TerminalLinksCard />
|
|
</SettingsSection>
|
|
</div>
|
|
);
|
|
}
|