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 && (
- {
- await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
- await rpc("cmd_restart");
- }}
- >
- Apply and Restart
-
- )}
-
+ }
+ />
);
}
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" ? (
-
- deactivate.mutate()}>
- Deactivate License
-
- platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
- rightSlot={ }
- >
- Direct Support
-
-
- ) : (
-
-
- Activate License
-
- }
- onClick={() =>
- platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
- }
- >
- Purchase License
-
-
- )}
-
- {activateFormVisible && (
-
{
- e.preventDefault();
- await activate.mutateAsync({ licenseKey: key });
- toggleActivateFormVisible();
- }}
- >
-
-
- Submit
-
-
- )}
-
- );
+ 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 (
-
-
- {label}
-
- {platform.osType() === "macos" && !filterable ? (
-
- {leftSlot && {leftSlot}
}
- 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 && -- Select an Option -- }
- {options.map((o) => {
- if (o.type === "separator") return null;
- return (
-
- {o.label}
- {o.value === defaultValue && " (default)"}
-
- );
- })}
-
-
- ) : (
- // 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)> },
- )}
- >
-
- {options.find((o) => o.type !== "separator" && o.value === value)?.label ?? "--"}
-
-
- )}
-
- );
-}
-
-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 (
-
- );
-}
-
export function ModelSettingSelectControl<
M extends AnyModel,
K extends ModelKeyOfValue,
@@ -549,51 +172,3 @@ export function ModelSettingRowSelect<
/>
);
}
-
-export function SettingOverrideRow({
- children,
- className,
- controlClassName,
- description,
- disabled,
- onResetOverride,
- overridden,
- resetTitle = "Reset override",
- title,
-}: {
- children: ReactNode;
- className?: string;
- controlClassName?: string;
- description?: ReactNode;
- disabled?: boolean;
- onResetOverride: () => void;
- overridden: boolean;
- resetTitle?: string;
- title: ReactNode;
-}) {
- return (
-
- {title}
- {overridden && (
-
- )}
-
- }
- >
- {children}
-
- );
-}
diff --git a/apps/yaak-client/hooks/useSettingsView.ts b/apps/yaak-client/hooks/useSettingsView.ts
new file mode 100644
index 000000000..d5213b72f
--- /dev/null
+++ b/apps/yaak-client/hooks/useSettingsView.ts
@@ -0,0 +1,76 @@
+import { useFonts } from "@yaakapp-internal/fonts";
+import { useLicense } from "@yaakapp-internal/license";
+import { patchModel, settingsAtom } from "@yaakapp-internal/models";
+import { platform } from "@yaakapp-internal/platform";
+import type { SettingsCallbacks, SettingsView } from "@yaakapp-internal/ui";
+import { useAtomValue } from "jotai";
+import { openExternalUrl } from "../components/core/Link";
+import { appInfo } from "../lib/appInfo";
+import { pricingUrl } from "../lib/pricingUrl";
+import { revealInFinderText } from "../lib/reveal";
+import { rpc } from "../lib/rpc";
+import {
+ licenseStatusFromCheck,
+ settingsValuesFromModel,
+ themesViewFromResolved,
+} from "../lib/settingsView";
+import { useCheckForUpdates } from "./useCheckForUpdates";
+import { useResolvedAppearance } from "./useResolvedAppearance";
+import { useResolvedTheme } from "./useResolvedTheme";
+
+/** Binds the shared settings tabs to the client's settings model, themes, fonts and license */
+export function useSettingsView(): { view: SettingsView; callbacks: SettingsCallbacks } | null {
+ const settings = useAtomValue(settingsAtom);
+ const appearance = useResolvedAppearance();
+ const activeTheme = useResolvedTheme();
+ const fonts = useFonts();
+ const { check, activate, deactivate } = useLicense();
+ const checkForUpdates = useCheckForUpdates();
+
+ if (settings == null) {
+ return null;
+ }
+
+ const view: SettingsView = {
+ app: {
+ name: "Yaak",
+ version: appInfo.version,
+ dataDir: appInfo.appDataDir,
+ logDir: appInfo.appLogDir,
+ revealPathLabel: revealInFinderText,
+ },
+ values: settingsValuesFromModel(settings),
+ themes: themesViewFromResolved(activeTheme.data, appearance),
+ fonts: fonts.data ?? null,
+ windowControlsSetting: platform.osType() !== "macos",
+ updates: appInfo.featureUpdater ? { checking: checkForUpdates.isPending } : null,
+ feedbackPrompts: appInfo.featureLicense,
+ license: appInfo.featureLicense
+ ? {
+ pending: check.isPending,
+ status: licenseStatusFromCheck(check.data),
+ checkError: check.error ?? null,
+ activateError: activate.error ?? null,
+ activating: activate.isPending,
+ }
+ : null,
+ };
+
+ const callbacks: SettingsCallbacks = {
+ onChange: async (patch) => {
+ await patchModel(settings, patch);
+ },
+ onApplyNativeTitlebar: async (useNativeTitlebar) => {
+ await patchModel(settings, { useNativeTitlebar });
+ await rpc("cmd_restart");
+ },
+ onCheckForUpdates: () => checkForUpdates.mutate(),
+ onActivateLicense: (licenseKey) => activate.mutateAsync({ licenseKey }),
+ onDeactivateLicense: () => deactivate.mutate(),
+ onOpenUrl: openExternalUrl,
+ onRevealPath: (path) => platform.revealItemInDir(path),
+ pricingUrl,
+ };
+
+ return { view, callbacks };
+}
diff --git a/apps/yaak-client/lib/settingsView.test.ts b/apps/yaak-client/lib/settingsView.test.ts
new file mode 100644
index 000000000..b5e142732
--- /dev/null
+++ b/apps/yaak-client/lib/settingsView.test.ts
@@ -0,0 +1,126 @@
+import type { LicenseCheckStatus } from "@yaakapp-internal/license";
+import type { Settings } from "@yaakapp-internal/models";
+import { describe, expect, test } from "vite-plus/test";
+import {
+ licenseStatusFromCheck,
+ settingsValuesFromModel,
+ themesViewFromResolved,
+} from "./settingsView";
+
+describe("settingsValuesFromModel", () => {
+ test("picks the shared tabs' values and nothing client-only", () => {
+ const settings = {
+ model: "settings",
+ id: "st_1",
+ appearance: "dark",
+ themeLight: "yaak-light",
+ themeDark: "hotdog",
+ interfaceFont: "Inter",
+ interfaceFontSize: 15,
+ editorFont: null,
+ editorFontSize: 13,
+ editorKeymap: "emacs",
+ editorSoftWrap: false,
+ coloredMethods: true,
+ useNativeTitlebar: true,
+ hideWindowControls: true,
+ updateChannel: "beta",
+ autoupdate: false,
+ autoDownloadUpdates: false,
+ checkNotifications: false,
+ promptFeedback: false,
+ openWorkspaceNewWindow: true,
+ hideLicenseBadge: true,
+ clientCertificates: [],
+ } as unknown as Settings;
+
+ expect(settingsValuesFromModel(settings)).toEqual({
+ appearance: "dark",
+ themeLight: "yaak-light",
+ themeDark: "hotdog",
+ interfaceFont: "Inter",
+ interfaceFontSize: 15,
+ editorFont: null,
+ editorFontSize: 13,
+ editorKeymap: "emacs",
+ editorSoftWrap: false,
+ coloredMethods: true,
+ useNativeTitlebar: true,
+ hideWindowControls: true,
+ updateChannel: "beta",
+ autoupdate: false,
+ autoDownloadUpdates: false,
+ checkNotifications: false,
+ promptFeedback: false,
+ });
+ });
+});
+
+describe("licenseStatusFromCheck", () => {
+ test("is null without a check", () => {
+ expect(licenseStatusFromCheck(null)).toBeNull();
+ expect(licenseStatusFromCheck(undefined)).toBeNull();
+ });
+
+ test("keeps what each state's copy needs", () => {
+ const cases: [LicenseCheckStatus, unknown][] = [
+ [
+ { status: "active", data: { periodEnd: "2027-01-01", cancelAt: null } },
+ { status: "active" },
+ ],
+ [
+ { status: "trialing", data: { end: "2026-11-20" } },
+ { status: "trialing", trialEnd: "2026-11-20" },
+ ],
+ [{ status: "personal_use", data: { trial_ended: "2026-01-01" } }, { status: "personal_use" }],
+ [{ status: "inactive", data: { status: "canceled" } }, { status: "inactive" }],
+ [
+ {
+ status: "expired",
+ data: {
+ changes: 3,
+ changesUrl: "https://yaak.app/changes",
+ billingUrl: "https://yaak.app/billing",
+ periodEnd: "2026-03-04",
+ },
+ },
+ { status: "expired", periodEnd: "2026-03-04", changesUrl: "https://yaak.app/changes" },
+ ],
+ [
+ { status: "past_due", data: { billingUrl: "https://yaak.app/billing", periodEnd: "x" } },
+ { status: "past_due", billingUrl: "https://yaak.app/billing" },
+ ],
+ [
+ { status: "error", data: { message: "Boom", code: "E1" } },
+ { status: "error", message: "Boom", code: "E1" },
+ ],
+ ];
+ for (const [check, expected] of cases) {
+ expect(licenseStatusFromCheck(check)).toEqual(expected);
+ }
+ });
+});
+
+describe("themesViewFromResolved", () => {
+ const light = { id: "yaak-light", label: "Yaak", dark: false, base: {} };
+ const dark = { id: "yaak-dark", label: "Yaak", dark: true, base: {} };
+
+ test("is null while the themes load", () => {
+ expect(themesViewFromResolved(undefined, "dark")).toBeNull();
+ });
+
+ test("keeps each theme's id, label and darkness, and the resolved appearance", () => {
+ expect(
+ themesViewFromResolved({ themes: [dark, light], light, dark, active: dark }, "dark"),
+ ).toEqual({
+ themes: [
+ { id: "yaak-dark", label: "Yaak", dark: true },
+ { id: "yaak-light", label: "Yaak", dark: false },
+ ],
+ light: { id: "yaak-light", label: "Yaak", dark: false },
+ dark: { id: "yaak-dark", label: "Yaak", dark: true },
+ active: { id: "yaak-dark", label: "Yaak", dark: true },
+ resolvedAppearance: "dark",
+ });
+ });
+});
diff --git a/apps/yaak-client/lib/settingsView.ts b/apps/yaak-client/lib/settingsView.ts
new file mode 100644
index 000000000..b34632f70
--- /dev/null
+++ b/apps/yaak-client/lib/settingsView.ts
@@ -0,0 +1,84 @@
+import type { LicenseCheckStatus } from "@yaakapp-internal/license";
+import type { Settings } from "@yaakapp-internal/models";
+import type {
+ SettingsLicenseStatus,
+ SettingsThemeOption,
+ SettingsThemesView,
+ SettingsValues,
+} from "@yaakapp-internal/ui";
+
+/** The values the shared settings tabs read, picked off the client's `Settings` model */
+export function settingsValuesFromModel(settings: Settings): SettingsValues {
+ return {
+ appearance: settings.appearance,
+ themeLight: settings.themeLight,
+ themeDark: settings.themeDark,
+ interfaceFont: settings.interfaceFont,
+ interfaceFontSize: settings.interfaceFontSize,
+ editorFont: settings.editorFont,
+ editorFontSize: settings.editorFontSize,
+ editorKeymap: settings.editorKeymap,
+ editorSoftWrap: settings.editorSoftWrap,
+ coloredMethods: settings.coloredMethods,
+ useNativeTitlebar: settings.useNativeTitlebar,
+ hideWindowControls: settings.hideWindowControls,
+ updateChannel: settings.updateChannel,
+ autoupdate: settings.autoupdate,
+ autoDownloadUpdates: settings.autoDownloadUpdates,
+ checkNotifications: settings.checkNotifications,
+ promptFeedback: settings.promptFeedback,
+ };
+}
+
+/** A license check, as the shared License tab's copy needs it */
+export function licenseStatusFromCheck(
+ check: LicenseCheckStatus | null | undefined,
+): SettingsLicenseStatus | null {
+ if (check == null) return null;
+ switch (check.status) {
+ case "active":
+ return { status: "active" };
+ case "trialing":
+ return { status: "trialing", trialEnd: check.data.end };
+ case "personal_use":
+ return { status: "personal_use" };
+ case "inactive":
+ return { status: "inactive" };
+ case "expired":
+ return {
+ status: "expired",
+ periodEnd: check.data.periodEnd,
+ changesUrl: check.data.changesUrl,
+ };
+ case "past_due":
+ return { status: "past_due", billingUrl: check.data.billingUrl };
+ case "error":
+ return { status: "error", message: check.data.message, code: check.data.code };
+ }
+}
+
+/** The fields of a theme the settings show */
+interface ThemeLike {
+ id: string;
+ label: string;
+ dark: boolean;
+}
+
+function themeOption({ id, label, dark }: ThemeLike): SettingsThemeOption {
+ return { id, label, dark };
+}
+
+/** The resolved themes, as the shared Theme tab shows them; null while they load */
+export function themesViewFromResolved(
+ data: { themes: ThemeLike[]; light: ThemeLike; dark: ThemeLike; active: ThemeLike } | undefined,
+ resolvedAppearance: "light" | "dark",
+): SettingsThemesView | null {
+ if (data == null) return null;
+ return {
+ themes: data.themes.map(themeOption),
+ light: themeOption(data.light),
+ dark: themeOption(data.dark),
+ active: themeOption(data.active),
+ resolvedAppearance,
+ };
+}
diff --git a/apps/yaak-proxy/harness-settings.html b/apps/yaak-proxy/harness-settings.html
new file mode 100644
index 000000000..14957782e
--- /dev/null
+++ b/apps/yaak-proxy/harness-settings.html
@@ -0,0 +1,212 @@
+
+
+
+
+
+ Yaak Proxy, shared settings harness
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index ed8af433b..a49345f02 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15018,6 +15018,7 @@
"name": "@yaakapp-internal/ui",
"version": "1.0.0",
"dependencies": {
+ "date-fns": "^4.1.0",
"hexy": "^0.4.0",
"jotai-family": "^1.0.1"
}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 92ae0f847..0c7a7baa1 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -19,6 +19,7 @@
"./useToggle": "./src/hooks/useToggle.ts"
},
"dependencies": {
+ "date-fns": "^4.1.0",
"hexy": "^0.4.0",
"jotai-family": "^1.0.1"
}
diff --git a/packages/ui/src/components/ExternalLink.tsx b/packages/ui/src/components/ExternalLink.tsx
new file mode 100644
index 000000000..6f6166104
--- /dev/null
+++ b/packages/ui/src/components/ExternalLink.tsx
@@ -0,0 +1,54 @@
+import classNames from "classnames";
+import type { HTMLAttributes } from "react";
+import { useEditorHost } from "./EditorHost";
+import { isYaakUrl } from "../lib/yaakUrl";
+import { Icon } from "./Icon";
+
+export interface ExternalLinkProps extends HTMLAttributes {
+ href: string;
+ noUnderline?: boolean;
+ /** Opens the link; defaults to the host's `openUrl`, then a new browser tab */
+ onOpen?: (href: string) => void;
+}
+
+/** A link that leaves the app, marked with an external-link icon */
+export function ExternalLink({
+ href,
+ children,
+ noUnderline,
+ className,
+ onOpen,
+ ...other
+}: ExternalLinkProps) {
+ const host = useEditorHost();
+ const open = onOpen ?? host.openUrl;
+ const isYaakLink = isYaakUrl(href);
+
+ return (
+ // eslint-disable-next-line react/jsx-no-target-blank
+ {
+ if (open == null) return;
+ e.preventDefault();
+ open(href);
+ }}
+ className={classNames(
+ className,
+ "relative",
+ "inline-flex items-center hover:underline group",
+ !noUnderline && "underline",
+ )}
+ {...other}
+ >
+ {children}
+
+
+ );
+}
diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx
new file mode 100644
index 000000000..c6ac0eb01
--- /dev/null
+++ b/packages/ui/src/components/Select.tsx
@@ -0,0 +1,149 @@
+import { platform } from "@yaakapp-internal/platform";
+import classNames from "classnames";
+import type { CSSProperties, ReactNode } from "react";
+import { useState } from "react";
+import type { ButtonProps } from "./Button";
+import { Button } from "./Button";
+import { Label } from "./Label";
+import type { RadioDropdownItem } from "./RadioDropdown";
+import { RadioDropdown } from "./RadioDropdown";
+import { HStack } from "./Stacks";
+
+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 (
+
+
+ {label}
+
+ {platform.osType() === "macos" && !filterable ? (
+
+ {leftSlot && {leftSlot}
}
+ 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 && -- Select an Option -- }
+ {options.map((o) => {
+ if (o.type === "separator") return null;
+ return (
+
+ {o.label}
+ {o.value === defaultValue && " (default)"}
+
+ );
+ })}
+
+
+ ) : (
+ // 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)> },
+ )}
+ >
+
+ {options.find((o) => o.type !== "separator" && o.value === value)?.label ?? "--"}
+
+
+ )}
+
+ );
+}
+
+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 (
+
+ );
+}
+
+export function SettingOverrideRow({
+ children,
+ className,
+ controlClassName,
+ description,
+ disabled,
+ onResetOverride,
+ overridden,
+ resetTitle = "Reset override",
+ title,
+}: {
+ children: ReactNode;
+ className?: string;
+ controlClassName?: string;
+ description?: ReactNode;
+ disabled?: boolean;
+ onResetOverride: () => void;
+ overridden: boolean;
+ resetTitle?: string;
+ title: ReactNode;
+}) {
+ return (
+
+ {title}
+ {overridden && (
+
+ )}
+
+ }
+ >
+ {children}
+
+ );
+}
diff --git a/packages/ui/src/components/settings/SettingsDialog.tsx b/packages/ui/src/components/settings/SettingsDialog.tsx
new file mode 100644
index 000000000..c6c4e3b1a
--- /dev/null
+++ b/packages/ui/src/components/settings/SettingsDialog.tsx
@@ -0,0 +1,110 @@
+import classNames from "classnames";
+import type { ReactNode } from "react";
+import { useEditorHost } from "../EditorHost";
+import { HeaderSize } from "../HeaderSize";
+import { Icon, type IconProps } from "../Icon";
+import { HStack } from "../Stacks";
+import { TabContent, type TabItem, Tabs } from "../tabs/Tabs";
+
+export const SETTINGS_TAB_GENERAL = "general";
+export const SETTINGS_TAB_THEME = "theme";
+export const SETTINGS_TAB_INTERFACE = "interface";
+export const SETTINGS_TAB_LICENSE = "license";
+
+/** A tab in the settings rail, with what it shows */
+export interface SettingsTabDef {
+ value: string;
+ label: string;
+ icon: IconProps["icon"];
+ hidden?: boolean;
+ /** A count or dot beside the label */
+ rightSlot?: ReactNode;
+ content: ReactNode;
+ /** Replaces the scrolling, padded default, for tabs that lay themselves out */
+ contentClassName?: string;
+}
+
+/** What the window's own title bar needs, when settings is a window rather than a dialog */
+export interface SettingsTitlebar {
+ osType: string;
+ hideWindowControls: boolean;
+ useNativeTitlebar: boolean;
+ interfaceScale: number;
+}
+
+export interface SettingsDialogProps {
+ tabs: SettingsTabDef[];
+ defaultTab?: string;
+ /** The window's title bar; null in a dialog, which has its own frame */
+ titlebar: SettingsTitlebar | null;
+ title?: string;
+}
+
+const DEFAULT_CONTENT_CLASS = "overflow-y-auto h-full px-6 py-4!";
+
+/**
+ * Yaak's settings: a title bar, the tabs down the left with their icons, and the active tab's
+ * content. Each app passes the tabs it has, shared ones and its own, in the order it wants.
+ */
+export function SettingsDialog({
+ tabs,
+ defaultTab,
+ titlebar,
+ title = "Settings",
+}: SettingsDialogProps) {
+ const { ErrorBoundary } = useEditorHost();
+ return (
+
+ {titlebar == null ? (
+
+ ) : (
+
+
+
+ {title}
+
+
+
+ )}
+
({
+ value: tab.value,
+ label: tab.label,
+ hidden: tab.hidden,
+ leftSlot: ,
+ rightSlot: tab.rightSlot ?? null,
+ }))}
+ >
+ {tabs.map((tab) => (
+
+ {tab.content}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/ui/src/components/settings/SettingsGeneral.tsx b/packages/ui/src/components/settings/SettingsGeneral.tsx
new file mode 100644
index 000000000..f8d830815
--- /dev/null
+++ b/packages/ui/src/components/settings/SettingsGeneral.tsx
@@ -0,0 +1,166 @@
+import type { ReactNode } from "react";
+import { Heading } from "../Heading";
+import { IconButton } from "../IconButton";
+import { VStack } from "../Stacks";
+import {
+ SettingRow,
+ SettingRowBoolean,
+ SettingRowSelect,
+ SettingSelectControl,
+ SettingsList,
+ SettingsSection,
+ SettingValue,
+} from "./SettingRow";
+import type { SettingsCallbacks, SettingsView } from "./types";
+
+export interface SettingsGeneralProps {
+ view: SettingsView;
+ callbacks: SettingsCallbacks;
+ /** Shown under the heading, like the client's commercial-use banner */
+ banner?: ReactNode;
+ /** Sections only this app has, shown before App Info */
+ extraSections?: ReactNode;
+}
+
+export function SettingsGeneral({ view, callbacks, banner, extraSections }: SettingsGeneralProps) {
+ const { app, values } = view;
+ return (
+
+
+
General
+
Configure general settings for update behavior and more.
+
+ {banner != null && {banner}
}
+
+ {view.updates != null && }
+
+ {view.feedbackPrompts && (
+
+ callbacks.onChange({ promptFeedback })}
+ />
+
+ )}
+
+ {extraSections}
+
+
+
+
+
+
+ callbacks.onRevealPath(app.dataDir),
+ },
+ ]}
+ />
+
+
+ callbacks.onRevealPath(app.logDir),
+ },
+ ]}
+ />
+
+
+
+
+ );
+}
+
+/** The update channel, how updates install, and the check button */
+export function SettingsUpdatesSection({
+ view,
+ callbacks,
+}: {
+ view: Pick;
+ callbacks: Pick;
+}) {
+ const { app, values } = view;
+ return (
+
+
+
+ callbacks.onChange({ updateChannel })}
+ options={[
+ { label: "Stable", value: "stable" },
+ { label: "Beta", value: "beta" },
+ ]}
+ />
+ callbacks.onCheckForUpdates()}
+ />
+
+
+
+ callbacks.onChange({ autoupdate: v === "auto" })}
+ options={[
+ { label: "Automatic", value: "auto" },
+ { label: "Manual", value: "manual" },
+ ]}
+ />
+
+ callbacks.onChange({ autoDownloadUpdates })}
+ />
+
+ callbacks.onChange({ checkNotifications })}
+ />
+
+ {}}
+ />
+
+ );
+}
diff --git a/packages/ui/src/components/settings/SettingsInterface.tsx b/packages/ui/src/components/settings/SettingsInterface.tsx
new file mode 100644
index 000000000..aaa344046
--- /dev/null
+++ b/packages/ui/src/components/settings/SettingsInterface.tsx
@@ -0,0 +1,201 @@
+import type { ReactNode } from "react";
+import { useState } from "react";
+import { clamp } from "../../lib/clamp";
+import { Button } from "../Button";
+import { Checkbox } from "../Checkbox";
+import { Heading } from "../Heading";
+import { VStack } from "../Stacks";
+import {
+ SettingRow,
+ SettingRowBoolean,
+ SettingRowSelect,
+ SettingSelectControl,
+ SettingsList,
+ SettingsSection,
+} from "./SettingRow";
+import type { SettingsCallbacks, SettingsEditorKeymap, SettingsView } from "./types";
+
+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: SettingsEditorKeymap; label: string }[] = [
+ { value: "default", label: "Default" },
+ { value: "vim", label: "Vim" },
+ { value: "vscode", label: "VSCode" },
+ { value: "emacs", label: "Emacs" },
+];
+
+export interface SettingsInterfaceProps {
+ view: SettingsView;
+ callbacks: SettingsCallbacks;
+ /** Sections only this app has, shown first, like the client's Workspaces */
+ leadingSections?: ReactNode;
+ /** Sections only this app has, shown last, like the client's License */
+ trailingSections?: ReactNode;
+}
+
+export function SettingsInterface({
+ view,
+ callbacks,
+ leadingSections,
+ trailingSections,
+}: SettingsInterfaceProps) {
+ const { app, values, fonts } = view;
+
+ return (
+
+
+
Interface
+
Tweak settings related to the user interface.
+
+
+ {leadingSections}
+
+
+
+ {fonts && (
+ ({ label: f, value: f })),
+ ...fonts.editorFonts.map((f) => ({ label: f, value: f })),
+ ]}
+ onChange={(v) =>
+ callbacks.onChange({ interfaceFont: v === NULL_FONT_VALUE ? null : v })
+ }
+ />
+ )}
+ callbacks.onChange({ interfaceFontSize: Number.parseInt(v, 10) })}
+ />
+
+
+
+ {fonts && (
+ ({ label: f, value: f })),
+ ]}
+ onChange={(v) =>
+ callbacks.onChange({ editorFont: v === NULL_FONT_VALUE ? null : v })
+ }
+ />
+ )}
+
+ callbacks.onChange({
+ editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
+ })
+ }
+ />
+
+
+
+
+ callbacks.onChange({ editorKeymap })}
+ options={keymaps}
+ />
+ callbacks.onChange({ editorSoftWrap })}
+ />
+ callbacks.onChange({ coloredMethods })}
+ />
+
+
+
+
+ {view.windowControlsSetting && (
+ callbacks.onChange({ hideWindowControls })}
+ />
+ )}
+
+
+ {trailingSections}
+
+
+ );
+}
+
+function NativeTitlebarSetting({
+ useNativeTitlebar,
+ onApply,
+}: {
+ useNativeTitlebar: boolean;
+ onApply: SettingsCallbacks["onApplyNativeTitlebar"];
+}) {
+ const [nativeTitlebar, setNativeTitlebar] = useState(useNativeTitlebar);
+
+ return (
+
+
+ {useNativeTitlebar !== nativeTitlebar && (
+ onApply(nativeTitlebar)}>
+ Apply and Restart
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/components/settings/SettingsLicense.tsx b/packages/ui/src/components/settings/SettingsLicense.tsx
new file mode 100644
index 000000000..ee2ec5c59
--- /dev/null
+++ b/packages/ui/src/components/settings/SettingsLicense.tsx
@@ -0,0 +1,204 @@
+import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
+import { differenceInDays } from "date-fns";
+import { formatDate } from "date-fns/format";
+import { useState } from "react";
+import { useToggle } from "../../hooks/useToggle";
+import { Banner } from "../Banner";
+import { Button } from "../Button";
+import { ExternalLink } from "../ExternalLink";
+import { Icon } from "../Icon";
+import { PlainInput } from "../PlainInput";
+import { Separator } from "../Separator";
+import { HStack, VStack } from "../Stacks";
+import type { SettingsCallbacks, SettingsLicenseStatus, SettingsView } from "./types";
+
+export interface SettingsLicenseProps {
+ view: Pick;
+ callbacks: Pick<
+ SettingsCallbacks,
+ "onActivateLicense" | "onDeactivateLicense" | "onOpenUrl" | "pricingUrl"
+ >;
+}
+
+export function SettingsLicense({ view, callbacks }: SettingsLicenseProps) {
+ const [key, setKey] = useState("");
+ const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
+ const license = view.license;
+
+ if (license == null || license.pending) {
+ return null;
+ }
+
+ return (
+
+ {license.status && (
+
+ )}
+
+ {license.checkError &&
{license.checkError} }
+ {license.activateError &&
{license.activateError} }
+
+ {license.status?.status === "active" ? (
+
+ callbacks.onDeactivateLicense()}
+ >
+ Deactivate License
+
+
+ callbacks.onOpenUrl("https://yaak.app/dashboard?intent=app.license.support")
+ }
+ rightSlot={ }
+ >
+ Direct Support
+
+
+ ) : (
+
+
+ Activate License
+
+ }
+ onClick={() =>
+ callbacks.onOpenUrl(
+ callbacks.pricingUrl(`app.license.purchase.${license.status?.status ?? "unknown"}`),
+ )
+ }
+ >
+ Purchase License
+
+
+ )}
+
+ {activateFormVisible && (
+
{
+ e.preventDefault();
+ await callbacks.onActivateLicense(key);
+ toggleActivateFormVisible();
+ }}
+ >
+
+
+ Submit
+
+
+ )}
+
+ );
+}
+
+function LicenseStatusBanner({
+ status,
+ appName,
+ callbacks,
+}: {
+ status: SettingsLicenseStatus;
+ appName: string;
+ callbacks: SettingsLicenseProps["callbacks"];
+}) {
+ const link = (href: string, children: string, noUnderline?: boolean) => (
+
+ {children}
+
+ );
+
+ switch (status.status) {
+ case "active":
+ return Your license is active 🥳 ;
+
+ case "trialing":
+ return (
+
+
+ {pluralizeCount("day", differenceInDays(status.trialEnd, new Date()))} {" "}
+ {`left to evaluate ${appName} for commercial use.`}
+
+ Personal use is always free, forever.
+
+
+ {link(callbacks.pricingUrl(`app.license.learn.${status.status}`), "Learn More", true)}
+
+
+
+ );
+
+ case "personal_use":
+ return (
+
+
+ Your commercial-use trial has ended.
+
+
+ {`You may continue using ${appName} for personal use only.`}
+ A license is required for commercial use.
+
+
+
+ {link(callbacks.pricingUrl(`app.license.learn.${status.status}`), "Learn More", true)}
+
+
+
+ );
+
+ case "inactive":
+ return (
+
+ Your license is invalid. Please {link("https://yaak.app/dashboard", "Sign In")} for more
+ details
+
+ );
+
+ case "expired":
+ return (
+
+ Your license expired {formatDate(status.periodEnd, "MMMM dd, yyyy")} .
+ Please {link("https://yaak.app/dashboard", "Resubscribe")} to continue receiving updates.
+ {status.changesUrl && (
+ <>
+
+ {link(status.changesUrl, "What's new in latest builds")}
+ >
+ )}
+
+ );
+
+ case "past_due":
+ return (
+
+ Your payment method needs attention.
+
+ To re-activate your license, please {link(status.billingUrl, "update your billing info")}.
+
+ );
+
+ case "error":
+ return (
+
+ License check failed: {status.message} (Code: {status.code})
+
+ );
+ }
+}
diff --git a/packages/ui/src/components/settings/SettingsTheme.tsx b/packages/ui/src/components/settings/SettingsTheme.tsx
new file mode 100644
index 000000000..6abb22d1c
--- /dev/null
+++ b/packages/ui/src/components/settings/SettingsTheme.tsx
@@ -0,0 +1,173 @@
+import { Suspense } from "react";
+import type { ButtonProps } from "../Button";
+import { useEditorHost } from "../EditorHost";
+import { ExternalLink } from "../ExternalLink";
+import { Heading } from "../Heading";
+import { Icon, type IconProps } from "../Icon";
+import { IconButton } from "../IconButton";
+import type { SelectProps } from "../Select";
+import { HStack, VStack } from "../Stacks";
+import { SettingRowSelect, SettingsList, SettingsSection } from "./SettingRow";
+import type { SettingsCallbacks, SettingsView } from "./types";
+
+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",
+];
+
+export interface SettingsThemeProps {
+ view: SettingsView;
+ callbacks: SettingsCallbacks;
+}
+
+export function SettingsTheme({ view, callbacks }: SettingsThemeProps) {
+ const { Editor } = useEditorHost();
+ const { app, values, themes } = view;
+
+ if (themes == null) {
+ return null;
+ }
+
+ const lightThemes: SelectProps["options"] = themes.themes
+ .filter((theme) => !theme.dark)
+ .map((theme) => ({
+ label: theme.label,
+ value: theme.id,
+ }));
+
+ const darkThemes: SelectProps["options"] = themes.themes
+ .filter((theme) => theme.dark)
+ .map((theme) => ({
+ label: theme.label,
+ value: theme.id,
+ }));
+
+ return (
+
+
+
Theme
+
+ {/* One text node, so the name kerns with the words around it */}
+ {`Make ${app.name} your own by selecting a theme, or`}{" "}
+
+ Create Your Own
+
+
+
+
+
+ callbacks.onChange({ appearance })}
+ options={[
+ { label: "Automatic", value: "system" },
+ { label: "Light", value: "light" },
+ { label: "Dark", value: "dark" },
+ ]}
+ />
+ {(values.appearance === "system" || values.appearance === "light") && (
+ callbacks.onChange({ themeLight })}
+ />
+ )}
+ {(values.appearance === "system" || values.appearance === "dark") && (
+ callbacks.onChange({ themeDark })}
+ />
+ )}
+
+
+
+
+
+
+ {themes.active.label}
+ (preview)
+
+
+ {buttonColors.map((c, i) => (
+
+ ))}
+ {buttonColors.map((c, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/settings/settings.test.tsx b/packages/ui/src/components/settings/settings.test.tsx
new file mode 100644
index 000000000..497baa6da
--- /dev/null
+++ b/packages/ui/src/components/settings/settings.test.tsx
@@ -0,0 +1,296 @@
+import type { ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, test, vi } from "vite-plus/test";
+import type { HostEditorProps } from "../EditorHost";
+import { EditorHostProvider } from "../EditorHost";
+import { SettingsDialog } from "./SettingsDialog";
+import { SettingsGeneral } from "./SettingsGeneral";
+import { SettingsInterface } from "./SettingsInterface";
+import { SettingsLicense } from "./SettingsLicense";
+import { SettingsTheme } from "./SettingsTheme";
+import type { SettingsCallbacks, SettingsLicenseStatus, SettingsView } from "./types";
+
+// Outside the app there's no Tauri; on macOS the selects are native, which shows their values
+vi.mock("@yaakapp-internal/platform", () => ({ platform: { osType: () => "macos" } }));
+
+function StubEditor({ defaultValue }: HostEditorProps) {
+ return {defaultValue}
;
+}
+
+function render(node: ReactNode) {
+ return renderToStaticMarkup(
+ {node} ,
+ );
+}
+
+/** Text only, so assertions read like the screen */
+function text(html: string) {
+ return html
+ .replace(/<[^>]+>/g, " ")
+ .replace(/'/g, "'")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+const light = { id: "yaak-light", label: "Yaak", dark: false };
+const dark = { id: "yaak-dark", label: "Yaak", dark: true };
+const hotdog = { id: "hotdog", label: "Hotdog Stand", dark: false };
+
+function stubView(overrides: Partial = {}): SettingsView {
+ return {
+ app: {
+ name: "Yaak Proxy",
+ version: "2026.11.0",
+ dataDir: "/data/app.yaak.proxy",
+ logDir: "/logs/app.yaak.proxy",
+ revealPathLabel: "Reveal in Finder",
+ },
+ values: {
+ appearance: "system",
+ themeLight: "missing-theme",
+ themeDark: "yaak-dark",
+ interfaceFont: null,
+ interfaceFontSize: 15,
+ editorFont: "Menlo",
+ editorFontSize: 13,
+ editorKeymap: "vim",
+ editorSoftWrap: true,
+ coloredMethods: false,
+ useNativeTitlebar: false,
+ hideWindowControls: false,
+ updateChannel: "beta",
+ autoupdate: false,
+ autoDownloadUpdates: true,
+ checkNotifications: true,
+ promptFeedback: true,
+ },
+ themes: {
+ themes: [dark, light, hotdog],
+ light: hotdog,
+ dark,
+ active: hotdog,
+ resolvedAppearance: "light",
+ },
+ fonts: { uiFonts: ["Inter"], editorFonts: ["Menlo"] },
+ windowControlsSetting: false,
+ updates: { checking: false },
+ feedbackPrompts: false,
+ license: {
+ pending: false,
+ status: null,
+ checkError: null,
+ activateError: null,
+ activating: false,
+ },
+ ...overrides,
+ };
+}
+
+const callbacks: SettingsCallbacks = {
+ onChange: () => {},
+ onApplyNativeTitlebar: () => {},
+ onCheckForUpdates: () => {},
+ onActivateLicense: async () => {},
+ onDeactivateLicense: () => {},
+ onOpenUrl: () => {},
+ onRevealPath: () => {},
+ pricingUrl: (intent) => `https://yaak.app/pricing?intent=${intent}`,
+};
+
+describe("SettingsGeneral", () => {
+ test("reads the app's name, version and paths, and the update values", () => {
+ const html = render( );
+ const t = text(html);
+ expect(t).toContain("Choose whether Yaak Proxy should use stable releases or beta releases.");
+ expect(t).toContain("Download Yaak Proxy updates in the background");
+ expect(t).toContain("Current Yaak Proxy version. 2026.11.0");
+ expect(t).toContain("/data/app.yaak.proxy");
+ expect(t).toContain("/logs/app.yaak.proxy");
+ expect(html).toContain('title="Reveal in Finder"');
+ // The channel select shows the value it was given
+ expect(html).toMatch(/Beta<\/option>/);
+ // Manual updates leave the auto-download row disabled
+ expect(html).toMatch(
+ /aria-disabled="true"[^>]*>(?:(?!aria-disabled).)*Automatically download/s,
+ );
+ });
+
+ test("leaves out Updates without an updater, and shows Feedback only when asked", () => {
+ const without = text(
+ render( ),
+ );
+ expect(without).not.toContain("Update Channel");
+ expect(without).not.toContain("Prompt for feedback");
+
+ const withFeedback = text(
+ render( ),
+ );
+ expect(withFeedback).toContain("Prompt for feedback");
+ });
+
+ test("puts the app's banner under the heading and its own sections before App Info", () => {
+ const t = text(
+ render(
+ Banner slot}
+ extraSections={Extra section
}
+ />,
+ ),
+ );
+ expect(t.indexOf("Banner slot")).toBeLessThan(t.indexOf("Updates"));
+ expect(t.indexOf("Extra section")).toBeGreaterThan(t.indexOf("Send anonymous"));
+ expect(t.indexOf("Extra section")).toBeLessThan(t.indexOf("App Info"));
+ });
+});
+
+describe("SettingsTheme", () => {
+ test("renders nothing until the themes load", () => {
+ expect(render( )).toBe(
+ "",
+ );
+ });
+
+ test("selects the resolved themes and splits them by appearance", () => {
+ const html = render( );
+ expect(text(html)).toContain("Make Yaak Proxy your own by selecting a theme");
+ // The setting names a missing light theme; the select shows the one in use
+ expect(html).toMatch(/Hotdog Stand<\/option>/);
+ expect(html).toMatch(/ Yaak<\/option>/);
+ expect(text(html)).toContain("Hotdog Stand (preview)");
+ });
+
+ test("shows only the light theme row in light mode", () => {
+ const view = stubView();
+ const html = render(
+ ,
+ );
+ expect(text(html)).toContain("Light theme");
+ expect(text(html)).not.toContain("Dark theme");
+ });
+});
+
+describe("SettingsInterface", () => {
+ test("reads fonts, sizes and editor options, with the app's sections around them", () => {
+ const html = render(
+ Leading}
+ trailingSections={Trailing
}
+ />,
+ );
+ const t = text(html);
+ expect(t).toContain("Font used for Yaak Proxy interface controls.");
+ expect(html).toMatch(/15<\/option>/);
+ expect(html).toMatch(/ Menlo<\/option>/);
+ expect(html).toMatch(/ Vim<\/option>/);
+ expect(t.indexOf("Leading")).toBeLessThan(t.indexOf("Fonts"));
+ expect(t.indexOf("Trailing")).toBeGreaterThan(t.indexOf("Native title bar"));
+ expect(t).not.toContain("Hide window controls");
+ });
+
+ test("offers hiding window controls where the app draws them", () => {
+ const t = text(
+ render(
+ ,
+ ),
+ );
+ expect(t).toContain("Hide window controls");
+ });
+});
+
+describe("SettingsLicense", () => {
+ function renderStatus(status: SettingsLicenseStatus | null, extra = {}) {
+ const view = stubView();
+ return text(
+ render(
+ ),
+ status,
+ ...extra,
+ },
+ }}
+ callbacks={callbacks}
+ />,
+ ),
+ );
+ }
+
+ test("says each state in the app's name", () => {
+ const trialEnd = new Date(Date.now() + 5.5 * 864e5).toISOString();
+ expect(renderStatus({ status: "trialing", trialEnd })).toContain(
+ "5 days left to evaluate Yaak Proxy for commercial use.",
+ );
+ expect(renderStatus({ status: "personal_use" })).toContain(
+ "You may continue using Yaak Proxy for personal use only.",
+ );
+ expect(renderStatus({ status: "active" })).toContain("Your license is active");
+ expect(renderStatus({ status: "inactive" })).toContain("Your license is invalid.");
+ expect(
+ renderStatus({
+ status: "expired",
+ periodEnd: "2026-03-04T00:00:00",
+ changesUrl: "https://yaak.app/changes",
+ }),
+ ).toContain("Your license expired March 04, 2026 .");
+ expect(renderStatus({ status: "past_due", billingUrl: "https://yaak.app/b" })).toContain(
+ "update your billing info",
+ );
+ expect(renderStatus({ status: "error", message: "Boom", code: "E1" })).toContain(
+ "License check failed: Boom (Code: E1)",
+ );
+ });
+
+ test("offers deactivation when active and purchase otherwise", () => {
+ expect(renderStatus({ status: "active" })).toContain("Deactivate License");
+ expect(renderStatus({ status: "personal_use" })).toContain("Purchase License");
+ expect(renderStatus(null)).toContain("Activate License");
+ });
+
+ test("shows errors, and nothing while the first check runs or without a license", () => {
+ expect(renderStatus(null, { checkError: "Offline", activateError: "Bad key" })).toMatch(
+ /Offline.*Bad key/,
+ );
+ expect(renderStatus(null, { pending: true })).toBe("");
+ expect(
+ render( ),
+ ).toBe("");
+ });
+});
+
+describe("SettingsDialog", () => {
+ test("lists shared and app tabs in order and shows the default one", () => {
+ const view = stubView();
+ const html = render(
+ ,
+ },
+ { value: "proxy", label: "Proxy", icon: "wifi", content: Proxy tab content
},
+ { value: "hidden", label: "Hidden", icon: "info", hidden: true, content: null },
+ ]}
+ />,
+ );
+ const t = text(html);
+ expect(t.indexOf("General")).toBeLessThan(t.indexOf("Proxy"));
+ expect(t).toContain("Proxy tab content");
+ expect(t).not.toContain("Hidden");
+ });
+});
diff --git a/packages/ui/src/components/settings/types.ts b/packages/ui/src/components/settings/types.ts
new file mode 100644
index 000000000..ea0355b39
--- /dev/null
+++ b/packages/ui/src/components/settings/types.ts
@@ -0,0 +1,124 @@
+/**
+ * What the shared settings tabs render and change. Each app maps its own settings store onto
+ * these (the client its `Settings` model, the proxy its own), so the tabs never import either
+ * app's models.
+ */
+
+export type SettingsEditorKeymap = "default" | "vim" | "vscode" | "emacs";
+
+/** The values the shared tabs read and change, named as Yaak's settings name them */
+export interface SettingsValues {
+ /** `system`, `light` or `dark` */
+ appearance: string;
+ themeLight: string;
+ themeDark: string;
+ interfaceFont: string | null;
+ interfaceFontSize: number;
+ editorFont: string | null;
+ editorFontSize: number;
+ editorKeymap: SettingsEditorKeymap;
+ editorSoftWrap: boolean;
+ coloredMethods: boolean;
+ useNativeTitlebar: boolean;
+ hideWindowControls: boolean;
+ /** `stable` or `beta` */
+ updateChannel: string;
+ autoupdate: boolean;
+ autoDownloadUpdates: boolean;
+ checkNotifications: boolean;
+ promptFeedback: boolean;
+}
+
+/** A change to some of the values, as a tab makes it */
+export type SettingsPatch = Partial;
+
+/** The app the settings belong to, for its name in the copy and its paths */
+export interface SettingsAppInfo {
+ /** As the copy says it: `Yaak`, `Yaak Proxy` */
+ name: string;
+ version: string;
+ dataDir: string;
+ logDir: string;
+ /** The label for showing a path in the file manager, like `Reveal in Finder` */
+ revealPathLabel: string;
+}
+
+export interface SettingsThemeOption {
+ id: string;
+ label: string;
+ dark: boolean;
+}
+
+export interface SettingsThemesView {
+ /** Every installed theme, light and dark */
+ themes: SettingsThemeOption[];
+ /** The light and dark themes in use, after falling back from a missing one */
+ light: SettingsThemeOption;
+ dark: SettingsThemeOption;
+ /** The theme showing now */
+ active: SettingsThemeOption;
+ /** The appearance showing now, with `system` resolved */
+ resolvedAppearance: "light" | "dark";
+}
+
+export interface SettingsFontsView {
+ uiFonts: string[];
+ editorFonts: string[];
+}
+
+export interface SettingsUpdatesView {
+ /** A check for updates is running */
+ checking: boolean;
+}
+
+/** Where a license stands, with what each state's copy needs */
+export type SettingsLicenseStatus =
+ | { status: "active" }
+ | { status: "trialing"; trialEnd: string }
+ | { status: "personal_use" }
+ | { status: "inactive" }
+ | { status: "expired"; periodEnd: string; changesUrl: string | null }
+ | { status: "past_due"; billingUrl: string }
+ | { status: "error"; message: string; code: string };
+
+export interface SettingsLicenseView {
+ /** The first check hasn't returned yet */
+ pending: boolean;
+ /** Null when the check returned nothing */
+ status: SettingsLicenseStatus | null;
+ checkError: string | null;
+ activateError: string | null;
+ activating: boolean;
+}
+
+export interface SettingsView {
+ app: SettingsAppInfo;
+ values: SettingsValues;
+ /** Null while the themes load */
+ themes: SettingsThemesView | null;
+ /** Null while the installed fonts load */
+ fonts: SettingsFontsView | null;
+ /** Show the row that hides the window controls, which only Windows and Linux draw */
+ windowControlsSetting: boolean;
+ /** Null when this build has no updater */
+ updates: SettingsUpdatesView | null;
+ /** Show the row that turns feedback prompts off */
+ feedbackPrompts: boolean;
+ /** Null when this build has no license */
+ license: SettingsLicenseView | null;
+}
+
+export interface SettingsCallbacks {
+ onChange: (patch: SettingsPatch) => void | Promise;
+ /** Save the native title bar choice and restart, since it only applies at launch */
+ onApplyNativeTitlebar: (useNativeTitlebar: boolean) => void | Promise;
+ onCheckForUpdates: () => void;
+ onActivateLicense: (licenseKey: string) => Promise;
+ onDeactivateLicense: () => void;
+ /** Open a link outside the app */
+ onOpenUrl: (url: string) => void;
+ /** Show a file or folder in the file manager */
+ onRevealPath: (path: string) => void;
+ /** Where to buy a license, tagged with where the click came from */
+ pricingUrl: (intent: string) => string;
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 006b12733..f0276fa99 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -41,6 +41,8 @@ export type {
} from "./components/EditorHost";
export { EditorHostProvider, useEditorHost } from "./components/EditorHost";
export { EmptyStateText } from "./components/EmptyStateText";
+export type { ExternalLinkProps } from "./components/ExternalLink";
+export { ExternalLink } from "./components/ExternalLink";
export { ConfirmLargeResponse } from "./components/http/ConfirmLargeResponse";
export { FormMultipartEditor } from "./components/http/FormMultipartEditor";
export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor";
@@ -55,7 +57,10 @@ export {
REQUEST_TAB_PARAMS,
RequestBodyEditor,
} from "./components/http/HttpRequestPaneView";
-export type { ExtraPaneTab, HttpResponsePaneViewProps } from "./components/http/HttpResponsePaneView";
+export type {
+ ExtraPaneTab,
+ HttpResponsePaneViewProps,
+} from "./components/http/HttpResponsePaneView";
export {
HttpResponsePaneView,
RESPONSE_TAB_BODY,
@@ -97,10 +102,7 @@ export { PairEditor, PairEditorRow } from "./components/pairs/PairEditor";
export { ensurePairId } from "./components/pairs/PairEditor.util";
export { PairOrBulkEditor } from "./components/pairs/PairOrBulkEditor";
export { PillButton } from "./components/PillButton";
-export type {
- RadioDropdownItem,
- RadioDropdownProps,
-} from "./components/RadioDropdown";
+export type { RadioDropdownItem, RadioDropdownProps } from "./components/RadioDropdown";
export { RadioDropdown } from "./components/RadioDropdown";
export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
export { ErrorBoundary } from "./components/ErrorBoundary";
@@ -126,8 +128,46 @@ export { Overlay } from "./components/Overlay";
export type { PlainInputProps } from "./components/PlainInput";
export { PlainInput } from "./components/PlainInput";
export { Portal } from "./components/Portal";
+export type { SelectProps } from "./components/Select";
+export { Select } from "./components/Select";
export type { SeparatorAction } from "./components/Separator";
export { Separator } from "./components/Separator";
+export type { SettingRowBaseProps, SettingSnippetLanguage } from "./components/settings/SettingRow";
+export {
+ SettingCommand,
+ SettingOverrideRow,
+ SettingRow,
+ SettingRowBoolean,
+ SettingRowNumber,
+ SettingRowSelect,
+ SettingRowText,
+ SettingSelectControl,
+ SettingSnippet,
+ SettingsList,
+ SettingsSection,
+ SettingValue,
+} from "./components/settings/SettingRow";
+export type {
+ SettingsDialogProps,
+ SettingsTabDef,
+ SettingsTitlebar,
+} from "./components/settings/SettingsDialog";
+export {
+ SETTINGS_TAB_GENERAL,
+ SETTINGS_TAB_INTERFACE,
+ SETTINGS_TAB_LICENSE,
+ SETTINGS_TAB_THEME,
+ SettingsDialog,
+} from "./components/settings/SettingsDialog";
+export type { SettingsGeneralProps } from "./components/settings/SettingsGeneral";
+export { SettingsGeneral, SettingsUpdatesSection } from "./components/settings/SettingsGeneral";
+export type { SettingsInterfaceProps } from "./components/settings/SettingsInterface";
+export { SettingsInterface } from "./components/settings/SettingsInterface";
+export type { SettingsLicenseProps } from "./components/settings/SettingsLicense";
+export { SettingsLicense } from "./components/settings/SettingsLicense";
+export type { SettingsThemeProps } from "./components/settings/SettingsTheme";
+export { SettingsTheme } from "./components/settings/SettingsTheme";
+export * from "./components/settings/types";
export type { ResizeHandleEvent } from "./components/ResizeHandle";
export { ResizeHandle } from "./components/ResizeHandle";
export { SidebarLayout } from "./components/SidebarLayout";
@@ -219,6 +259,7 @@ export { languageFromContent, languageFromContentType } from "./lib/contentType"
export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "./lib/mimeType";
export { textLikelyContainsJsonComments } from "./lib/jsonComments";
export { validateHttpHeader } from "./lib/validateHttpHeader";
+export { isYaakUrl } from "./lib/yaakUrl";
export { computeSideForDragMove } from "./lib/dnd";
export { docFingerprint } from "./lib/docFingerprint";
export { getNodeText } from "./lib/getNodeText";
diff --git a/packages/ui/src/lib/yaakUrl.test.ts b/packages/ui/src/lib/yaakUrl.test.ts
new file mode 100644
index 000000000..0f68c39b8
--- /dev/null
+++ b/packages/ui/src/lib/yaakUrl.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "vite-plus/test";
+import { isYaakUrl } from "./yaakUrl";
+
+describe("isYaakUrl", () => {
+ test("accepts yaak.app and its subdomains over https", () => {
+ expect(isYaakUrl("https://yaak.app")).toBe(true);
+ expect(isYaakUrl("https://yaak.app/pricing?intent=x")).toBe(true);
+ expect(isYaakUrl("https://docs.yaak.app/plugins")).toBe(true);
+ expect(isYaakUrl("https://YAAK.app/dashboard")).toBe(true);
+ });
+
+ test("rejects hosts that only start or end like yaak.app", () => {
+ expect(isYaakUrl("https://yaak.app.evil.com")).toBe(false);
+ expect(isYaakUrl("https://yaak.app.evil.com/pricing")).toBe(false);
+ expect(isYaakUrl("https://notyaak.app")).toBe(false);
+ expect(isYaakUrl("https://evil.com/?next=https://yaak.app")).toBe(false);
+ expect(isYaakUrl("https://yaak.app@evil.com")).toBe(false);
+ });
+
+ test("rejects other schemes and things that aren't URLs", () => {
+ expect(isYaakUrl("http://yaak.app")).toBe(false);
+ expect(isYaakUrl("javascript:alert(1)//yaak.app")).toBe(false);
+ expect(isYaakUrl("/docs")).toBe(false);
+ expect(isYaakUrl("")).toBe(false);
+ });
+});
diff --git a/packages/ui/src/lib/yaakUrl.ts b/packages/ui/src/lib/yaakUrl.ts
new file mode 100644
index 000000000..19badd69d
--- /dev/null
+++ b/packages/ui/src/lib/yaakUrl.ts
@@ -0,0 +1,15 @@
+/**
+ * Whether a link goes to Yaak's own site: https, on `yaak.app` or one of its subdomains. Parsed
+ * rather than matched on the prefix, which `https://yaak.app.evil.com` would pass.
+ */
+export function isYaakUrl(href: string): boolean {
+ let url: URL;
+ try {
+ url = new URL(href);
+ } catch {
+ return false;
+ }
+ return (
+ url.protocol === "https:" && (url.hostname === "yaak.app" || url.hostname.endsWith(".yaak.app"))
+ );
+}