From 603a4ce364714bb6a0ab71128ab917c93fca8a62 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 6 Nov 2026 10:37:42 -0700 Subject: [PATCH] Share Yaak's settings dialog (#817) Co-authored-by: Claude Opus 5.5 --- .../components/Settings/Settings.tsx | 170 +++---- .../components/Settings/SettingsGeneral.tsx | 170 +------ .../components/Settings/SettingsInterface.tsx | 195 +------ .../components/Settings/SettingsLicense.tsx | 180 +------ .../components/Settings/SettingsTheme.tsx | 168 +------ apps/yaak-client/components/core/Link.tsx | 60 ++- apps/yaak-client/components/core/Select.tsx | 150 +----- .../components/core/SettingRow.tsx | 475 +----------------- apps/yaak-client/hooks/useSettingsView.ts | 76 +++ apps/yaak-client/lib/settingsView.test.ts | 126 +++++ apps/yaak-client/lib/settingsView.ts | 84 ++++ apps/yaak-proxy/harness-settings.html | 212 ++++++++ package-lock.json | 1 + packages/ui/package.json | 1 + packages/ui/src/components/ExternalLink.tsx | 54 ++ packages/ui/src/components/Select.tsx | 149 ++++++ .../ui/src/components/settings/SettingRow.tsx | 457 +++++++++++++++++ .../components/settings/SettingsDialog.tsx | 110 ++++ .../components/settings/SettingsGeneral.tsx | 166 ++++++ .../components/settings/SettingsInterface.tsx | 201 ++++++++ .../components/settings/SettingsLicense.tsx | 204 ++++++++ .../src/components/settings/SettingsTheme.tsx | 173 +++++++ .../src/components/settings/settings.test.tsx | 296 +++++++++++ packages/ui/src/components/settings/types.ts | 124 +++++ packages/ui/src/index.ts | 51 +- packages/ui/src/lib/yaakUrl.test.ts | 26 + packages/ui/src/lib/yaakUrl.ts | 15 + 27 files changed, 2680 insertions(+), 1414 deletions(-) create mode 100644 apps/yaak-client/hooks/useSettingsView.ts create mode 100644 apps/yaak-client/lib/settingsView.test.ts create mode 100644 apps/yaak-client/lib/settingsView.ts create mode 100644 apps/yaak-proxy/harness-settings.html create mode 100644 packages/ui/src/components/ExternalLink.tsx create mode 100644 packages/ui/src/components/Select.tsx create mode 100644 packages/ui/src/components/settings/SettingRow.tsx create mode 100644 packages/ui/src/components/settings/SettingsDialog.tsx create mode 100644 packages/ui/src/components/settings/SettingsGeneral.tsx create mode 100644 packages/ui/src/components/settings/SettingsInterface.tsx create mode 100644 packages/ui/src/components/settings/SettingsLicense.tsx create mode 100644 packages/ui/src/components/settings/SettingsTheme.tsx create mode 100644 packages/ui/src/components/settings/settings.test.tsx create mode 100644 packages/ui/src/components/settings/types.ts create mode 100644 packages/ui/src/lib/yaakUrl.test.ts create mode 100644 packages/ui/src/lib/yaakUrl.ts diff --git a/apps/yaak-client/components/Settings/Settings.tsx b/apps/yaak-client/components/Settings/Settings.tsx index 1039ce74f..6ab3bed9d 100644 --- a/apps/yaak-client/components/Settings/Settings.tsx +++ b/apps/yaak-client/components/Settings/Settings.tsx @@ -1,13 +1,19 @@ import { platform } from "@yaakapp-internal/platform"; import { useLicense } from "@yaakapp-internal/license"; import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models"; -import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui"; -import classNames from "classnames"; +import { + CountBadge, + SETTINGS_TAB_GENERAL, + SETTINGS_TAB_INTERFACE, + SETTINGS_TAB_LICENSE, + SETTINGS_TAB_THEME, + SettingsDialog, + type SettingsTabDef, +} from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; import { useKeyPressEvent } from "react-use"; import { appInfo } from "../../lib/appInfo"; import { capitalize } from "../../lib/capitalize"; -import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs"; import { SettingsAgents } from "./SettingsAgents"; import { SettingsCertificates } from "./SettingsCertificates"; import { SettingsGeneral } from "./SettingsGeneral"; @@ -24,15 +30,15 @@ interface Props { hide?: () => void; } -const TAB_GENERAL = "general"; -const TAB_INTERFACE = "interface"; -const TAB_THEME = "theme"; +const TAB_GENERAL = SETTINGS_TAB_GENERAL; +const TAB_INTERFACE = SETTINGS_TAB_INTERFACE; +const TAB_THEME = SETTINGS_TAB_THEME; const TAB_SHORTCUTS = "shortcuts"; const TAB_PROXY = "proxy"; const TAB_CERTIFICATES = "certificates"; const TAB_PLUGINS = "plugins"; const TAB_AGENTS = "agents"; -const TAB_LICENSE = "license"; +const TAB_LICENSE = SETTINGS_TAB_LICENSE; const tabs = [ TAB_GENERAL, TAB_THEME, @@ -60,105 +66,57 @@ export default function Settings({ tab, hide }: Props) { if (hide == null) await platform.window.close(); }); + const tabDefs: Record> = { + [TAB_GENERAL]: { icon: "settings", content: }, + [TAB_THEME]: { icon: "palette", content: }, + [TAB_INTERFACE]: { icon: "columns_2", content: }, + [TAB_SHORTCUTS]: { icon: "keyboard", content: }, + [TAB_PLUGINS]: { + icon: "puzzle", + rightSlot: p.source !== "bundled").length} />, + content: , + contentClassName: "h-full grid grid-rows-1", + }, + [TAB_AGENTS]: { + icon: "sparkles", + hidden: !platform.capabilities.agents, + content: , + }, + [TAB_CERTIFICATES]: { + icon: "shield_check", + rightSlot: , + content: , + }, + [TAB_PROXY]: { + icon: "wifi", + rightSlot: settings.proxy?.type === "enabled" ? : null, + content: , + }, + [TAB_LICENSE]: { + icon: "key_round", + hidden: !appInfo.featureLicense, + rightSlot: + licenseCheck.check.data?.status === "personal_use" ? ( + + ) : null, + content: , + }, + }; + return ( -
- {hide ? ( - - ) : ( - - -
Settings
-
-
- )} - ({ - value, - label: capitalize(value), - hidden: - (!appInfo.featureLicense && value === TAB_LICENSE) || - (!platform.capabilities.agents && value === TAB_AGENTS), - leftSlot: - value === TAB_GENERAL ? ( - - ) : value === TAB_THEME ? ( - - ) : value === TAB_INTERFACE ? ( - - ) : value === TAB_SHORTCUTS ? ( - - ) : value === TAB_CERTIFICATES ? ( - - ) : value === TAB_PROXY ? ( - - ) : value === TAB_PLUGINS ? ( - - ) : value === TAB_AGENTS ? ( - - ) : value === TAB_LICENSE ? ( - - ) : null, - rightSlot: - value === TAB_CERTIFICATES ? ( - - ) : value === TAB_PLUGINS ? ( - p.source !== "bundled").length} /> - ) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? ( - - ) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? ( - - ) : null, - }), - )} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ ({ value, label: capitalize(value), ...tabDefs[value] }))} + /> ); } diff --git a/apps/yaak-client/components/Settings/SettingsGeneral.tsx b/apps/yaak-client/components/Settings/SettingsGeneral.tsx index d36758f9b..9b022b644 100644 --- a/apps/yaak-client/components/Settings/SettingsGeneral.tsx +++ b/apps/yaak-client/components/Settings/SettingsGeneral.tsx @@ -1,32 +1,17 @@ -import { patchModel, settingsAtom } from "@yaakapp-internal/models"; -import { Heading, VStack } from "@yaakapp-internal/ui"; +import { settingsAtom } from "@yaakapp-internal/models"; +import { SettingsGeneral as SharedSettingsGeneral } from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; -import { useCheckForUpdates } from "../../hooks/useCheckForUpdates"; -import { appInfo } from "../../lib/appInfo"; -import { revealInFinderText } from "../../lib/reveal"; -import { CargoFeature } from "../CargoFeature"; +import { useSettingsView } from "../../hooks/useSettingsView"; import { CommercialUseBanner } from "../CommercialUseBanner"; import { DismissibleBanner } from "../core/DismissibleBanner"; -import { IconButton } from "../core/IconButton"; -import { platform } from "@yaakapp-internal/platform"; -import { - ModelSettingRowBoolean, - ModelSettingSelectControl, - SettingValue, - SettingRow, - SettingRowBoolean, - SettingRowSelect, - SettingsList, - SettingsSection, -} from "../core/SettingRow"; const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30"; export function SettingsGeneral() { const settings = useAtomValue(settingsAtom); - const checkForUpdates = useCheckForUpdates(); + const bound = useSettingsView(); - if (settings == null) { + if (settings == null || bound == null) { return null; } @@ -34,147 +19,24 @@ export function SettingsGeneral() { settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT; return ( - -
- General -

