Share Yaak's settings dialog (#817)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and GitHub committed 2026-11-06 10:37:42 -07:00
1 parent 1129f7aa93
commit 603a4ce364
27 files changed
+2680 -1414

No files matched your search

+64 -106
View File
@@ -1,13 +1,19 @@
import { platform } from "@yaakapp-internal/platform"; import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license"; import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models"; import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui"; import {
import classNames from "classnames"; 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 { useAtomValue } from "jotai";
import { useKeyPressEvent } from "react-use"; import { useKeyPressEvent } from "react-use";
import { appInfo } from "../../lib/appInfo"; import { appInfo } from "../../lib/appInfo";
import { capitalize } from "../../lib/capitalize"; import { capitalize } from "../../lib/capitalize";
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
import { SettingsAgents } from "./SettingsAgents"; import { SettingsAgents } from "./SettingsAgents";
import { SettingsCertificates } from "./SettingsCertificates"; import { SettingsCertificates } from "./SettingsCertificates";
import { SettingsGeneral } from "./SettingsGeneral"; import { SettingsGeneral } from "./SettingsGeneral";
@@ -24,15 +30,15 @@ interface Props {
hide?: () => void; hide?: () => void;
} }
const TAB_GENERAL = "general"; const TAB_GENERAL = SETTINGS_TAB_GENERAL;
const TAB_INTERFACE = "interface"; const TAB_INTERFACE = SETTINGS_TAB_INTERFACE;
const TAB_THEME = "theme"; const TAB_THEME = SETTINGS_TAB_THEME;
const TAB_SHORTCUTS = "shortcuts"; const TAB_SHORTCUTS = "shortcuts";
const TAB_PROXY = "proxy"; const TAB_PROXY = "proxy";
const TAB_CERTIFICATES = "certificates"; const TAB_CERTIFICATES = "certificates";
const TAB_PLUGINS = "plugins"; const TAB_PLUGINS = "plugins";
const TAB_AGENTS = "agents"; const TAB_AGENTS = "agents";
const TAB_LICENSE = "license"; const TAB_LICENSE = SETTINGS_TAB_LICENSE;
const tabs = [ const tabs = [
TAB_GENERAL, TAB_GENERAL,
TAB_THEME, TAB_THEME,
@@ -60,105 +66,57 @@ export default function Settings({ tab, hide }: Props) {
if (hide == null) await platform.window.close(); if (hide == null) await platform.window.close();
}); });
const tabDefs: Record<SettingsTab, Omit<SettingsTabDef, "value" | "label">> = {
[TAB_GENERAL]: { icon: "settings", content: <SettingsGeneral /> },
[TAB_THEME]: { icon: "palette", content: <SettingsTheme /> },
[TAB_INTERFACE]: { icon: "columns_2", content: <SettingsInterface /> },
[TAB_SHORTCUTS]: { icon: "keyboard", content: <SettingsHotkeys /> },
[TAB_PLUGINS]: {
icon: "puzzle",
rightSlot: <CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />,
content: <SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />,
contentClassName: "h-full grid grid-rows-1",
},
[TAB_AGENTS]: {
icon: "sparkles",
hidden: !platform.capabilities.agents,
content: <SettingsAgents />,
},
[TAB_CERTIFICATES]: {
icon: "shield_check",
rightSlot: <CountBadge count={settings.clientCertificates.length} />,
content: <SettingsCertificates />,
},
[TAB_PROXY]: {
icon: "wifi",
rightSlot: settings.proxy?.type === "enabled" ? <CountBadge count /> : null,
content: <SettingsProxy />,
},
[TAB_LICENSE]: {
icon: "key_round",
hidden: !appInfo.featureLicense,
rightSlot:
licenseCheck.check.data?.status === "personal_use" ? (
<CountBadge count color="notice" />
) : null,
content: <SettingsLicense />,
},
};
return ( return (
<div className={classNames("grid grid-rows-[auto_minmax(0,1fr)] h-full")}> <SettingsDialog
{hide ? ( defaultTab={mainTab}
<span /> titlebar={
) : ( hide
<HeaderSize ? null
data-tauri-drag-region : {
ignoreControlsSpacing osType: platform.osType(),
onlyXWindowControl hideWindowControls: settings.hideWindowControls,
size="md" useNativeTitlebar: settings.useNativeTitlebar,
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold" interfaceScale: settings.interfaceScale,
osType={platform.osType()} }
hideWindowControls={settings.hideWindowControls} }
useNativeTitlebar={settings.useNativeTitlebar} tabs={tabs.map((value) => ({ value, label: capitalize(value), ...tabDefs[value] }))}
interfaceScale={settings.interfaceScale} />
>
<HStack
space={2}
justifyContent="center"
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
>
<div className={classNames(platform.osType() === "macos" ? "text-center" : "pl-2")}>Settings</div>
</HStack>
</HeaderSize>
)}
<Tabs
layout="horizontal"
defaultValue={mainTab}
addBorders
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
tabs={tabs.map(
(value): TabItem => ({
value,
label: capitalize(value),
hidden:
(!appInfo.featureLicense && value === TAB_LICENSE) ||
(!platform.capabilities.agents && value === TAB_AGENTS),
leftSlot:
value === TAB_GENERAL ? (
<Icon icon="settings" className="text-secondary" />
) : value === TAB_THEME ? (
<Icon icon="palette" className="text-secondary" />
) : value === TAB_INTERFACE ? (
<Icon icon="columns_2" className="text-secondary" />
) : value === TAB_SHORTCUTS ? (
<Icon icon="keyboard" className="text-secondary" />
) : value === TAB_CERTIFICATES ? (
<Icon icon="shield_check" className="text-secondary" />
) : value === TAB_PROXY ? (
<Icon icon="wifi" className="text-secondary" />
) : value === TAB_PLUGINS ? (
<Icon icon="puzzle" className="text-secondary" />
) : value === TAB_AGENTS ? (
<Icon icon="sparkles" className="text-secondary" />
) : value === TAB_LICENSE ? (
<Icon icon="key_round" className="text-secondary" />
) : null,
rightSlot:
value === TAB_CERTIFICATES ? (
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
<CountBadge count color="notice" />
) : null,
}),
)}
>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!">
<SettingsGeneral />
</TabContent>
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsInterface />
</TabContent>
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!">
<SettingsTheme />
</TabContent>
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsHotkeys />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_AGENTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsAgents />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
<SettingsProxy />
</TabContent>
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!">
<SettingsCertificates />
</TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsLicense />
</TabContent>
</Tabs>
</div>
); );
} }
@@ -1,32 +1,17 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui"; import { SettingsGeneral as SharedSettingsGeneral } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates"; import { useSettingsView } from "../../hooks/useSettingsView";
import { appInfo } from "../../lib/appInfo";
import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature";
import { CommercialUseBanner } from "../CommercialUseBanner"; import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner"; 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"; const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
export function SettingsGeneral() { export function SettingsGeneral() {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const checkForUpdates = useCheckForUpdates(); const bound = useSettingsView();
if (settings == null) { if (settings == null || bound == null) {
return null; return null;
} }
@@ -34,147 +19,24 @@ export function SettingsGeneral() {
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT; settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
return ( return (
<VStack space={1.5} className="mb-4"> <SharedSettingsGeneral
<div> view={bound.view}
<Heading>General</Heading> callbacks={bound.callbacks}
<p className="text-text-subtle"> banner={<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />}
Configure general settings for update behavior and more. extraSections={
</p> showWorkspaceSettingsMovedBanner && (
</div>
<div className="mt-3 mb-5">
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
</div>
<SettingsList className="space-y-8">
<CargoFeature feature="updater">
<SettingsSection title="Updates">
<SettingRow
title="Update Channel"
description="Choose whether Yaak should use stable releases or beta releases."
>
<div className="grid grid-cols-[12rem_auto] gap-1">
<ModelSettingSelectControl
model={settings}
modelKey="updateChannel"
label="Update Channel"
selectClassName="w-full!"
options={[
{ label: "Stable", value: "stable" },
{ label: "Beta", value: "beta" },
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
</SettingRow>
<SettingRowSelect
title="Update Behavior"
description="Choose whether updates are installed automatically or manually."
name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"}
onChange={(v) =>
patchModel(settings, { autoupdate: v === "auto" })
}
options={[
{ label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" },
]}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="autoDownloadUpdates"
title="Automatically download updates"
description="Download Yaak updates in the background so they are ready to install."
disabled={!settings.autoupdate}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="checkNotifications"
title="Check for notifications"
description="Periodically ping Yaak servers to check for relevant notifications."
/>
<SettingRowBoolean
title="Send anonymous usage statistics"
description="Yaak is local-first and does not collect analytics or usage data."
disabled
checked={false}
onChange={() => {}}
/>
</SettingsSection>
</CargoFeature>
<CargoFeature feature="license">
<SettingsSection title="Feedback">
<SettingRowBoolean
title="Prompt for feedback"
description="Show rare one-time prompts asking how new features are working."
checked={settings.promptFeedback}
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
/>
</SettingsSection>
</CargoFeature>
{showWorkspaceSettingsMovedBanner && (
<DismissibleBanner <DismissibleBanner
id="workspace-settings-moved-2026-06-30" id="workspace-settings-moved-2026-06-30"
color="info" color="info"
className="w-full p-4 max-w-xl mr-auto" className="w-full p-4 max-w-xl mr-auto"
> >
<p> <p>
Workspace specific settings have moved to{" "} Workspace specific settings have moved to <b>Workspace Settings</b>, accessible from
<b>Workspace Settings</b>, accessible from the workspace switcher the workspace switcher menu.
menu.
</p> </p>
</DismissibleBanner> </DismissibleBanner>
)} )
}
<SettingsSection title="App Info"> />
<SettingRow title="Version" description="Current Yaak version.">
<SettingValue value={appInfo.version} />
</SettingRow>
<SettingRow
title="Data Directory"
description="Where Yaak stores application data."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appDataDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
},
]}
/>
</SettingRow>
<SettingRow
title="Logs Directory"
description="Where Yaak writes application logs."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appLogDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
},
]}
/>
</SettingRow>
</SettingsSection>
</SettingsList>
</VStack>
); );
} }
@@ -1,59 +1,30 @@
import { platform } from "@yaakapp-internal/platform";
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license"; 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 { 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 { useAtomValue } from "jotai";
import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useSettingsView } from "../../hooks/useSettingsView";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl"; import { pricingUrl } from "../../lib/pricingUrl";
import { rpc } from "../../lib/rpc";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
import { import { SettingRowBoolean, SettingRowSelect, SettingsSection } from "../core/SettingRow";
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" },
];
export function SettingsInterface() { export function SettingsInterface() {
const workspace = useAtomValue(activeWorkspaceAtom); const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom); 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 null;
} }
return ( return (
<VStack space={1.5} className="mb-4"> <SharedSettingsInterface
<div className="mb-3"> view={bound.view}
<Heading>Interface</Heading> callbacks={bound.callbacks}
<p className="text-text-subtle">Tweak settings related to the user interface.</p> leadingSections={
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Workspaces"> <SettingsSection title="Workspaces">
<SettingRowSelect <SettingRowSelect
title="Open workspace behavior" title="Open workspace behavior"
@@ -78,151 +49,13 @@ export function SettingsInterface() {
]} ]}
/> />
</SettingsSection> </SettingsSection>
}
<SettingsSection title="Fonts"> trailingSections={
<SettingRow
title="Interface font"
description="Font used for Yaak interface controls."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="uiFont"
label="Interface font"
selectClassName="w-72!"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.uiFonts.map((f) => ({ 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 });
}}
/>
)}
<SettingSelectControl
name="interfaceFontSize"
label="Interface Font Size"
selectClassName="w-20!"
value={`${settings.interfaceFontSize}`}
defaultValue="14"
options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/>
</SettingRow>
<SettingRow
title="Editor font"
description="Font used in request and response editors."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="editorFont"
label="Editor font"
selectClassName="w-72!"
value={settings.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const editorFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { editorFont });
}}
/>
)}
<SettingSelectControl
name="editorFontSize"
label="Editor Font Size"
selectClassName="w-20!"
value={`${settings.editorFontSize}`}
defaultValue="12"
options={fontSizeOptions}
onChange={(v) =>
patchModel(settings, {
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
})
}
/>
</SettingRow>
</SettingsSection>
<SettingsSection title="Editor">
<ModelSettingRowSelect
model={settings}
modelKey="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
options={keymaps}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="editorSoftWrap"
title="Wrap editor lines"
description="Wrap long lines in request and response editors."
/>
<ModelSettingRowBoolean
model={settings}
modelKey="coloredMethods"
title="Colorize request methods"
description="Use method-specific colors for HTTP request methods."
/>
</SettingsSection>
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{platform.osType() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
/>
)}
</SettingsSection>
<CargoFeature feature="license"> <CargoFeature feature="license">
<LicenseSettings settings={settings} /> <LicenseSettings settings={settings} />
</CargoFeature> </CargoFeature>
</SettingsList> }
</VStack> />
);
}
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
return (
<SettingRow
title="Native title bar"
description="Use the operating system's standard title bar and window controls."
controlClassName="gap-2"
>
<Checkbox
hideLabel
size="md"
checked={nativeTitlebar}
title="Native title bar"
onChange={setNativeTitlebar}
/>
{settings.useNativeTitlebar !== nativeTitlebar && (
<Button
color="primary"
size="xs"
onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await rpc("cmd_restart");
}}
>
Apply and Restart
</Button>
)}
</SettingRow>
); );
} }
@@ -1,17 +1,6 @@
import { useLicense } from "@yaakapp-internal/license"; import { SettingsLicense as SharedSettingsLicense } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui"; import { useSettingsView } from "../../hooks/useSettingsView";
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 { CargoFeature } from "../CargoFeature"; 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() { export function SettingsLicense() {
return ( return (
@@ -22,166 +11,7 @@ export function SettingsLicense() {
} }
function SettingsLicenseCmp() { function SettingsLicenseCmp() {
const { check, activate, deactivate } = useLicense(); const bound = useSettingsView();
const [key, setKey] = useState<string>(""); if (bound == null) return null;
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false); return <SharedSettingsLicense view={bound.view} callbacks={bound.callbacks} />;
if (check.isPending) {
return null;
}
const renderBanner = () => {
if (!check.data) return null;
switch (check.data.status) {
case "active":
return <Banner color="success">Your license is active 🥳</Banner>;
case "trialing":
return (
<Banner color="info" className="max-w-lg">
<p className="w-full">
<strong>
{pluralizeCount("day", differenceInDays(check.data.data.end, new Date()))}
</strong>{" "}
left to evaluate Yaak for commercial use.
<br />
<span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More
</Link>
</div>
</p>
</Banner>
);
case "personal_use":
return (
<Banner color="notice" className="max-w-lg">
<p className="w-full">
Your commercial-use trial has ended.
<br />
<span className="opacity-50">
You may continue using Yaak for personal use only.
<br />A license is required for commercial use.
</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More
</Link>
</div>
</p>
</Banner>
);
case "inactive":
return (
<Banner color="danger">
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link>{" "}
for more details
</Banner>
);
case "expired":
return (
<Banner color="notice">
Your license expired{" "}
<strong>{formatDate(check.data.data.periodEnd, "MMMM dd, yyyy")}</strong>. Please{" "}
<Link href="https://yaak.app/dashboard">Resubscribe</Link> to continue receiving
updates.
{check.data.data.changesUrl && (
<>
<br />
<Link href={check.data.data.changesUrl}>What's new in latest builds</Link>
</>
)}
</Banner>
);
case "past_due":
return (
<Banner color="danger">
<strong>Your payment method needs attention.</strong>
<br />
To re-activate your license, please{" "}
<Link href={check.data.data.billingUrl}>update your billing info</Link>.
</Banner>
);
case "error":
return (
<Banner color="danger">
License check failed: {check.data.data.message} (Code: {check.data.data.code})
</Banner>
);
}
};
return (
<div className="flex flex-col gap-6 max-w-xl">
{renderBanner()}
{check.error && <Banner color="danger">{check.error}</Banner>}
{activate.error && <Banner color="danger">{activate.error}</Banner>}
{check.data?.status === "active" ? (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={() => deactivate.mutate()}>
Deactivate License
</Button>
<Button
color="secondary"
size="sm"
onClick={() => platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
rightSlot={<Icon icon="external_link" />}
>
Direct Support
</Button>
</HStack>
) : (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}>
Activate License
</Button>
<Button
size="sm"
color="primary"
rightSlot={<Icon icon="external_link" />}
onClick={() =>
platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
}
>
Purchase License
</Button>
</HStack>
)}
{activateFormVisible && (
<VStack
as="form"
space={3}
className="max-w-sm"
onSubmit={async (e) => {
e.preventDefault();
await activate.mutateAsync({ licenseKey: key });
toggleActivateFormVisible();
}}
>
<PlainInput
autoFocus
label="License Key"
name="key"
onChange={setKey}
placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX"
/>
<Button type="submit" color="primary" size="sm" isLoading={activate.isPending}>
Submit
</Button>
</VStack>
)}
</div>
);
} }
@@ -1,173 +1,15 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { SettingsTheme as SharedSettingsTheme } from "@yaakapp-internal/ui";
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { lazy, Suspense } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance"; import { useSettingsView } from "../../hooks/useSettingsView";
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",
];
export function SettingsTheme() { export function SettingsTheme() {
const workspace = useAtomValue(activeWorkspaceAtom); const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom); const bound = useSettingsView();
const appearance = useResolvedAppearance();
const activeTheme = useResolvedTheme();
if (settings == null || workspace == null || activeTheme.data == null) { if (workspace == null || bound == null) {
return null; return null;
} }
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes return <SharedSettingsTheme view={bound.view} callbacks={bound.callbacks} />;
.filter((theme) => !theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
const darkThemes: SelectProps<string>["options"] = activeTheme.data.themes
.filter((theme) => theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Theme</Heading>
<p className="text-text-subtle">
Make Yaak your own by selecting a theme, or{" "}
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
Create Your Own
</Link>
</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Theme">
<ModelSettingRowSelect
model={settings}
modelKey="appearance"
title="Appearance"
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
options={[
{ label: "Automatic", value: "system" },
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
]}
/>
{(settings.appearance === "system" || settings.appearance === "light") && (
<SettingRowSelect
name="lightTheme"
title="Light theme"
description="Theme used when Yaak is in light mode."
value={activeTheme.data.light.id}
options={lightThemes}
onChange={(themeLight) => patchModel(settings, { themeLight })}
/>
)}
{(settings.appearance === "system" || settings.appearance === "dark") && (
<SettingRowSelect
name="darkTheme"
title="Dark theme"
description="Theme used when Yaak is in dark mode."
value={activeTheme.data.dark.id}
options={darkThemes}
onChange={(themeDark) => patchModel(settings, { themeDark })}
/>
)}
</SettingsSection>
<SettingsSection title="Preview">
<VStack
space={3}
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto"
>
<HStack className="text" space={1.5}>
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
<strong>{activeTheme.data.active.label}</strong>
<em>(preview)</em>
</HStack>
<HStack space={1.5} className="w-full">
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
variant="border"
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
</HStack>
<Suspense>
<Editor
defaultValue={[
"let foo = { // Demo code editor",
' foo: ("bar" || "baz" ?? \'qux\'),',
" baz: [1, 10.2, null, false, true],",
"};",
].join("\n")}
heightMode="auto"
language="javascript"
stateKey={null}
/>
</Suspense>
</VStack>
</SettingsSection>
</SettingsList>
</VStack>
);
} }
+28 -32
View File
@@ -3,16 +3,43 @@ import { platform } from "@yaakapp-internal/platform";
import classNames from "classnames"; import classNames from "classnames";
import type { HTMLAttributes } from "react"; import type { HTMLAttributes } from "react";
import { appInfo } from "../../lib/appInfo"; import { appInfo } from "../../lib/appInfo";
import { Icon } from "@yaakapp-internal/ui"; import { ExternalLink, isYaakUrl } from "@yaakapp-internal/ui";
interface Props extends HTMLAttributes<HTMLAnchorElement> { interface Props extends HTMLAttributes<HTMLAnchorElement> {
href: string; href: string;
noUnderline?: boolean; 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) { export function Link({ href, children, noUnderline, className, ...other }: Props) {
const isExternal = href.match(/^https?:\/\//); const isExternal = href.match(/^https?:\/\//);
if (isExternal) {
return (
<ExternalLink
href={withYaakRef(href)}
noUnderline={noUnderline}
onOpen={openExternalUrl}
className={className}
{...other}
>
{children}
</ExternalLink>
);
}
className = classNames( className = classNames(
className, className,
"relative", "relative",
@@ -20,37 +47,6 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
!noUnderline && "underline", !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
<a
href={finalHref}
target="_blank"
rel={isYaakLink ? undefined : "noopener noreferrer"}
onClick={(e) => {
e.preventDefault();
void platform.openUrl(finalHref);
}}
className={className}
{...other}
>
<span className="pr-5">{children}</span>
<Icon
className="inline absolute right-0.5 top-[0.3em] opacity-70 group-hover:opacity-100"
size="xs"
icon="external_link"
/>
</a>
);
}
return ( return (
<RouterLink to={href} className={className} {...other}> <RouterLink to={href} className={className} {...other}>
{children} {children}
+2 -148
View File
@@ -1,148 +1,2 @@
import { platform } from "@yaakapp-internal/platform"; export type { SelectProps } from "@yaakapp-internal/ui";
import { HStack, Label } from "@yaakapp-internal/ui"; export { Select } 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<T extends string> {
name: string;
label: string;
labelPosition?: "top" | "left";
labelClassName?: string;
hideLabel?: boolean;
value: T;
help?: ReactNode;
leftSlot?: ReactNode;
options: RadioDropdownItem<T>[];
onChange: (value: T) => void;
defaultValue?: T;
size?: ButtonProps["size"];
className?: string;
disabled?: boolean;
filterable?: boolean;
}
export function Select<T extends string>({
labelPosition = "top",
name,
help,
labelClassName,
disabled,
hideLabel,
label,
value,
options,
leftSlot,
onChange,
className,
defaultValue,
filterable,
size = "md",
}: SelectProps<T>) {
const [focused, setFocused] = useState<boolean>(false);
const id = `input-${name}`;
const isInvalidSelection = options.find((o) => "value" in o && o.value === value) == null;
const handleChange = (value: T) => {
onChange?.(value);
};
return (
<div
className={classNames(
className,
"x-theme-input",
"w-full",
"pointer-events-auto", // Just in case we're placing in disabled parent
labelPosition === "left" && "grid grid-cols-[auto_1fr] items-center gap-2",
labelPosition === "top" && "flex-row gap-0.5",
)}
>
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
{label}
</Label>
{platform.osType() === "macos" && !filterable ? (
<HStack
space={2}
className={classNames(
"w-full rounded-md text text-sm font-mono",
"pl-2",
"border",
focused && !disabled ? "border-border-focus" : "border-border",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
size === "xs" && "h-xs",
size === "sm" && "h-sm",
size === "md" && "h-md",
)}
>
{leftSlot && <div>{leftSlot}</div>}
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => 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 && <option value={"__NONE__"}>-- Select an Option --</option>}
{options.map((o) => {
if (o.type === "separator") return null;
return (
<option key={o.value} value={o.value}>
{o.label}
{o.value === defaultValue && " (default)"}
</option>
);
})}
</select>
</HStack>
) : (
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
// light mode
<RadioDropdown
value={value}
onChange={handleChange}
items={options.map((o) =>
o.type === "separator" || o.value !== defaultValue
? o
: { ...o, label: <>{o.label} (default)</> },
)}
>
<Button
className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
justify="start"
variant="input"
size={size}
leftSlot={leftSlot}
disabled={disabled}
forDropdown
>
{options.find((o) => o.type !== "separator" && o.value === value)?.label ?? "--"}
</Button>
</RadioDropdown>
)}
</div>
);
}
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",
};
+25 -450
View File
@@ -1,260 +1,36 @@
import type { AnyModel } from "@yaakapp-internal/models"; import type { AnyModel } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models"; import { patchModel } from "@yaakapp-internal/models";
import { Icon } from "@yaakapp-internal/ui"; import {
import classNames from "classnames"; SettingRow,
import type { ReactNode } from "react"; type SettingRowBaseProps,
import { CopyIconButton } from "../CopyIconButton"; SettingRowBoolean,
import { Checkbox } from "./Checkbox"; SettingRowNumber,
import { IconButton, type IconButtonProps } from "./IconButton"; SettingRowSelect,
import { PlainInput } from "./PlainInput"; SettingRowText,
import type { RadioDropdownItem } from "./RadioDropdown"; SettingSelectControl,
import { Select } from "./Select"; } from "@yaakapp-internal/ui";
import { SelectFile } from "../SelectFile"; 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<T, V> = { type ModelKeyOfValue<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never; [K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T]; }[keyof T];
type SettingRowBaseProps = {
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
title: ReactNode;
};
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
return <div className={classNames("w-full", className)}>{children}</div>;
}
export function SettingsSection({
children,
className,
description,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
title: ReactNode | null;
}) {
const showHeader = title != null || description != null;
return (
<section className={classNames(className, "w-full")}>
{showHeader && (
<div className="border-b border-border-subtle pb-2">
{title != null && <div className="text-text-subtle">{title}</div>}
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
</div>
)}
<div className="[&>*:last-child]:border-b-0">{children}</div>
</section>
);
}
export function SettingRow({
children,
className,
controlClassName,
description,
disabled,
title,
}: {
children: ReactNode;
} & SettingRowBaseProps) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"@container border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div
className={classNames(
"grid grid-cols-1 gap-2",
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
)}
>
<div className="min-w-0">
<div className="text text-text">{title}</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
</div>
<div
className={classNames(
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
controlClassName,
)}
>
{children}
</div>
</div>
</div>
);
}
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 (
<>
<span
className={classNames(
className,
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
)}
>
{value}
</span>
{actions?.map((action) => (
<IconButton
key={action.title}
icon={action.icon}
title={action.title}
size="2xs"
iconSize="sm"
onClick={action.onClick}
/>
))}
{enableCopy && textToCopy != null && (
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
)}
</>
);
}
/**
* 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<SettingRowBaseProps, "className">) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div className="flex min-w-0 items-center gap-2 text text-text">
<span>{title}</span>
{done != null && done !== false && (
<span className="inline-flex items-center gap-1 text-sm text-success">
<Icon icon="check" size="sm" />
{done}
</span>
)}
</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
<SettingSnippet className="mt-3" language={language} snippet={snippet} />
</div>
);
}
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 (
<div
className={classNames(
className,
"flex items-start gap-2 rounded-md border border-border-subtle bg-surface-highlight/40 py-1.5 pr-1.5 pl-3",
)}
>
<div className="flex min-h-sm min-w-0 flex-1 items-center font-mono text-editor text-text">
<span className="cursor-text select-text whitespace-pre-wrap [overflow-wrap:anywhere]">
{prompt && (
<span aria-hidden className="mr-2 select-none text-text-subtlest">
$
</span>
)}
{snippet}
</span>
</div>
<CopyIconButton size="sm" variant="border" title="Copy" text={snippet} className="shrink-0" />
</div>
);
}
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 (
<SettingRow title={title} {...props}>
<Checkbox
hideLabel
size={checkboxSize}
checked={checked}
disabled={props.disabled}
title={title}
onChange={onChange}
/>
</SettingRow>
);
}
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({ export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
model, model,
modelKey, modelKey,
@@ -272,50 +48,6 @@ export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfV
); );
} }
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 (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={`${value}`}
validate={validate}
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({ export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
model, model,
modelKey, modelKey,
@@ -334,47 +66,6 @@ export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfVa
); );
} }
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 (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={value}
onChange={onChange}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({ export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
model, model,
modelKey, modelKey,
@@ -438,74 +129,6 @@ export function SettingRowDirectory({
return <SettingRowFile directory noun={noun} {...props} />; return <SettingRowFile directory noun={noun} {...props} />;
} }
export function SettingRowSelect<T extends string>({
defaultValue,
name,
onChange,
options,
selectClassName = "w-48!",
title,
value,
...props
}: {
defaultValue?: T;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<SettingSelectControl
name={name}
label={typeof title === "string" ? title : name}
value={value}
defaultValue={defaultValue}
selectClassName={selectClassName}
disabled={props.disabled}
onChange={onChange}
options={options}
/>
</SettingRow>
);
}
export function SettingSelectControl<T extends string>({
defaultValue,
disabled,
label,
name,
onChange,
options,
selectClassName = "w-48!",
value,
}: {
defaultValue?: T;
disabled?: boolean;
label: string;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
}) {
return (
<Select
hideLabel
name={name}
value={value}
defaultValue={defaultValue}
label={label}
size="sm"
className={selectClassName}
disabled={disabled}
onChange={onChange}
options={options}
/>
);
}
export function ModelSettingSelectControl< export function ModelSettingSelectControl<
M extends AnyModel, M extends AnyModel,
K extends ModelKeyOfValue<M, string>, K extends ModelKeyOfValue<M, string>,
@@ -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 (
<SettingRow
className={className}
controlClassName={controlClassName}
description={description}
disabled={disabled}
title={
<span className="inline-flex items-center gap-1.5">
{title}
{overridden && (
<IconButton
icon="undo_2"
size="2xs"
iconSize="sm"
title={resetTitle}
className="text-text-subtle"
onClick={onResetOverride}
/>
)}
</span>
}
>
{children}
</SettingRow>
);
}
+76
View File
@@ -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 };
}
+126
View File
@@ -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",
});
});
});
+84
View File
@@ -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,
};
}
+212
View File
@@ -0,0 +1,212 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak Proxy, shared settings harness</title>
<style>
html,
body,
#root {
height: 100%;
margin: 0;
}
</style>
</head>
<body class="text-base">
<!--
Dev only, served by `vp dev` at /harness-settings.html?theme=dark&tab=proxy; the build's only
entry is index.html. Renders the settings dialog shared from packages/ui with a stub settings
view and one tab of the proxy's own, passed in as a slot, until the proxy has a real one.
-->
<div id="root"></div>
<script>
// Outside the app there's no Tauri; the platform package still looks for its window
window.__TAURI_INTERNALS__ = {
metadata: {
currentWindow: { label: "harness" },
currentWebview: { label: "harness", windowLabel: "harness" },
},
invoke: () => Promise.reject(new Error("Not running in the app")),
transformCallback: () => 0,
};
// What the OS plugin hands the page at launch, which the settings' selects read
window.__TAURI_OS_PLUGIN_INTERNALS__ = { os_type: "macos", platform: "macos" };
</script>
<script type="module" src="/font-size.ts"></script>
<script type="module">
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { applyThemeToDocument, defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
import {
Heading,
SETTINGS_TAB_GENERAL,
SETTINGS_TAB_INTERFACE,
SETTINGS_TAB_LICENSE,
SETTINGS_TAB_THEME,
SettingCommand,
SettingRowBoolean,
SettingRowNumber,
SettingRowSelect,
SettingsDialog,
SettingsGeneral,
SettingsInterface,
SettingsLicense,
SettingsList,
SettingsSection,
SettingsTheme,
VStack,
} from "@yaakapp-internal/ui";
import { createElement as h, StrictMode, useState } from "react";
import { createRoot } from "react-dom/client";
import "/main.css";
const params = new URLSearchParams(location.search);
const dark = params.get("theme") !== "light";
applyThemeToDocument(dark ? defaultDarkTheme : defaultLightTheme);
const themeOption = ({ id, label, dark }) => ({ id, label, dark });
const initialValues = {
appearance: dark ? "dark" : "light",
themeLight: defaultLightTheme.id,
themeDark: defaultDarkTheme.id,
interfaceFont: null,
interfaceFontSize: 15,
editorFont: null,
editorFontSize: 13,
editorKeymap: "default",
editorSoftWrap: true,
coloredMethods: false,
useNativeTitlebar: false,
hideWindowControls: false,
updateChannel: "stable",
autoupdate: true,
autoDownloadUpdates: true,
checkNotifications: true,
promptFeedback: true,
};
function view(values) {
const active = dark ? defaultDarkTheme : defaultLightTheme;
return {
app: {
name: "Yaak Proxy",
version: "2026.11.0",
dataDir: "~/Library/Application Support/app.yaak.proxy",
logDir: "~/Library/Logs/app.yaak.proxy",
revealPathLabel: "Reveal in Finder",
},
values,
themes: {
themes: [defaultDarkTheme, defaultLightTheme].map(themeOption),
light: themeOption(defaultLightTheme),
dark: themeOption(defaultDarkTheme),
active: themeOption(active),
resolvedAppearance: dark ? "dark" : "light",
},
fonts: { uiFonts: [], editorFonts: [] },
windowControlsSetting: false,
updates: { checking: false },
feedbackPrompts: false,
license: {
pending: false,
status: { status: "trialing", trialEnd: new Date(Date.now() + 12.5 * 864e5).toISOString() },
checkError: null,
activateError: null,
activating: false,
},
};
}
/** A stand-in for the proxy's own tab, to show one passes in beside the shared ones */
function ProxyTab() {
const [port, setPort] = useState(9090);
const [httpVersion, setHttpVersion] = useState("h2");
const [atLaunch, setAtLaunch] = useState(true);
return h(VStack, { space: 1.5, className: "mb-4" }, [
h("div", { key: "head", className: "mb-3" }, [
h(Heading, { key: "h" }, "Proxy"),
h(
"p",
{ key: "p", className: "text-text-subtle" },
"How Yaak Proxy listens, what it keeps, and how it sets the system proxy.",
),
]),
h(SettingsList, { key: "list", className: "space-y-8" }, [
h(SettingsSection, { key: "listening", title: "Listening" }, [
h(SettingRowNumber, {
key: "port",
name: "port",
title: "Port",
description: "Where apps send their traffic. Changing it restarts the proxy.",
value: port,
onChange: setPort,
}),
h(SettingRowSelect, {
key: "http",
name: "httpVersion",
title: "HTTP version",
description: "What apps may speak to the proxy; it applies to new connections.",
value: httpVersion,
onChange: setHttpVersion,
options: [
{ label: "HTTP/2 and HTTP/1.1", value: "h2" },
{ label: "HTTP/1.1 only", value: "h1" },
],
}),
]),
h(SettingsSection, { key: "system", title: "System proxy" }, [
h(SettingRowBoolean, {
key: "launch",
title: "Override system proxy at launch",
description: "Routes every app on this Mac through the proxy as soon as Yaak Proxy opens.",
checked: atLaunch,
onChange: setAtLaunch,
}),
]),
h(SettingsSection, { key: "terminal", title: "Terminal" }, [
h(SettingCommand, {
key: "env",
title: "Environment for one terminal",
description: "For a terminal or one app instead of the whole Mac.",
language: "shell",
snippet: `export HTTP_PROXY=http://127.0.0.1:${port}\nexport HTTPS_PROXY=http://127.0.0.1:${port}`,
}),
]),
]),
]);
}
function Harness() {
const [values, setValues] = useState(initialValues);
const settingsView = view(values);
const callbacks = {
onChange: (patch) => setValues((v) => ({ ...v, ...patch })),
onApplyNativeTitlebar: (useNativeTitlebar) => setValues((v) => ({ ...v, useNativeTitlebar })),
onCheckForUpdates: () => console.log("Check for updates"),
onActivateLicense: async (licenseKey) => console.log("Activate", licenseKey),
onDeactivateLicense: () => console.log("Deactivate"),
onOpenUrl: (url) => window.open(url, "_blank"),
onRevealPath: (path) => console.log("Reveal", path),
pricingUrl: (intent) => `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`,
};
const props = { view: settingsView, callbacks };
return h(SettingsDialog, {
defaultTab: params.get("tab") ?? SETTINGS_TAB_GENERAL,
titlebar: { osType: "macos", hideWindowControls: false, useNativeTitlebar: false, interfaceScale: 1 },
tabs: [
{ value: SETTINGS_TAB_GENERAL, label: "General", icon: "settings", content: h(SettingsGeneral, props) },
{ value: SETTINGS_TAB_THEME, label: "Theme", icon: "palette", content: h(SettingsTheme, props) },
{ value: SETTINGS_TAB_INTERFACE, label: "Interface", icon: "columns_2", content: h(SettingsInterface, props) },
{ value: "proxy", label: "Proxy", icon: "wifi", content: h(ProxyTab) },
{ value: SETTINGS_TAB_LICENSE, label: "License", icon: "key_round", content: h(SettingsLicense, props) },
],
});
}
createRoot(document.getElementById("root")).render(
h(StrictMode, null, h(QueryClientProvider, { client: new QueryClient() }, h(Harness))),
);
</script>
</body>
</html>
+1
View File
@@ -15018,6 +15018,7 @@
"name": "@yaakapp-internal/ui", "name": "@yaakapp-internal/ui",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"date-fns": "^4.1.0",
"hexy": "^0.4.0", "hexy": "^0.4.0",
"jotai-family": "^1.0.1" "jotai-family": "^1.0.1"
} }
+1
View File
@@ -19,6 +19,7 @@
"./useToggle": "./src/hooks/useToggle.ts" "./useToggle": "./src/hooks/useToggle.ts"
}, },
"dependencies": { "dependencies": {
"date-fns": "^4.1.0",
"hexy": "^0.4.0", "hexy": "^0.4.0",
"jotai-family": "^1.0.1" "jotai-family": "^1.0.1"
} }
@@ -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<HTMLAnchorElement> {
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
<a
href={href}
target="_blank"
rel={isYaakLink ? undefined : "noopener noreferrer"}
onClick={(e) => {
if (open == null) return;
e.preventDefault();
open(href);
}}
className={classNames(
className,
"relative",
"inline-flex items-center hover:underline group",
!noUnderline && "underline",
)}
{...other}
>
<span className="pr-5">{children}</span>
<Icon
className="inline absolute right-0.5 top-[0.3em] opacity-70 group-hover:opacity-100"
size="xs"
icon="external_link"
/>
</a>
);
}
+149
View File
@@ -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<T extends string> {
name: string;
label: string;
labelPosition?: "top" | "left";
labelClassName?: string;
hideLabel?: boolean;
value: T;
help?: ReactNode;
leftSlot?: ReactNode;
options: RadioDropdownItem<T>[];
onChange: (value: T) => void;
defaultValue?: T;
size?: ButtonProps["size"];
className?: string;
disabled?: boolean;
filterable?: boolean;
}
export function Select<T extends string>({
labelPosition = "top",
name,
help,
labelClassName,
disabled,
hideLabel,
label,
value,
options,
leftSlot,
onChange,
className,
defaultValue,
filterable,
size = "md",
}: SelectProps<T>) {
const [focused, setFocused] = useState<boolean>(false);
const id = `input-${name}`;
const isInvalidSelection = options.find((o) => "value" in o && o.value === value) == null;
const handleChange = (value: T) => {
onChange?.(value);
};
return (
<div
className={classNames(
className,
"x-theme-input",
"w-full",
"pointer-events-auto", // Just in case we're placing in disabled parent
labelPosition === "left" && "grid grid-cols-[auto_1fr] items-center gap-2",
labelPosition === "top" && "flex-row gap-0.5",
)}
>
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
{label}
</Label>
{platform.osType() === "macos" && !filterable ? (
<HStack
space={2}
className={classNames(
"w-full rounded-md text text-sm font-mono",
"pl-2",
"border",
focused && !disabled ? "border-border-focus" : "border-border",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
size === "xs" && "h-xs",
size === "sm" && "h-sm",
size === "md" && "h-md",
)}
>
{leftSlot && <div>{leftSlot}</div>}
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => 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 && <option value={"__NONE__"}>-- Select an Option --</option>}
{options.map((o) => {
if (o.type === "separator") return null;
return (
<option key={o.value} value={o.value}>
{o.label}
{o.value === defaultValue && " (default)"}
</option>
);
})}
</select>
</HStack>
) : (
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
// light mode
<RadioDropdown
value={value}
onChange={handleChange}
items={options.map((o) =>
o.type === "separator" || o.value !== defaultValue
? o
: { ...o, label: <>{o.label} (default)</> },
)}
>
<Button
className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
justify="start"
variant="input"
size={size}
leftSlot={leftSlot}
disabled={disabled}
forDropdown
>
{options.find((o) => o.type !== "separator" && o.value === value)?.label ?? "--"}
</Button>
</RadioDropdown>
)}
</div>
);
}
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",
};
@@ -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<CopyIconButtonProps, "copy">) {
const host = useEditorHost();
return <BaseCopyIconButton {...props} copy={host.copy} />;
}
export type SettingRowBaseProps = {
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
title: ReactNode;
};
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
return <div className={classNames("w-full", className)}>{children}</div>;
}
export function SettingsSection({
children,
className,
description,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
title: ReactNode | null;
}) {
const showHeader = title != null || description != null;
return (
<section className={classNames(className, "w-full")}>
{showHeader && (
<div className="border-b border-border-subtle pb-2">
{title != null && <div className="text-text-subtle">{title}</div>}
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
</div>
)}
<div className="[&>*:last-child]:border-b-0">{children}</div>
</section>
);
}
export function SettingRow({
children,
className,
controlClassName,
description,
disabled,
title,
}: {
children: ReactNode;
} & SettingRowBaseProps) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"@container border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div
className={classNames(
"grid grid-cols-1 gap-2",
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
)}
>
<div className="min-w-0">
<div className="text text-text">{title}</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
</div>
<div
className={classNames(
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
controlClassName,
)}
>
{children}
</div>
</div>
</div>
);
}
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 (
<>
<span
className={classNames(
className,
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
)}
>
{value}
</span>
{actions?.map((action) => (
<IconButton
key={action.title}
icon={action.icon}
title={action.title}
size="2xs"
iconSize="sm"
onClick={action.onClick}
/>
))}
{enableCopy && textToCopy != null && (
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
)}
</>
);
}
/**
* 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<SettingRowBaseProps, "className">) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div className="flex min-w-0 items-center gap-2 text text-text">
<span>{title}</span>
{done != null && done !== false && (
<span className="inline-flex items-center gap-1 text-sm text-success">
<Icon icon="check" size="sm" />
{done}
</span>
)}
</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
<SettingSnippet className="mt-3" language={language} snippet={snippet} />
</div>
);
}
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 (
<div
className={classNames(
className,
"flex items-start gap-2 rounded-md border border-border-subtle bg-surface-highlight/40 py-1.5 pr-1.5 pl-3",
)}
>
<div className="flex min-h-sm min-w-0 flex-1 items-center font-mono text-editor text-text">
<span className="cursor-text select-text whitespace-pre-wrap [overflow-wrap:anywhere]">
{prompt && (
<span aria-hidden className="mr-2 select-none text-text-subtlest">
$
</span>
)}
{snippet}
</span>
</div>
<CopyIconButton size="sm" variant="border" title="Copy" text={snippet} className="shrink-0" />
</div>
);
}
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 (
<SettingRow title={title} {...props}>
<Checkbox
hideLabel
size={checkboxSize}
checked={checked}
disabled={props.disabled}
title={title}
onChange={onChange}
/>
</SettingRow>
);
}
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 (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={`${value}`}
validate={validate}
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
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 (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={value}
onChange={onChange}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function SettingRowSelect<T extends string>({
defaultValue,
name,
onChange,
options,
selectClassName = "w-48!",
title,
value,
...props
}: {
defaultValue?: T;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<SettingSelectControl
name={name}
label={typeof title === "string" ? title : name}
value={value}
defaultValue={defaultValue}
selectClassName={selectClassName}
disabled={props.disabled}
onChange={onChange}
options={options}
/>
</SettingRow>
);
}
export function SettingSelectControl<T extends string>({
defaultValue,
disabled,
label,
name,
onChange,
options,
selectClassName = "w-48!",
value,
}: {
defaultValue?: T;
disabled?: boolean;
label: string;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
}) {
return (
<Select
hideLabel
name={name}
value={value}
defaultValue={defaultValue}
label={label}
size="sm"
className={selectClassName}
disabled={disabled}
onChange={onChange}
options={options}
/>
);
}
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 (
<SettingRow
className={className}
controlClassName={controlClassName}
description={description}
disabled={disabled}
title={
<span className="inline-flex items-center gap-1.5">
{title}
{overridden && (
<IconButton
icon="undo_2"
size="2xs"
iconSize="sm"
title={resetTitle}
className="text-text-subtle"
onClick={onResetOverride}
/>
)}
</span>
}
>
{children}
</SettingRow>
);
}
@@ -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 (
<div className={classNames("grid grid-rows-[auto_minmax(0,1fr)] h-full")}>
{titlebar == null ? (
<span />
) : (
<HeaderSize
data-tauri-drag-region
ignoreControlsSpacing
onlyXWindowControl
size="md"
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
osType={titlebar.osType}
hideWindowControls={titlebar.hideWindowControls}
useNativeTitlebar={titlebar.useNativeTitlebar}
interfaceScale={titlebar.interfaceScale}
>
<HStack
space={2}
justifyContent="center"
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
>
<div className={classNames(titlebar.osType === "macos" ? "text-center" : "pl-2")}>
{title}
</div>
</HStack>
</HeaderSize>
)}
<Tabs
layout="horizontal"
defaultValue={defaultTab}
addBorders
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label={title}
tabs={tabs.map((tab): TabItem => ({
value: tab.value,
label: tab.label,
hidden: tab.hidden,
leftSlot: <Icon icon={tab.icon} className="text-secondary" />,
rightSlot: tab.rightSlot ?? null,
}))}
>
{tabs.map((tab) => (
<TabContent
key={tab.value}
value={tab.value}
className={tab.contentClassName ?? DEFAULT_CONTENT_CLASS}
ErrorBoundary={ErrorBoundary}
>
{tab.content}
</TabContent>
))}
</Tabs>
</div>
);
}
@@ -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 (
<VStack space={1.5} className="mb-4">
<div>
<Heading>General</Heading>
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
</div>
{banner != null && <div className="mt-3 mb-5">{banner}</div>}
<SettingsList className="space-y-8">
{view.updates != null && <SettingsUpdatesSection view={view} callbacks={callbacks} />}
{view.feedbackPrompts && (
<SettingsSection title="Feedback">
<SettingRowBoolean
title="Prompt for feedback"
description="Show rare one-time prompts asking how new features are working."
checked={values.promptFeedback}
onChange={(promptFeedback) => callbacks.onChange({ promptFeedback })}
/>
</SettingsSection>
)}
{extraSections}
<SettingsSection title="App Info">
<SettingRow title="Version" description={`Current ${app.name} version.`}>
<SettingValue value={app.version} />
</SettingRow>
<SettingRow
title="Data Directory"
description={`Where ${app.name} stores application data.`}
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={app.dataDir}
actions={[
{
title: app.revealPathLabel,
icon: "folder_open",
onClick: () => callbacks.onRevealPath(app.dataDir),
},
]}
/>
</SettingRow>
<SettingRow
title="Logs Directory"
description={`Where ${app.name} writes application logs.`}
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={app.logDir}
actions={[
{
title: app.revealPathLabel,
icon: "folder_open",
onClick: () => callbacks.onRevealPath(app.logDir),
},
]}
/>
</SettingRow>
</SettingsSection>
</SettingsList>
</VStack>
);
}
/** The update channel, how updates install, and the check button */
export function SettingsUpdatesSection({
view,
callbacks,
}: {
view: Pick<SettingsView, "app" | "values" | "updates">;
callbacks: Pick<SettingsCallbacks, "onChange" | "onCheckForUpdates">;
}) {
const { app, values } = view;
return (
<SettingsSection title="Updates">
<SettingRow
title="Update Channel"
description={`Choose whether ${app.name} should use stable releases or beta releases.`}
>
<div className="grid grid-cols-[12rem_auto] gap-1">
<SettingSelectControl
name="updateChannel"
label="Update Channel"
selectClassName="w-full!"
value={values.updateChannel}
onChange={(updateChannel) => callbacks.onChange({ updateChannel })}
options={[
{ label: "Stable", value: "stable" },
{ label: "Beta", value: "beta" },
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={view.updates?.checking ?? false}
onClick={() => callbacks.onCheckForUpdates()}
/>
</div>
</SettingRow>
<SettingRowSelect
title="Update Behavior"
description="Choose whether updates are installed automatically or manually."
name="autoupdate"
value={values.autoupdate ? "auto" : "manual"}
onChange={(v) => callbacks.onChange({ autoupdate: v === "auto" })}
options={[
{ label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" },
]}
/>
<SettingRowBoolean
title="Automatically download updates"
description={`Download ${app.name} updates in the background so they are ready to install.`}
disabled={!values.autoupdate}
checked={values.autoDownloadUpdates}
onChange={(autoDownloadUpdates) => callbacks.onChange({ autoDownloadUpdates })}
/>
<SettingRowBoolean
title="Check for notifications"
description="Periodically ping Yaak servers to check for relevant notifications."
checked={values.checkNotifications}
onChange={(checkNotifications) => callbacks.onChange({ checkNotifications })}
/>
<SettingRowBoolean
title="Send anonymous usage statistics"
description={`${app.name} is local-first and does not collect analytics or usage data.`}
disabled
checked={false}
onChange={() => {}}
/>
</SettingsSection>
);
}
@@ -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 (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div>
<SettingsList className="space-y-8">
{leadingSections}
<SettingsSection title="Fonts">
<SettingRow
title="Interface font"
description={`Font used for ${app.name} interface controls.`}
controlClassName="gap-1"
>
{fonts && (
<SettingSelectControl
name="uiFont"
label="Interface font"
selectClassName="w-72!"
value={values.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.uiFonts.map((f) => ({ label: f, value: f })),
...fonts.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={(v) =>
callbacks.onChange({ interfaceFont: v === NULL_FONT_VALUE ? null : v })
}
/>
)}
<SettingSelectControl
name="interfaceFontSize"
label="Interface Font Size"
selectClassName="w-20!"
value={`${values.interfaceFontSize}`}
defaultValue="14"
options={fontSizeOptions}
onChange={(v) => callbacks.onChange({ interfaceFontSize: Number.parseInt(v, 10) })}
/>
</SettingRow>
<SettingRow
title="Editor font"
description="Font used in request and response editors."
controlClassName="gap-1"
>
{fonts && (
<SettingSelectControl
name="editorFont"
label="Editor font"
selectClassName="w-72!"
value={values.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={(v) =>
callbacks.onChange({ editorFont: v === NULL_FONT_VALUE ? null : v })
}
/>
)}
<SettingSelectControl
name="editorFontSize"
label="Editor Font Size"
selectClassName="w-20!"
value={`${values.editorFontSize}`}
defaultValue="12"
options={fontSizeOptions}
onChange={(v) =>
callbacks.onChange({
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
})
}
/>
</SettingRow>
</SettingsSection>
<SettingsSection title="Editor">
<SettingRowSelect
name="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
value={values.editorKeymap}
onChange={(editorKeymap) => callbacks.onChange({ editorKeymap })}
options={keymaps}
/>
<SettingRowBoolean
title="Wrap editor lines"
description="Wrap long lines in request and response editors."
checked={values.editorSoftWrap}
onChange={(editorSoftWrap) => callbacks.onChange({ editorSoftWrap })}
/>
<SettingRowBoolean
title="Colorize request methods"
description="Use method-specific colors for HTTP request methods."
checked={values.coloredMethods}
onChange={(coloredMethods) => callbacks.onChange({ coloredMethods })}
/>
</SettingsSection>
<SettingsSection title="Window">
<NativeTitlebarSetting
useNativeTitlebar={values.useNativeTitlebar}
onApply={callbacks.onApplyNativeTitlebar}
/>
{view.windowControlsSetting && (
<SettingRowBoolean
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
checked={values.hideWindowControls}
onChange={(hideWindowControls) => callbacks.onChange({ hideWindowControls })}
/>
)}
</SettingsSection>
{trailingSections}
</SettingsList>
</VStack>
);
}
function NativeTitlebarSetting({
useNativeTitlebar,
onApply,
}: {
useNativeTitlebar: boolean;
onApply: SettingsCallbacks["onApplyNativeTitlebar"];
}) {
const [nativeTitlebar, setNativeTitlebar] = useState(useNativeTitlebar);
return (
<SettingRow
title="Native title bar"
description="Use the operating system's standard title bar and window controls."
controlClassName="gap-2"
>
<Checkbox
hideLabel
size="md"
checked={nativeTitlebar}
title="Native title bar"
onChange={setNativeTitlebar}
/>
{useNativeTitlebar !== nativeTitlebar && (
<Button color="primary" size="xs" onClick={() => onApply(nativeTitlebar)}>
Apply and Restart
</Button>
)}
</SettingRow>
);
}
@@ -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<SettingsView, "app" | "license">;
callbacks: Pick<
SettingsCallbacks,
"onActivateLicense" | "onDeactivateLicense" | "onOpenUrl" | "pricingUrl"
>;
}
export function SettingsLicense({ view, callbacks }: SettingsLicenseProps) {
const [key, setKey] = useState<string>("");
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
const license = view.license;
if (license == null || license.pending) {
return null;
}
return (
<div className="flex flex-col gap-6 max-w-xl">
{license.status && (
<LicenseStatusBanner
status={license.status}
appName={view.app.name}
callbacks={callbacks}
/>
)}
{license.checkError && <Banner color="danger">{license.checkError}</Banner>}
{license.activateError && <Banner color="danger">{license.activateError}</Banner>}
{license.status?.status === "active" ? (
<HStack space={2}>
<Button
variant="border"
color="secondary"
size="sm"
onClick={() => callbacks.onDeactivateLicense()}
>
Deactivate License
</Button>
<Button
color="secondary"
size="sm"
onClick={() =>
callbacks.onOpenUrl("https://yaak.app/dashboard?intent=app.license.support")
}
rightSlot={<Icon icon="external_link" />}
>
Direct Support
</Button>
</HStack>
) : (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}>
Activate License
</Button>
<Button
size="sm"
color="primary"
rightSlot={<Icon icon="external_link" />}
onClick={() =>
callbacks.onOpenUrl(
callbacks.pricingUrl(`app.license.purchase.${license.status?.status ?? "unknown"}`),
)
}
>
Purchase License
</Button>
</HStack>
)}
{activateFormVisible && (
<VStack
as="form"
space={3}
className="max-w-sm"
onSubmit={async (e) => {
e.preventDefault();
await callbacks.onActivateLicense(key);
toggleActivateFormVisible();
}}
>
<PlainInput
autoFocus
label="License Key"
name="key"
onChange={setKey}
placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX"
/>
<Button type="submit" color="primary" size="sm" isLoading={license.activating}>
Submit
</Button>
</VStack>
)}
</div>
);
}
function LicenseStatusBanner({
status,
appName,
callbacks,
}: {
status: SettingsLicenseStatus;
appName: string;
callbacks: SettingsLicenseProps["callbacks"];
}) {
const link = (href: string, children: string, noUnderline?: boolean) => (
<ExternalLink href={href} noUnderline={noUnderline} onOpen={callbacks.onOpenUrl}>
{children}
</ExternalLink>
);
switch (status.status) {
case "active":
return <Banner color="success">Your license is active 🥳</Banner>;
case "trialing":
return (
<Banner color="info" className="max-w-lg">
<p className="w-full">
<strong>{pluralizeCount("day", differenceInDays(status.trialEnd, new Date()))}</strong>{" "}
{`left to evaluate ${appName} for commercial use.`}
<br />
<span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
{link(callbacks.pricingUrl(`app.license.learn.${status.status}`), "Learn More", true)}
</div>
</p>
</Banner>
);
case "personal_use":
return (
<Banner color="notice" className="max-w-lg">
<p className="w-full">
Your commercial-use trial has ended.
<br />
<span className="opacity-50">
{`You may continue using ${appName} for personal use only.`}
<br />A license is required for commercial use.
</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
{link(callbacks.pricingUrl(`app.license.learn.${status.status}`), "Learn More", true)}
</div>
</p>
</Banner>
);
case "inactive":
return (
<Banner color="danger">
Your license is invalid. Please {link("https://yaak.app/dashboard", "Sign In")} for more
details
</Banner>
);
case "expired":
return (
<Banner color="notice">
Your license expired <strong>{formatDate(status.periodEnd, "MMMM dd, yyyy")}</strong>.
Please {link("https://yaak.app/dashboard", "Resubscribe")} to continue receiving updates.
{status.changesUrl && (
<>
<br />
{link(status.changesUrl, "What's new in latest builds")}
</>
)}
</Banner>
);
case "past_due":
return (
<Banner color="danger">
<strong>Your payment method needs attention.</strong>
<br />
To re-activate your license, please {link(status.billingUrl, "update your billing info")}.
</Banner>
);
case "error":
return (
<Banner color="danger">
License check failed: {status.message} (Code: {status.code})
</Banner>
);
}
}
@@ -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<string>["options"] = themes.themes
.filter((theme) => !theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
const darkThemes: SelectProps<string>["options"] = themes.themes
.filter((theme) => theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Theme</Heading>
<p className="text-text-subtle">
{/* One text node, so the name kerns with the words around it */}
{`Make ${app.name} your own by selecting a theme, or`}{" "}
<ExternalLink
href="https://yaak.app/docs/plugin-development/plugins-quick-start"
onOpen={callbacks.onOpenUrl}
>
Create Your Own
</ExternalLink>
</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Theme">
<SettingRowSelect
name="appearance"
title="Appearance"
description={`Choose whether ${app.name} follows your system appearance or uses a fixed mode.`}
value={values.appearance}
onChange={(appearance) => callbacks.onChange({ appearance })}
options={[
{ label: "Automatic", value: "system" },
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
]}
/>
{(values.appearance === "system" || values.appearance === "light") && (
<SettingRowSelect
name="lightTheme"
title="Light theme"
description={`Theme used when ${app.name} is in light mode.`}
value={themes.light.id}
options={lightThemes}
onChange={(themeLight) => callbacks.onChange({ themeLight })}
/>
)}
{(values.appearance === "system" || values.appearance === "dark") && (
<SettingRowSelect
name="darkTheme"
title="Dark theme"
description={`Theme used when ${app.name} is in dark mode.`}
value={themes.dark.id}
options={darkThemes}
onChange={(themeDark) => callbacks.onChange({ themeDark })}
/>
)}
</SettingsSection>
<SettingsSection title="Preview">
<VStack
space={3}
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto"
>
<HStack className="text" space={1.5}>
<Icon icon={themes.resolvedAppearance === "dark" ? "moon" : "sun"} />
<strong>{themes.active.label}</strong>
<em>(preview)</em>
</HStack>
<HStack space={1.5} className="w-full">
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
variant="border"
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
</HStack>
<Suspense>
<Editor
defaultValue={[
"let foo = { // Demo code editor",
' foo: ("bar" || "baz" ?? \'qux\'),',
" baz: [1, 10.2, null, false, true],",
"};",
].join("\n")}
heightMode="auto"
language="javascript"
stateKey={null}
/>
</Suspense>
</VStack>
</SettingsSection>
</SettingsList>
</VStack>
);
}
@@ -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 <div data-editor>{defaultValue}</div>;
}
function render(node: ReactNode) {
return renderToStaticMarkup(
<EditorHostProvider value={{ Editor: StubEditor }}>{node}</EditorHostProvider>,
);
}
/** Text only, so assertions read like the screen */
function text(html: string) {
return html
.replace(/<[^>]+>/g, " ")
.replace(/&#x27;/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> = {}): 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(<SettingsGeneral view={stubView()} callbacks={callbacks} />);
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(/<option value="beta" selected="">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(<SettingsGeneral view={stubView({ updates: null })} callbacks={callbacks} />),
);
expect(without).not.toContain("Update Channel");
expect(without).not.toContain("Prompt for feedback");
const withFeedback = text(
render(<SettingsGeneral view={stubView({ feedbackPrompts: true })} callbacks={callbacks} />),
);
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(
<SettingsGeneral
view={stubView()}
callbacks={callbacks}
banner={<p>Banner slot</p>}
extraSections={<p>Extra section</p>}
/>,
),
);
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(<SettingsTheme view={stubView({ themes: null })} callbacks={callbacks} />)).toBe(
"",
);
});
test("selects the resolved themes and splits them by appearance", () => {
const html = render(<SettingsTheme view={stubView()} callbacks={callbacks} />);
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(/<option value="hotdog" selected="">Hotdog Stand<\/option>/);
expect(html).toMatch(/<option value="yaak-dark" selected="">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(
<SettingsTheme
view={{ ...view, values: { ...view.values, appearance: "light" } }}
callbacks={callbacks}
/>,
);
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(
<SettingsInterface
view={stubView()}
callbacks={callbacks}
leadingSections={<p>Leading</p>}
trailingSections={<p>Trailing</p>}
/>,
);
const t = text(html);
expect(t).toContain("Font used for Yaak Proxy interface controls.");
expect(html).toMatch(/<option value="15" selected="">15<\/option>/);
expect(html).toMatch(/<option value="Menlo" selected="">Menlo<\/option>/);
expect(html).toMatch(/<option value="vim" selected="">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(
<SettingsInterface
view={stubView({ windowControlsSetting: true })}
callbacks={callbacks}
/>,
),
);
expect(t).toContain("Hide window controls");
});
});
describe("SettingsLicense", () => {
function renderStatus(status: SettingsLicenseStatus | null, extra = {}) {
const view = stubView();
return text(
render(
<SettingsLicense
view={{
...view,
license: {
...(view.license as NonNullable<SettingsView["license"]>),
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(<SettingsLicense view={stubView({ license: null })} callbacks={callbacks} />),
).toBe("");
});
});
describe("SettingsDialog", () => {
test("lists shared and app tabs in order and shows the default one", () => {
const view = stubView();
const html = render(
<SettingsDialog
titlebar={null}
defaultTab="proxy"
tabs={[
{
value: "general",
label: "General",
icon: "settings",
content: <SettingsGeneral view={view} callbacks={callbacks} />,
},
{ value: "proxy", label: "Proxy", icon: "wifi", content: <p>Proxy tab content</p> },
{ 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");
});
});
@@ -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<SettingsValues>;
/** 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<void>;
/** Save the native title bar choice and restart, since it only applies at launch */
onApplyNativeTitlebar: (useNativeTitlebar: boolean) => void | Promise<void>;
onCheckForUpdates: () => void;
onActivateLicense: (licenseKey: string) => Promise<void>;
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;
}
+46 -5
View File
@@ -41,6 +41,8 @@ export type {
} from "./components/EditorHost"; } from "./components/EditorHost";
export { EditorHostProvider, useEditorHost } from "./components/EditorHost"; export { EditorHostProvider, useEditorHost } from "./components/EditorHost";
export { EmptyStateText } from "./components/EmptyStateText"; export { EmptyStateText } from "./components/EmptyStateText";
export type { ExternalLinkProps } from "./components/ExternalLink";
export { ExternalLink } from "./components/ExternalLink";
export { ConfirmLargeResponse } from "./components/http/ConfirmLargeResponse"; export { ConfirmLargeResponse } from "./components/http/ConfirmLargeResponse";
export { FormMultipartEditor } from "./components/http/FormMultipartEditor"; export { FormMultipartEditor } from "./components/http/FormMultipartEditor";
export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor"; export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor";
@@ -55,7 +57,10 @@ export {
REQUEST_TAB_PARAMS, REQUEST_TAB_PARAMS,
RequestBodyEditor, RequestBodyEditor,
} from "./components/http/HttpRequestPaneView"; } from "./components/http/HttpRequestPaneView";
export type { ExtraPaneTab, HttpResponsePaneViewProps } from "./components/http/HttpResponsePaneView"; export type {
ExtraPaneTab,
HttpResponsePaneViewProps,
} from "./components/http/HttpResponsePaneView";
export { export {
HttpResponsePaneView, HttpResponsePaneView,
RESPONSE_TAB_BODY, RESPONSE_TAB_BODY,
@@ -97,10 +102,7 @@ export { PairEditor, PairEditorRow } from "./components/pairs/PairEditor";
export { ensurePairId } from "./components/pairs/PairEditor.util"; export { ensurePairId } from "./components/pairs/PairEditor.util";
export { PairOrBulkEditor } from "./components/pairs/PairOrBulkEditor"; export { PairOrBulkEditor } from "./components/pairs/PairOrBulkEditor";
export { PillButton } from "./components/PillButton"; export { PillButton } from "./components/PillButton";
export type { export type { RadioDropdownItem, RadioDropdownProps } from "./components/RadioDropdown";
RadioDropdownItem,
RadioDropdownProps,
} from "./components/RadioDropdown";
export { RadioDropdown } from "./components/RadioDropdown"; export { RadioDropdown } from "./components/RadioDropdown";
export type { ErrorBoundaryProps } from "./components/ErrorBoundary"; export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
export { ErrorBoundary } from "./components/ErrorBoundary"; export { ErrorBoundary } from "./components/ErrorBoundary";
@@ -126,8 +128,46 @@ export { Overlay } from "./components/Overlay";
export type { PlainInputProps } from "./components/PlainInput"; export type { PlainInputProps } from "./components/PlainInput";
export { PlainInput } from "./components/PlainInput"; export { PlainInput } from "./components/PlainInput";
export { Portal } from "./components/Portal"; export { Portal } from "./components/Portal";
export type { SelectProps } from "./components/Select";
export { Select } from "./components/Select";
export type { SeparatorAction } from "./components/Separator"; export type { SeparatorAction } from "./components/Separator";
export { Separator } 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 type { ResizeHandleEvent } from "./components/ResizeHandle";
export { ResizeHandle } from "./components/ResizeHandle"; export { ResizeHandle } from "./components/ResizeHandle";
export { SidebarLayout } from "./components/SidebarLayout"; export { SidebarLayout } from "./components/SidebarLayout";
@@ -219,6 +259,7 @@ export { languageFromContent, languageFromContentType } from "./lib/contentType"
export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "./lib/mimeType"; export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "./lib/mimeType";
export { textLikelyContainsJsonComments } from "./lib/jsonComments"; export { textLikelyContainsJsonComments } from "./lib/jsonComments";
export { validateHttpHeader } from "./lib/validateHttpHeader"; export { validateHttpHeader } from "./lib/validateHttpHeader";
export { isYaakUrl } from "./lib/yaakUrl";
export { computeSideForDragMove } from "./lib/dnd"; export { computeSideForDragMove } from "./lib/dnd";
export { docFingerprint } from "./lib/docFingerprint"; export { docFingerprint } from "./lib/docFingerprint";
export { getNodeText } from "./lib/getNodeText"; export { getNodeText } from "./lib/getNodeText";
+26
View File
@@ -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);
});
});
+15
View File
@@ -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"))
);
}