From ed7e7cd8f558234f42badb02d4e3951680f0e290 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 15 Sep 2026 03:07:45 +0530 Subject: [PATCH 1/9] feat(local): pick a base URL from common endpoints when adding a connection --- .../frontend/src/components/ui/combobox.tsx | 431 ++++++++++++++++++ .../src/components/ui/input-group.tsx | 136 ++++++ .../model-selection/connection-form.tsx | 78 +++- 3 files changed, 640 insertions(+), 5 deletions(-) create mode 100644 surfsense_local/frontend/src/components/ui/combobox.tsx create mode 100644 surfsense_local/frontend/src/components/ui/input-group.tsx diff --git a/surfsense_local/frontend/src/components/ui/combobox.tsx b/surfsense_local/frontend/src/components/ui/combobox.tsx new file mode 100644 index 0000000000..fcef3a5f8b --- /dev/null +++ b/surfsense_local/frontend/src/components/ui/combobox.tsx @@ -0,0 +1,431 @@ +import * as React from "react" +import { Popover as PopoverPrimitive } from "radix-ui" + +import { CheckIcon, ChevronDownIcon } from "@/components/ui/icons" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group" +import { cn } from "@/lib/utils" + +// shadcn ships its Combobox on Base UI, which this app does not depend on, so +// the same API is rebuilt here on the Radix Popover the rest of the UI uses. +type ComboboxFilter = ( + itemValue: string, + query: string, + keywords: string[] +) => boolean + +const defaultFilter: ComboboxFilter = (itemValue, query, keywords) => { + const needle = query.trim().toLowerCase() + if (!needle) return true + return [itemValue, ...keywords].some((candidate) => + candidate.toLowerCase().includes(needle) + ) +} + +type ComboboxContextValue = { + open: boolean + setOpen: (open: boolean) => void + value: string | null + select: (value: string) => void + inputValue: string + setInputValue: (value: string) => void + disabled: boolean + matches: (itemValue: string, keywords: string[]) => boolean + register: (itemValue: string, keywords: string[]) => () => void + visibleCount: number + listId: string + activeId: string | null + setActiveId: (id: string | null) => void + itemId: (itemValue: string) => string + listRef: React.RefObject +} + +const ComboboxContext = React.createContext(null) + +function useCombobox(part: string) { + const context = React.useContext(ComboboxContext) + if (!context) { + throw new Error(`${part} must be used within a Combobox`) + } + return context +} + +function Combobox({ + children, + value, + onValueChange, + inputValue, + onInputValueChange, + open: openProp, + onOpenChange, + filter = defaultFilter, + disabled = false, +}: { + children: React.ReactNode + value?: string | null + onValueChange?: (value: string) => void + inputValue: string + onInputValueChange: (value: string) => void + open?: boolean + onOpenChange?: (open: boolean) => void + filter?: ComboboxFilter + disabled?: boolean +}) { + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false) + const [activeId, setActiveId] = React.useState(null) + const [items, setItems] = React.useState>({}) + const listRef = React.useRef(null) + const listId = React.useId() + + const open = openProp ?? uncontrolledOpen + const setOpen = React.useCallback( + (next: boolean) => { + if (!next) setActiveId(null) + setUncontrolledOpen(next) + onOpenChange?.(next) + }, + [onOpenChange] + ) + + const matches = React.useCallback( + (itemValue: string, keywords: string[]) => + filter(itemValue, inputValue, keywords), + [filter, inputValue] + ) + + const register = React.useCallback( + (itemValue: string, keywords: string[]) => { + setItems((current) => ({ ...current, [itemValue]: keywords })) + return () => { + setItems((current) => { + const next = { ...current } + delete next[itemValue] + return next + }) + } + }, + [] + ) + + const itemId = React.useCallback( + (itemValue: string) => `${listId}-${encodeURIComponent(itemValue)}`, + [listId] + ) + + const select = React.useCallback( + (next: string) => { + onValueChange?.(next) + onInputValueChange(next) + setOpen(false) + }, + [onInputValueChange, onValueChange, setOpen] + ) + + const visibleCount = Object.entries(items).filter(([itemValue, keywords]) => + matches(itemValue, keywords) + ).length + + const context: ComboboxContextValue = { + open, + setOpen, + value: value ?? null, + select, + inputValue, + setInputValue: onInputValueChange, + disabled, + matches, + register, + visibleCount, + listId, + activeId, + setActiveId, + itemId, + listRef, + } + + return ( + + + {children} + + + ) +} + +function ComboboxInput({ + className, + showTrigger = true, + onKeyDown, + ...props +}: React.ComponentProps<"input"> & { showTrigger?: boolean }) { + const combobox = useCombobox("ComboboxInput") + const { open, setOpen, setActiveId, listRef, select, disabled } = combobox + + const move = (direction: 1 | -1) => { + const options = listRef.current?.querySelectorAll( + '[data-slot="combobox-item"]:not([hidden])' + ) + if (!options?.length) return + const current = [...options].findIndex( + (option) => option.id === combobox.activeId + ) + const next = + current === -1 + ? direction === 1 + ? 0 + : options.length - 1 + : (current + direction + options.length) % options.length + const option = options[next] + setActiveId(option.id) + option.scrollIntoView?.({ block: "nearest" }) + } + + return ( + + + { + combobox.setInputValue(event.target.value) + setActiveId(null) + if (!open) setOpen(true) + }} + onClick={() => { + if (!open && !disabled) setOpen(true) + }} + onKeyDown={(event) => { + onKeyDown?.(event) + if (event.defaultPrevented) return + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault() + if (!open) { + setOpen(true) + return + } + move(event.key === "ArrowDown" ? 1 : -1) + return + } + if (event.key === "Enter" && open && combobox.activeId) { + const option = listRef.current?.querySelector( + `#${CSS.escape(combobox.activeId)}` + ) + if (option?.dataset.value) { + event.preventDefault() + select(option.dataset.value) + } + return + } + if (event.key === "Escape" && open) { + event.preventDefault() + setOpen(false) + } + }} + {...props} + /> + {showTrigger ? ( + + + + + + + + ) : null} + + + ) +} + +function ComboboxContent({ + className, + align = "start", + sideOffset = 6, + container, + ...props +}: React.ComponentProps & { + // Inside a modal Dialog, pass a host element within the dialog: the dialog's + // scroll lock only lets wheel events through for targets it contains, so a + // popup portalled to the body cannot scroll its own list. + container?: React.ComponentProps["container"] +}) { + useCombobox("ComboboxContent") + return ( + + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + className={cn( + "z-50 max-h-(--radix-popover-content-available-height) w-(--radix-popover-trigger-width) origin-(--radix-popover-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", + className + )} + {...props} + /> + + ) +} + +function ComboboxList({ className, ...props }: React.ComponentProps<"div">) { + const { listRef, listId } = useCombobox("ComboboxList") + return ( +
+ ) +} + +function ComboboxGroup({ className, ...props }: React.ComponentProps<"div">) { + // Hidden wholesale when the query matches nothing, so its label does not + // caption an empty list. + const { visibleCount } = useCombobox("ComboboxGroup") + return ( +