- Configure general settings for update behavior and more. -

-
-
- -
- - - - -
- - checkForUpdates.mutateAsync()} - /> -
-
- - - patchModel(settings, { autoupdate: v === "auto" }) - } - options={[ - { label: "Automatic", value: "auto" }, - { label: "Manual", value: "manual" }, - ]} - /> - - - - - - {}} - /> -
-
- - - - patchModel(settings, { promptFeedback })} - /> - - - - {showWorkspaceSettingsMovedBanner && ( + } + extraSections={ + showWorkspaceSettingsMovedBanner && (

- Workspace specific settings have moved to{" "} - Workspace Settings, accessible from the workspace switcher - menu. + Workspace specific settings have moved to Workspace Settings, accessible from + the workspace switcher menu.

- )} - - - - - - - platform.revealItemInDir(appInfo.appDataDir), - }, - ]} - /> - - - platform.revealItemInDir(appInfo.appLogDir), - }, - ]} - /> - - -
-
+ ) + } + /> ); } diff --git a/apps/yaak-client/components/Settings/SettingsInterface.tsx b/apps/yaak-client/components/Settings/SettingsInterface.tsx index baffa2738..6952ab511 100644 --- a/apps/yaak-client/components/Settings/SettingsInterface.tsx +++ b/apps/yaak-client/components/Settings/SettingsInterface.tsx @@ -1,59 +1,30 @@ -import { platform } from "@yaakapp-internal/platform"; -import { useFonts } from "@yaakapp-internal/fonts"; import { useLicense } from "@yaakapp-internal/license"; -import type { EditorKeymap, Settings } from "@yaakapp-internal/models"; +import type { Settings } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models"; -import { clamp, Heading, VStack } from "@yaakapp-internal/ui"; +import { SettingsInterface as SharedSettingsInterface, VStack } from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; -import { useState } from "react"; import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace"; +import { useSettingsView } from "../../hooks/useSettingsView"; import { showConfirm } from "../../lib/confirm"; import { pricingUrl } from "../../lib/pricingUrl"; -import { rpc } from "../../lib/rpc"; import { CargoFeature } from "../CargoFeature"; -import { Button } from "../core/Button"; -import { Checkbox } from "../core/Checkbox"; import { Link } from "../core/Link"; -import { - ModelSettingRowBoolean, - ModelSettingRowSelect, - SettingRow, - SettingRowBoolean, - SettingRowSelect, - SettingSelectControl, - SettingsList, - SettingsSection, -} from "../core/SettingRow"; - -const NULL_FONT_VALUE = "__NULL_FONT__"; - -const fontSizeOptions = [ - 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, -].map((n) => ({ label: `${n}`, value: `${n}` })); - -const keymaps: { value: EditorKeymap; label: string }[] = [ - { value: "default", label: "Default" }, - { value: "vim", label: "Vim" }, - { value: "vscode", label: "VSCode" }, - { value: "emacs", label: "Emacs" }, -]; +import { SettingRowBoolean, SettingRowSelect, SettingsSection } from "../core/SettingRow"; export function SettingsInterface() { const workspace = useAtomValue(activeWorkspaceAtom); const settings = useAtomValue(settingsAtom); - const fonts = useFonts(); + const bound = useSettingsView(); - if (settings == null || workspace == null) { + if (settings == null || workspace == null || bound == null) { return null; } return ( - -
- Interface -

Tweak settings related to the user interface.

-
- + - - - - {fonts.data && ( - ({ label: f, value: f })), - ...fonts.data.editorFonts.map((f) => ({ label: f, value: f })), - ]} - onChange={async (v) => { - const interfaceFont = v === NULL_FONT_VALUE ? null : v; - await patchModel(settings, { interfaceFont }); - }} - /> - )} - patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })} - /> - - - - {fonts.data && ( - ({ label: f, value: f })), - ]} - onChange={async (v) => { - const editorFont = v === NULL_FONT_VALUE ? null : v; - await patchModel(settings, { editorFont }); - }} - /> - )} - - patchModel(settings, { - editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30), - }) - } - /> - - - - - - - - - - - - {platform.osType() !== "macos" && ( - - )} - - + } + trailingSections={ - -
- ); -} - -function NativeTitlebarSetting({ settings }: { settings: Settings }) { - const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar); - - return ( - - - {settings.useNativeTitlebar !== nativeTitlebar && ( - - )} - + } + /> ); } diff --git a/apps/yaak-client/components/Settings/SettingsLicense.tsx b/apps/yaak-client/components/Settings/SettingsLicense.tsx index 9e72d481c..3853bd21c 100644 --- a/apps/yaak-client/components/Settings/SettingsLicense.tsx +++ b/apps/yaak-client/components/Settings/SettingsLicense.tsx @@ -1,17 +1,6 @@ -import { useLicense } from "@yaakapp-internal/license"; -import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui"; -import { differenceInDays } from "date-fns"; -import { formatDate } from "date-fns/format"; -import { useState } from "react"; -import { useToggle } from "../../hooks/useToggle"; -import { pricingUrl } from "../../lib/pricingUrl"; +import { SettingsLicense as SharedSettingsLicense } from "@yaakapp-internal/ui"; +import { useSettingsView } from "../../hooks/useSettingsView"; import { CargoFeature } from "../CargoFeature"; -import { Button } from "../core/Button"; -import { Link } from "../core/Link"; -import { PlainInput } from "../core/PlainInput"; -import { Separator } from "../core/Separator"; -import { platform } from "@yaakapp-internal/platform"; -import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; export function SettingsLicense() { return ( @@ -22,166 +11,7 @@ export function SettingsLicense() { } function SettingsLicenseCmp() { - const { check, activate, deactivate } = useLicense(); - const [key, setKey] = useState(""); - const [activateFormVisible, toggleActivateFormVisible] = useToggle(false); - - if (check.isPending) { - return null; - } - - const renderBanner = () => { - if (!check.data) return null; - - switch (check.data.status) { - case "active": - return Your license is active 🥳; - - case "trialing": - return ( - -

