Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
268 lines
9.2 KiB
TypeScript
268 lines
9.2 KiB
TypeScript
import React from "react";
|
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
|
import { settingsAnchorDomId } from "../../domain/settingsSearchCatalog";
|
|
import { cn } from "../../lib/utils";
|
|
import { TabsContent } from "../ui/tabs";
|
|
|
|
interface ToggleProps {
|
|
checked: boolean;
|
|
onChange: (checked: boolean) => void;
|
|
disabled?: boolean;
|
|
ariaLabel?: string;
|
|
}
|
|
|
|
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled, ariaLabel }) => (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
aria-label={ariaLabel}
|
|
disabled={disabled}
|
|
onClick={() => onChange(!checked)}
|
|
className={cn(
|
|
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
checked ? "bg-primary" : "bg-input",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
|
checked ? "translate-x-4" : "translate-x-0",
|
|
)}
|
|
/>
|
|
</button>
|
|
);
|
|
|
|
interface SelectProps {
|
|
value: string;
|
|
options: { value: string; label: string; icon?: React.ReactNode }[];
|
|
onChange: (value: string) => void;
|
|
className?: string;
|
|
disabled?: boolean;
|
|
placeholder?: string;
|
|
}
|
|
|
|
export const Select: React.FC<SelectProps> = ({
|
|
value,
|
|
options,
|
|
onChange,
|
|
className,
|
|
disabled,
|
|
placeholder,
|
|
}) => {
|
|
const selectedOption = options.find((opt) => opt.value === value);
|
|
const fitSelectedText = typeof className !== "string" || !className.includes("w-full");
|
|
return (
|
|
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
|
|
<SelectPrimitive.Trigger
|
|
className={cn(
|
|
"flex h-9 max-w-full items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:min-w-0 [&>span]:truncate [&>span]:whitespace-nowrap",
|
|
fitSelectedText && "min-w-max",
|
|
className,
|
|
)}
|
|
>
|
|
<SelectPrimitive.Value placeholder={placeholder}>
|
|
<span className="flex min-w-0 items-center gap-2 truncate whitespace-nowrap">
|
|
{selectedOption?.icon}
|
|
<span className="truncate whitespace-nowrap">{selectedOption?.label}</span>
|
|
</span>
|
|
</SelectPrimitive.Value>
|
|
<SelectPrimitive.Icon asChild>
|
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</SelectPrimitive.Icon>
|
|
</SelectPrimitive.Trigger>
|
|
<SelectPrimitive.Portal>
|
|
<SelectPrimitive.Content
|
|
className="z-[200000] max-h-80 w-max max-w-[min(24rem,var(--radix-select-content-available-width))] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
|
|
position="popper"
|
|
sideOffset={4}
|
|
style={{ minWidth: "max(12rem, var(--radix-select-trigger-width))" }}
|
|
>
|
|
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
|
<ChevronUp className="h-4 w-4" />
|
|
</SelectPrimitive.ScrollUpButton>
|
|
<SelectPrimitive.Viewport className="p-1">
|
|
{options.map((opt) => (
|
|
<SelectPrimitive.Item
|
|
key={opt.value}
|
|
value={opt.value}
|
|
className="relative flex w-full min-w-0 cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
|
>
|
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
<SelectPrimitive.ItemIndicator>
|
|
<Check className="h-4 w-4" />
|
|
</SelectPrimitive.ItemIndicator>
|
|
</span>
|
|
<SelectPrimitive.ItemText>
|
|
<span className="flex min-w-0 items-center gap-2 whitespace-normal break-words leading-snug">
|
|
{opt.icon}
|
|
{opt.label}
|
|
</span>
|
|
</SelectPrimitive.ItemText>
|
|
</SelectPrimitive.Item>
|
|
))}
|
|
</SelectPrimitive.Viewport>
|
|
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
|
<ChevronDown className="h-4 w-4" />
|
|
</SelectPrimitive.ScrollDownButton>
|
|
</SelectPrimitive.Content>
|
|
</SelectPrimitive.Portal>
|
|
</SelectPrimitive.Root>
|
|
);
|
|
};
|
|
|
|
export const SectionHeader: React.FC<{
|
|
title: string;
|
|
className?: string;
|
|
anchorId?: string;
|
|
}> = ({ title, className, anchorId }) => (
|
|
<h3
|
|
id={anchorId ? settingsAnchorDomId(anchorId) : undefined}
|
|
data-settings-anchor={anchorId}
|
|
className={cn("text-sm font-semibold text-foreground mb-3 rounded-md", className)}
|
|
>
|
|
{title}
|
|
</h3>
|
|
);
|
|
|
|
/** Section title row → content gap (shared across settings pages). */
|
|
export const settingsSectionGapClassName = "gap-2";
|
|
|
|
/** Groups a section title (optional icon/actions) with its content at a uniform gap. */
|
|
export const SettingsSection: React.FC<{
|
|
title?: string;
|
|
leading?: React.ReactNode;
|
|
actions?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
anchorId?: string;
|
|
}> = ({ title, leading, actions, children, className, anchorId }) => (
|
|
<section
|
|
id={anchorId ? settingsAnchorDomId(anchorId) : undefined}
|
|
data-settings-anchor={anchorId}
|
|
className={cn("flex flex-col rounded-md", settingsSectionGapClassName, className)}
|
|
>
|
|
{(title || leading || actions) && (
|
|
<div
|
|
className={cn(
|
|
"flex min-h-8 items-center gap-2",
|
|
actions && "justify-between gap-4",
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
{leading}
|
|
{title ? <h3 className="text-sm font-semibold text-foreground">{title}</h3> : null}
|
|
</div>
|
|
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
|
</div>
|
|
)}
|
|
{children}
|
|
</section>
|
|
);
|
|
|
|
/** Footer note under a SettingCard. Keep a gap so it does not kiss the card. */
|
|
export const settingHintClassName = "mt-3 text-xs text-muted-foreground";
|
|
|
|
export const SettingHint: React.FC<{
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}> = ({ children, className }) => (
|
|
<p className={cn(settingHintClassName, className)}>{children}</p>
|
|
);
|
|
|
|
export const settingCardClassName = "rounded-lg border bg-card";
|
|
|
|
interface SettingCardProps {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
/** Row list with dividers; vertical spacing comes from SettingRow. */
|
|
divided?: boolean;
|
|
/** Free-form content; apply even padding on all sides. */
|
|
padded?: boolean;
|
|
}
|
|
|
|
export const SettingCard: React.FC<SettingCardProps> = ({
|
|
children,
|
|
className,
|
|
divided = false,
|
|
padded = false,
|
|
}) => (
|
|
<div
|
|
className={cn(
|
|
settingCardClassName,
|
|
padded ? "p-4" : "px-4",
|
|
divided && "space-y-0 divide-y divide-border",
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
interface SettingRowProps {
|
|
label?: string;
|
|
description?: string;
|
|
children: React.ReactNode;
|
|
align?: "center" | "start";
|
|
/** Stable catalog id for settings search jump targets. */
|
|
anchorId?: string;
|
|
}
|
|
|
|
export const SettingRow: React.FC<SettingRowProps> = ({
|
|
label,
|
|
description,
|
|
children,
|
|
align = "center",
|
|
anchorId,
|
|
}) => (
|
|
<div
|
|
id={anchorId ? settingsAnchorDomId(anchorId) : undefined}
|
|
data-settings-anchor={anchorId}
|
|
className={cn(
|
|
// Keep square corners: rounded rows bend divide-y separators at both ends.
|
|
"flex justify-between py-3 gap-4",
|
|
align === "start" ? "items-start" : "items-center",
|
|
)}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
{label && <div className="text-sm font-medium">{label}</div>}
|
|
{description && (
|
|
<div className={cn("text-xs text-muted-foreground", label && "mt-0.5")}>{description}</div>
|
|
)}
|
|
</div>
|
|
<div className="shrink-0">{children}</div>
|
|
</div>
|
|
);
|
|
|
|
/** Lightweight wrapper for non-SettingRow search targets. */
|
|
export const SettingsAnchor: React.FC<{
|
|
anchorId: string;
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}> = ({ anchorId, className, children }) => (
|
|
<div
|
|
id={settingsAnchorDomId(anchorId)}
|
|
data-settings-anchor={anchorId}
|
|
// No default rounded-md: inside divide-y cards, child border-radius bends separators.
|
|
className={className}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
export const SettingsTabContent: React.FC<{
|
|
value: string;
|
|
children: React.ReactNode;
|
|
}> = ({ value, children }) => (
|
|
<TabsContent value={value} className="flex-1 m-0 h-full overflow-hidden">
|
|
{/* data-settings-scroll-pane: search jump scrolls only this pane, not the window */}
|
|
<div
|
|
data-settings-scroll-pane
|
|
className="h-full overflow-y-auto overflow-x-hidden"
|
|
>
|
|
<div className="p-6 space-y-6">{children}</div>
|
|
</div>
|
|
</TabsContent>
|
|
);
|