- - {pluralizeCount("day", differenceInDays(check.data.data.end, new Date()))} - {" "} - left to evaluate Yaak for commercial use. -
- Personal use is always free, forever. - -

- - Learn More - -
-

-
- ); - - case "personal_use": - return ( - -

- Your commercial-use trial has ended. -
- - You may continue using Yaak for personal use only. -
A license is required for commercial use. -
- -

- - Learn More - -
-

-
- ); - - case "inactive": - return ( - - Your license is invalid. Please Sign In{" "} - for more details - - ); - - case "expired": - return ( - - Your license expired{" "} - {formatDate(check.data.data.periodEnd, "MMMM dd, yyyy")}. Please{" "} - Resubscribe to continue receiving - updates. - {check.data.data.changesUrl && ( - <> -
- What's new in latest builds - - )} -
- ); - - case "past_due": - return ( - - Your payment method needs attention. -
- To re-activate your license, please{" "} - update your billing info. -
- ); - - case "error": - return ( - - License check failed: {check.data.data.message} (Code: {check.data.data.code}) - - ); - } - }; - - return ( -
- {renderBanner()} - - {check.error && {check.error}} - {activate.error && {activate.error}} - - {check.data?.status === "active" ? ( - - - - - ) : ( - - - - - )} - - {activateFormVisible && ( - { - e.preventDefault(); - await activate.mutateAsync({ licenseKey: key }); - toggleActivateFormVisible(); - }} - > - - - - )} -
- ); + const bound = useSettingsView(); + if (bound == null) return null; + return ; } diff --git a/apps/yaak-client/components/Settings/SettingsTheme.tsx b/apps/yaak-client/components/Settings/SettingsTheme.tsx index 268109045..d71e3113e 100644 --- a/apps/yaak-client/components/Settings/SettingsTheme.tsx +++ b/apps/yaak-client/components/Settings/SettingsTheme.tsx @@ -1,173 +1,15 @@ -import { patchModel, settingsAtom } from "@yaakapp-internal/models"; -import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui"; +import { SettingsTheme as SharedSettingsTheme } from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; -import { lazy, Suspense } from "react"; import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace"; -import { useResolvedAppearance } from "../../hooks/useResolvedAppearance"; -import { useResolvedTheme } from "../../hooks/useResolvedTheme"; -import type { ButtonProps } from "../core/Button"; -import { IconButton } from "../core/IconButton"; -import { Link } from "../core/Link"; -import type { SelectProps } from "../core/Select"; -import { - ModelSettingRowSelect, - SettingRowSelect, - SettingsList, - SettingsSection, -} from "../core/SettingRow"; - -const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor }))); - -const buttonColors: ButtonProps["color"][] = [ - "primary", - "info", - "success", - "notice", - "warning", - "danger", - "secondary", - "default", -]; - -const icons: IconProps["icon"][] = [ - "info", - "box", - "update", - "alert_triangle", - "arrow_big_right_dash", - "download", - "copy", - "magic_wand", - "settings", - "trash", - "sparkles", - "pencil", - "paste", - "search", - "send_horizontal", -]; +import { useSettingsView } from "../../hooks/useSettingsView"; export function SettingsTheme() { const workspace = useAtomValue(activeWorkspaceAtom); - const settings = useAtomValue(settingsAtom); - const appearance = useResolvedAppearance(); - const activeTheme = useResolvedTheme(); + const bound = useSettingsView(); - if (settings == null || workspace == null || activeTheme.data == null) { + if (workspace == null || bound == null) { return null; } - const lightThemes: SelectProps["options"] = activeTheme.data.themes - .filter((theme) => !theme.dark) - .map((theme) => ({ - label: theme.label, - value: theme.id, - })); - - const darkThemes: SelectProps["options"] = activeTheme.data.themes - .filter((theme) => theme.dark) - .map((theme) => ({ - label: theme.label, - value: theme.id, - })); - - return ( - -
- Theme -

- Make Yaak your own by selecting a theme, or{" "} - - Create Your Own - -

-
- - - - {(settings.appearance === "system" || settings.appearance === "light") && ( - patchModel(settings, { themeLight })} - /> - )} - {(settings.appearance === "system" || settings.appearance === "dark") && ( - patchModel(settings, { themeDark })} - /> - )} - - - - - - - {activeTheme.data.active.label} - (preview) - - - {buttonColors.map((c, i) => ( - - ))} - {buttonColors.map((c, i) => ( - - ))} - - - - - - - -
- ); + return ; } diff --git a/apps/yaak-client/components/core/Link.tsx b/apps/yaak-client/components/core/Link.tsx index 297e205dd..46a42a1e4 100644 --- a/apps/yaak-client/components/core/Link.tsx +++ b/apps/yaak-client/components/core/Link.tsx @@ -3,16 +3,43 @@ import { platform } from "@yaakapp-internal/platform"; import classNames from "classnames"; import type { HTMLAttributes } from "react"; import { appInfo } from "../../lib/appInfo"; -import { Icon } from "@yaakapp-internal/ui"; +import { ExternalLink, isYaakUrl } from "@yaakapp-internal/ui"; interface Props extends HTMLAttributes { href: string; noUnderline?: boolean; } +/** Tags a link to yaak.app with which app it came from */ +export function withYaakRef(href: string): string { + if (!isYaakUrl(href)) return href; + const url = new URL(href); + url.searchParams.set("ref", appInfo.identifier); + return url.toString(); +} + +/** Opens a link outside the app, tagged as `Link` tags it */ +export function openExternalUrl(href: string) { + void platform.openUrl(withYaakRef(href)); +} + export function Link({ href, children, noUnderline, className, ...other }: Props) { const isExternal = href.match(/^https?:\/\//); + if (isExternal) { + return ( + + {children} + + ); + } + className = classNames( className, "relative", @@ -20,37 +47,6 @@ export function Link({ href, children, noUnderline, className, ...other }: Props !noUnderline && "underline", ); - if (isExternal) { - const isYaakLink = href.startsWith("https://yaak.app"); - let finalHref = href; - if (isYaakLink) { - const url = new URL(href); - url.searchParams.set("ref", appInfo.identifier); - finalHref = url.toString(); - } - return ( - // eslint-disable-next-line react/jsx-no-target-blank - { - e.preventDefault(); - void platform.openUrl(finalHref); - }} - className={className} - {...other} - > - {children} - - - ); - } - return ( {children} diff --git a/apps/yaak-client/components/core/Select.tsx b/apps/yaak-client/components/core/Select.tsx index 6abf68c53..d5a13bdfb 100644 --- a/apps/yaak-client/components/core/Select.tsx +++ b/apps/yaak-client/components/core/Select.tsx @@ -1,148 +1,2 @@ -import { platform } from "@yaakapp-internal/platform"; -import { HStack, Label } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { CSSProperties, ReactNode } from "react"; -import { useState } from "react"; -import type { ButtonProps } from "./Button"; -import { Button } from "./Button"; -import type { RadioDropdownItem } from "./RadioDropdown"; -import { RadioDropdown } from "./RadioDropdown"; - -export interface SelectProps { - name: string; - label: string; - labelPosition?: "top" | "left"; - labelClassName?: string; - hideLabel?: boolean; - value: T; - help?: ReactNode; - leftSlot?: ReactNode; - options: RadioDropdownItem[]; - onChange: (value: T) => void; - defaultValue?: T; - size?: ButtonProps["size"]; - className?: string; - disabled?: boolean; - filterable?: boolean; -} - -export function Select({ - labelPosition = "top", - name, - help, - labelClassName, - disabled, - hideLabel, - label, - value, - options, - leftSlot, - onChange, - className, - defaultValue, - filterable, - size = "md", -}: SelectProps) { - const [focused, setFocused] = useState(false); - const id = `input-${name}`; - const isInvalidSelection = options.find((o) => "value" in o && o.value === value) == null; - - const handleChange = (value: T) => { - onChange?.(value); - }; - - return ( -
- - {platform.osType() === "macos" && !filterable ? ( - - {leftSlot &&
{leftSlot}
} - -
- ) : ( - // Use custom "select" component until Tauri can be configured to have select menus not always appear in - // light mode - - o.type === "separator" || o.value !== defaultValue - ? o - : { ...o, label: <>{o.label} (default) }, - )} - > - - - )} -
- ); -} - -const selectBackgroundStyles: CSSProperties = { - backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`, - backgroundPosition: "right 0.3rem center", - backgroundRepeat: "no-repeat", - backgroundSize: "1.5em 1.5em", - appearance: "none", - printColorAdjust: "exact", -}; +export type { SelectProps } from "@yaakapp-internal/ui"; +export { Select } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/SettingRow.tsx b/apps/yaak-client/components/core/SettingRow.tsx index a3c6f6c63..d912d04d3 100644 --- a/apps/yaak-client/components/core/SettingRow.tsx +++ b/apps/yaak-client/components/core/SettingRow.tsx @@ -1,260 +1,36 @@ import type { AnyModel } from "@yaakapp-internal/models"; import { patchModel } from "@yaakapp-internal/models"; -import { Icon } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { ReactNode } from "react"; -import { CopyIconButton } from "../CopyIconButton"; -import { Checkbox } from "./Checkbox"; -import { IconButton, type IconButtonProps } from "./IconButton"; -import { PlainInput } from "./PlainInput"; -import type { RadioDropdownItem } from "./RadioDropdown"; -import { Select } from "./Select"; +import { + SettingRow, + type SettingRowBaseProps, + SettingRowBoolean, + SettingRowNumber, + SettingRowSelect, + SettingRowText, + SettingSelectControl, +} from "@yaakapp-internal/ui"; import { SelectFile } from "../SelectFile"; +export type { SettingRowBaseProps, SettingSnippetLanguage } from "@yaakapp-internal/ui"; +export { + SettingCommand, + SettingOverrideRow, + SettingRow, + SettingRowBoolean, + SettingRowNumber, + SettingRowSelect, + SettingRowText, + SettingSelectControl, + SettingSnippet, + SettingsList, + SettingsSection, + SettingValue, +} from "@yaakapp-internal/ui"; + type ModelKeyOfValue = { [K in keyof T]-?: T[K] extends V ? K : never; }[keyof T]; -type SettingRowBaseProps = { - className?: string; - controlClassName?: string; - description?: ReactNode; - disabled?: boolean; - title: ReactNode; -}; - -export function SettingsList({ children, className }: { children: ReactNode; className?: string }) { - return
{children}
; -} - -export function SettingsSection({ - children, - className, - description, - title, -}: { - children: ReactNode; - className?: string; - description?: ReactNode; - title: ReactNode | null; -}) { - const showHeader = title != null || description != null; - - return ( -
- {showHeader && ( -
- {title != null &&
{title}
} - {description != null &&

{description}

} -
- )} -
{children}
-
- ); -} - -export function SettingRow({ - children, - className, - controlClassName, - description, - disabled, - title, -}: { - children: ReactNode; -} & SettingRowBaseProps) { - return ( -
-
-
-
{title}
- {description != null && ( -
{description}
- )} -
-
- {children} -
-
-
- ); -} - -export function SettingValue({ - actions, - className, - copyText, - enableCopy = true, - value, -}: { - actions?: SettingValueAction[]; - className?: string; - copyText?: string; - enableCopy?: boolean; - value: ReactNode; -}) { - const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null; - const textToCopy = copyText ?? textValue; - - return ( - <> - - {value} - - {actions?.map((action) => ( - - ))} - {enableCopy && textToCopy != null && ( - - )} - - ); -} - -/** - * A command or config to copy, set as a block: its title, an optional line - * saying which file it goes in, then the snippet at full width with the copy - * button on it. One-line shell commands and multi-line JSON get the same block. - */ -export function SettingCommand({ - className, - description, - disabled, - done, - language, - snippet, - title, -}: { - description?: ReactNode; - disabled?: boolean; - /** A short word shown with a check beside the title once the step is detected as done */ - done?: ReactNode; - language: SettingSnippetLanguage; - snippet: string; - title: ReactNode; -} & Pick) { - return ( -
-
- {title} - {done != null && done !== false && ( - - - {done} - - )} -
- {description != null && ( -
{description}
- )} - -
- ); -} - -export type SettingSnippetLanguage = "shell" | "json" | "text"; - -/** The snippet half of a `SettingCommand`, for places with their own title, like a banner. */ -export function SettingSnippet({ - className, - language, - snippet, -}: { - className?: string; - language: SettingSnippetLanguage; - snippet: string; -}) { - const prompt = language === "shell"; - - return ( -
-
- - {prompt && ( - - $ - - )} - {snippet} - -
- -
- ); -} - -type SettingValueAction = { - icon: IconButtonProps["icon"]; - onClick: () => void; - title: string; -}; - -export function SettingRowBoolean({ - checked, - checkboxSize = "md", - onChange, - title, - ...props -}: { - checked: boolean; - checkboxSize?: "sm" | "md"; - onChange: (checked: boolean) => void; -} & SettingRowBaseProps) { - return ( - - - - ); -} - export function ModelSettingRowBoolean>({ model, modelKey, @@ -272,50 +48,6 @@ export function ModelSettingRowBoolean void; - placeholder?: string; - required?: boolean; - type?: "number"; - validate?: (value: string) => boolean; - value: number; -} & SettingRowBaseProps) { - return ( - - onChange(Number.parseInt(value, 10) || 0)} - type={type} - className={inputClassName} - containerClassName={inputWidthClassName} - disabled={props.disabled} - /> - - ); -} - export function ModelSettingRowNumber>({ model, modelKey, @@ -334,47 +66,6 @@ export function ModelSettingRowNumber void; - placeholder?: string; - required?: boolean; - type?: "text" | "password"; - value: string; -} & SettingRowBaseProps) { - return ( - - - - ); -} - export function ModelSettingRowText>({ model, modelKey, @@ -438,74 +129,6 @@ export function SettingRowDirectory({ return ; } -export function SettingRowSelect({ - defaultValue, - name, - onChange, - options, - selectClassName = "w-48!", - title, - value, - ...props -}: { - defaultValue?: T; - name: string; - onChange: (value: T) => void; - options: RadioDropdownItem[]; - selectClassName?: string; - value: T; -} & SettingRowBaseProps) { - return ( - - - - ); -} - -export function SettingSelectControl({ - defaultValue, - disabled, - label, - name, - onChange, - options, - selectClassName = "w-48!", - value, -}: { - defaultValue?: T; - disabled?: boolean; - label: string; - name: string; - onChange: (value: T) => void; - options: RadioDropdownItem[]; - selectClassName?: string; - value: T; -}) { - return ( - handleChange(e.target.value as T)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + disabled={disabled} + className={classNames( + "pr-7 w-full outline-hidden bg-transparent disabled:opacity-disabled", + "leading-none rounded-none", // Center the text better vertically + )} + > + {isInvalidSelection && } + {options.map((o) => { + if (o.type === "separator") return null; + return ( + + ); + })} + + + ) : ( + // Use custom "select" component until Tauri can be configured to have select menus not always appear in + // light mode + + o.type === "separator" || o.value !== defaultValue + ? o + : { ...o, label: <>{o.label} (default) }, + )} + > + + + )} + + ); +} + +const selectBackgroundStyles: CSSProperties = { + backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`, + backgroundPosition: "right 0.3rem center", + backgroundRepeat: "no-repeat", + backgroundSize: "1.5em 1.5em", + appearance: "none", + printColorAdjust: "exact", +}; diff --git a/packages/ui/src/components/settings/SettingRow.tsx b/packages/ui/src/components/settings/SettingRow.tsx new file mode 100644 index 000000000..b2712c2dd --- /dev/null +++ b/packages/ui/src/components/settings/SettingRow.tsx @@ -0,0 +1,457 @@ +import classNames from "classnames"; +import type { ReactNode } from "react"; +import { Checkbox } from "../Checkbox"; +import { CopyIconButton as BaseCopyIconButton, type CopyIconButtonProps } from "../CopyIconButton"; +import { useEditorHost } from "../EditorHost"; +import { Icon } from "../Icon"; +import { IconButton, type IconButtonProps } from "../IconButton"; +import { PlainInput } from "../PlainInput"; +import type { RadioDropdownItem } from "../RadioDropdown"; +import { Select } from "../Select"; + +/** Copies through the host, quietly, as the rows' values do */ +function CopyIconButton(props: Omit) { + const host = useEditorHost(); + return ; +} + +export type SettingRowBaseProps = { + className?: string; + controlClassName?: string; + description?: ReactNode; + disabled?: boolean; + title: ReactNode; +}; + +export function SettingsList({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} + +export function SettingsSection({ + children, + className, + description, + title, +}: { + children: ReactNode; + className?: string; + description?: ReactNode; + title: ReactNode | null; +}) { + const showHeader = title != null || description != null; + + return ( +
+ {showHeader && ( +
+ {title != null &&
{title}
} + {description != null &&

{description}

} +
+ )} +
{children}
+
+ ); +} + +export function SettingRow({ + children, + className, + controlClassName, + description, + disabled, + title, +}: { + children: ReactNode; +} & SettingRowBaseProps) { + return ( +
+
+
+
{title}
+ {description != null && ( +
{description}
+ )} +
+
+ {children} +
+
+
+ ); +} + +export function SettingValue({ + actions, + className, + copyText, + enableCopy = true, + value, +}: { + actions?: SettingValueAction[]; + className?: string; + copyText?: string; + enableCopy?: boolean; + value: ReactNode; +}) { + const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null; + const textToCopy = copyText ?? textValue; + + return ( + <> + + {value} + + {actions?.map((action) => ( + + ))} + {enableCopy && textToCopy != null && ( + + )} + + ); +} + +/** + * A command or config to copy, set as a block: its title, an optional line + * saying which file it goes in, then the snippet at full width with the copy + * button on it. One-line shell commands and multi-line JSON get the same block. + */ +export function SettingCommand({ + className, + description, + disabled, + done, + language, + snippet, + title, +}: { + description?: ReactNode; + disabled?: boolean; + /** A short word shown with a check beside the title once the step is detected as done */ + done?: ReactNode; + language: SettingSnippetLanguage; + snippet: string; + title: ReactNode; +} & Pick) { + return ( +
+
+ {title} + {done != null && done !== false && ( + + + {done} + + )} +
+ {description != null && ( +
{description}
+ )} + +
+ ); +} + +export type SettingSnippetLanguage = "shell" | "json" | "text"; + +/** The snippet half of a `SettingCommand`, for places with their own title, like a banner. */ +export function SettingSnippet({ + className, + language, + snippet, +}: { + className?: string; + language: SettingSnippetLanguage; + snippet: string; +}) { + const prompt = language === "shell"; + + return ( +
+
+ + {prompt && ( + + $ + + )} + {snippet} + +
+ +
+ ); +} + +type SettingValueAction = { + icon: IconButtonProps["icon"]; + onClick: () => void; + title: string; +}; + +export function SettingRowBoolean({ + checked, + checkboxSize = "md", + onChange, + title, + ...props +}: { + checked: boolean; + checkboxSize?: "sm" | "md"; + onChange: (checked: boolean) => void; +} & SettingRowBaseProps) { + return ( + + + + ); +} + +export function SettingRowNumber({ + inputClassName, + inputWidthClassName = "w-48!", + name, + onChange, + placeholder, + required, + title, + type = "number", + validate, + value, + ...props +}: { + inputClassName?: string; + inputWidthClassName?: string; + name: string; + onChange: (value: number) => void; + placeholder?: string; + required?: boolean; + type?: "number"; + validate?: (value: string) => boolean; + value: number; +} & SettingRowBaseProps) { + return ( + + onChange(Number.parseInt(value, 10) || 0)} + type={type} + className={inputClassName} + containerClassName={inputWidthClassName} + disabled={props.disabled} + /> + + ); +} + +export function SettingRowText({ + inputClassName, + inputWidthClassName = "w-80!", + name, + onChange, + placeholder, + required, + title, + type = "text", + value, + ...props +}: { + inputClassName?: string; + inputWidthClassName?: string; + name: string; + onChange: (value: string) => void; + placeholder?: string; + required?: boolean; + type?: "text" | "password"; + value: string; +} & SettingRowBaseProps) { + return ( + + + + ); +} + +export function SettingRowSelect({ + defaultValue, + name, + onChange, + options, + selectClassName = "w-48!", + title, + value, + ...props +}: { + defaultValue?: T; + name: string; + onChange: (value: T) => void; + options: RadioDropdownItem[]; + selectClassName?: string; + value: T; +} & SettingRowBaseProps) { + return ( + + + + ); +} + +export function SettingSelectControl({ + defaultValue, + disabled, + label, + name, + onChange, + options, + selectClassName = "w-48!", + value, +}: { + defaultValue?: T; + disabled?: boolean; + label: string; + name: string; + onChange: (value: T) => void; + options: RadioDropdownItem[]; + selectClassName?: string; + value: T; +}) { + return ( +