mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Share Yaak's settings dialog (#817)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1129f7aa93
commit
603a4ce364
27 files changed
+2680
-1414
No files matched your search
@@ -1,13 +1,19 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
CountBadge,
|
||||
SETTINGS_TAB_GENERAL,
|
||||
SETTINGS_TAB_INTERFACE,
|
||||
SETTINGS_TAB_LICENSE,
|
||||
SETTINGS_TAB_THEME,
|
||||
SettingsDialog,
|
||||
type SettingsTabDef,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useKeyPressEvent } from "react-use";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { capitalize } from "../../lib/capitalize";
|
||||
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
|
||||
import { SettingsAgents } from "./SettingsAgents";
|
||||
import { SettingsCertificates } from "./SettingsCertificates";
|
||||
import { SettingsGeneral } from "./SettingsGeneral";
|
||||
@@ -24,15 +30,15 @@ interface Props {
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
const TAB_GENERAL = "general";
|
||||
const TAB_INTERFACE = "interface";
|
||||
const TAB_THEME = "theme";
|
||||
const TAB_GENERAL = SETTINGS_TAB_GENERAL;
|
||||
const TAB_INTERFACE = SETTINGS_TAB_INTERFACE;
|
||||
const TAB_THEME = SETTINGS_TAB_THEME;
|
||||
const TAB_SHORTCUTS = "shortcuts";
|
||||
const TAB_PROXY = "proxy";
|
||||
const TAB_CERTIFICATES = "certificates";
|
||||
const TAB_PLUGINS = "plugins";
|
||||
const TAB_AGENTS = "agents";
|
||||
const TAB_LICENSE = "license";
|
||||
const TAB_LICENSE = SETTINGS_TAB_LICENSE;
|
||||
const tabs = [
|
||||
TAB_GENERAL,
|
||||
TAB_THEME,
|
||||
@@ -60,105 +66,57 @@ export default function Settings({ tab, hide }: Props) {
|
||||
if (hide == null) await platform.window.close();
|
||||
});
|
||||
|
||||
const tabDefs: Record<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 (
|
||||
<div className={classNames("grid grid-rows-[auto_minmax(0,1fr)] h-full")}>
|
||||
{hide ? (
|
||||
<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={platform.osType()}
|
||||
hideWindowControls={settings.hideWindowControls}
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
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>
|
||||
<SettingsDialog
|
||||
defaultTab={mainTab}
|
||||
titlebar={
|
||||
hide
|
||||
? null
|
||||
: {
|
||||
osType: platform.osType(),
|
||||
hideWindowControls: settings.hideWindowControls,
|
||||
useNativeTitlebar: settings.useNativeTitlebar,
|
||||
interfaceScale: settings.interfaceScale,
|
||||
}
|
||||
}
|
||||
tabs={tabs.map((value) => ({ value, label: capitalize(value), ...tabDefs[value] }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,17 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { SettingsGeneral as SharedSettingsGeneral } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { revealInFinderText } from "../../lib/reveal";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { DismissibleBanner } from "../core/DismissibleBanner";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import {
|
||||
ModelSettingRowBoolean,
|
||||
ModelSettingSelectControl,
|
||||
SettingValue,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowSelect,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const checkForUpdates = useCheckForUpdates();
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null) {
|
||||
if (settings == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,147 +19,24 @@ export function SettingsGeneral() {
|
||||
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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 && (
|
||||
<SharedSettingsGeneral
|
||||
view={bound.view}
|
||||
callbacks={bound.callbacks}
|
||||
banner={<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />}
|
||||
extraSections={
|
||||
showWorkspaceSettingsMovedBanner && (
|
||||
<DismissibleBanner
|
||||
id="workspace-settings-moved-2026-06-30"
|
||||
color="info"
|
||||
className="w-full p-4 max-w-xl mr-auto"
|
||||
>
|
||||
<p>
|
||||
Workspace specific settings have moved to{" "}
|
||||
<b>Workspace Settings</b>, accessible from the workspace switcher
|
||||
menu.
|
||||
Workspace specific settings have moved to <b>Workspace Settings</b>, accessible from
|
||||
the workspace switcher menu.
|
||||
</p>
|
||||
</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 type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
||||
import type { Settings } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
|
||||
import { SettingsInterface as SharedSettingsInterface, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { showConfirm } from "../../lib/confirm";
|
||||
import { pricingUrl } from "../../lib/pricingUrl";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { Link } from "../core/Link";
|
||||
import {
|
||||
ModelSettingRowBoolean,
|
||||
ModelSettingRowSelect,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowSelect,
|
||||
SettingSelectControl,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const NULL_FONT_VALUE = "__NULL_FONT__";
|
||||
|
||||
const fontSizeOptions = [
|
||||
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
|
||||
].map((n) => ({ label: `${n}`, value: `${n}` }));
|
||||
|
||||
const keymaps: { value: EditorKeymap; label: string }[] = [
|
||||
{ value: "default", label: "Default" },
|
||||
{ value: "vim", label: "Vim" },
|
||||
{ value: "vscode", label: "VSCode" },
|
||||
{ value: "emacs", label: "Emacs" },
|
||||
];
|
||||
import { SettingRowBoolean, SettingRowSelect, SettingsSection } from "../core/SettingRow";
|
||||
|
||||
export function SettingsInterface() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const fonts = useFonts();
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null || workspace == null) {
|
||||
if (settings == null || workspace == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<SharedSettingsInterface
|
||||
view={bound.view}
|
||||
callbacks={bound.callbacks}
|
||||
leadingSections={
|
||||
<SettingsSection title="Workspaces">
|
||||
<SettingRowSelect
|
||||
title="Open workspace behavior"
|
||||
@@ -78,151 +49,13 @@ export function SettingsInterface() {
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Fonts">
|
||||
<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>
|
||||
|
||||
}
|
||||
trailingSections={
|
||||
<CargoFeature feature="license">
|
||||
<LicenseSettings settings={settings} />
|
||||
</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 { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import { differenceInDays } from "date-fns";
|
||||
import { formatDate } from "date-fns/format";
|
||||
import { useState } from "react";
|
||||
import { useToggle } from "../../hooks/useToggle";
|
||||
import { pricingUrl } from "../../lib/pricingUrl";
|
||||
import { SettingsLicense as SharedSettingsLicense } from "@yaakapp-internal/ui";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Button } from "../core/Button";
|
||||
import { Link } from "../core/Link";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
import { Separator } from "../core/Separator";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
export function SettingsLicense() {
|
||||
return (
|
||||
@@ -22,166 +11,7 @@ export function SettingsLicense() {
|
||||
}
|
||||
|
||||
function SettingsLicenseCmp() {
|
||||
const { check, activate, deactivate } = useLicense();
|
||||
const [key, setKey] = useState<string>("");
|
||||
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
|
||||
|
||||
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>
|
||||
);
|
||||
const bound = useSettingsView();
|
||||
if (bound == null) return null;
|
||||
return <SharedSettingsLicense view={bound.view} callbacks={bound.callbacks} />;
|
||||
}
|
||||
@@ -1,173 +1,15 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
||||
import { SettingsTheme as SharedSettingsTheme } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
|
||||
import { useResolvedTheme } from "../../hooks/useResolvedTheme";
|
||||
import type { ButtonProps } from "../core/Button";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Link } from "../core/Link";
|
||||
import type { SelectProps } from "../core/Select";
|
||||
import {
|
||||
ModelSettingRowSelect,
|
||||
SettingRowSelect,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
|
||||
|
||||
const buttonColors: ButtonProps["color"][] = [
|
||||
"primary",
|
||||
"info",
|
||||
"success",
|
||||
"notice",
|
||||
"warning",
|
||||
"danger",
|
||||
"secondary",
|
||||
"default",
|
||||
];
|
||||
|
||||
const icons: IconProps["icon"][] = [
|
||||
"info",
|
||||
"box",
|
||||
"update",
|
||||
"alert_triangle",
|
||||
"arrow_big_right_dash",
|
||||
"download",
|
||||
"copy",
|
||||
"magic_wand",
|
||||
"settings",
|
||||
"trash",
|
||||
"sparkles",
|
||||
"pencil",
|
||||
"paste",
|
||||
"search",
|
||||
"send_horizontal",
|
||||
];
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
|
||||
export function SettingsTheme() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const appearance = useResolvedAppearance();
|
||||
const activeTheme = useResolvedTheme();
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null || workspace == null || activeTheme.data == null) {
|
||||
if (workspace == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
||||
.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>
|
||||
);
|
||||
return <SharedSettingsTheme view={bound.view} callbacks={bound.callbacks} />;
|
||||
}
|
||||
@@ -3,16 +3,43 @@ import { platform } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import { ExternalLink, isYaakUrl } from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLAnchorElement> {
|
||||
href: string;
|
||||
noUnderline?: boolean;
|
||||
}
|
||||
|
||||
/** Tags a link to yaak.app with which app it came from */
|
||||
export function withYaakRef(href: string): string {
|
||||
if (!isYaakUrl(href)) return href;
|
||||
const url = new URL(href);
|
||||
url.searchParams.set("ref", appInfo.identifier);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** Opens a link outside the app, tagged as `Link` tags it */
|
||||
export function openExternalUrl(href: string) {
|
||||
void platform.openUrl(withYaakRef(href));
|
||||
}
|
||||
|
||||
export function Link({ href, children, noUnderline, className, ...other }: Props) {
|
||||
const isExternal = href.match(/^https?:\/\//);
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<ExternalLink
|
||||
href={withYaakRef(href)}
|
||||
noUnderline={noUnderline}
|
||||
onOpen={openExternalUrl}
|
||||
className={className}
|
||||
{...other}
|
||||
>
|
||||
{children}
|
||||
</ExternalLink>
|
||||
);
|
||||
}
|
||||
|
||||
className = classNames(
|
||||
className,
|
||||
"relative",
|
||||
@@ -20,37 +47,6 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
|
||||
!noUnderline && "underline",
|
||||
);
|
||||
|
||||
if (isExternal) {
|
||||
const isYaakLink = href.startsWith("https://yaak.app");
|
||||
let finalHref = href;
|
||||
if (isYaakLink) {
|
||||
const url = new URL(href);
|
||||
url.searchParams.set("ref", appInfo.identifier);
|
||||
finalHref = url.toString();
|
||||
}
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-no-target-blank
|
||||
<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 (
|
||||
<RouterLink to={href} className={className} {...other}>
|
||||
{children}
|
||||
|
||||
@@ -1,148 +1,2 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { HStack, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import type { ButtonProps } from "./Button";
|
||||
import { Button } from "./Button";
|
||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
||||
import { RadioDropdown } from "./RadioDropdown";
|
||||
|
||||
export interface SelectProps<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",
|
||||
};
|
||||
export type { SelectProps } from "@yaakapp-internal/ui";
|
||||
export { Select } from "@yaakapp-internal/ui";
|
||||
@@ -1,260 +1,36 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||
import { PlainInput } from "./PlainInput";
|
||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
||||
import { Select } from "./Select";
|
||||
import {
|
||||
SettingRow,
|
||||
type SettingRowBaseProps,
|
||||
SettingRowBoolean,
|
||||
SettingRowNumber,
|
||||
SettingRowSelect,
|
||||
SettingRowText,
|
||||
SettingSelectControl,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { SelectFile } from "../SelectFile";
|
||||
|
||||
export type { SettingRowBaseProps, SettingSnippetLanguage } from "@yaakapp-internal/ui";
|
||||
export {
|
||||
SettingCommand,
|
||||
SettingOverrideRow,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowNumber,
|
||||
SettingRowSelect,
|
||||
SettingRowText,
|
||||
SettingSelectControl,
|
||||
SettingSnippet,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
SettingValue,
|
||||
} from "@yaakapp-internal/ui";
|
||||
|
||||
type ModelKeyOfValue<T, V> = {
|
||||
[K in keyof T]-?: T[K] extends V ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
type SettingRowBaseProps = {
|
||||
className?: string;
|
||||
controlClassName?: string;
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <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>>({
|
||||
model,
|
||||
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>>({
|
||||
model,
|
||||
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>>({
|
||||
model,
|
||||
modelKey,
|
||||
@@ -438,74 +129,6 @@ export function SettingRowDirectory({
|
||||
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<
|
||||
M extends AnyModel,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+1
@@ -15018,6 +15018,7 @@
|
||||
"name": "@yaakapp-internal/ui",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"hexy": "^0.4.0",
|
||||
"jotai-family": "^1.0.1"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"./useToggle": "./src/hooks/useToggle.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"hexy": "^0.4.0",
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -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(/'/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;
|
||||
}
|
||||
@@ -41,6 +41,8 @@ export type {
|
||||
} from "./components/EditorHost";
|
||||
export { EditorHostProvider, useEditorHost } from "./components/EditorHost";
|
||||
export { EmptyStateText } from "./components/EmptyStateText";
|
||||
export type { ExternalLinkProps } from "./components/ExternalLink";
|
||||
export { ExternalLink } from "./components/ExternalLink";
|
||||
export { ConfirmLargeResponse } from "./components/http/ConfirmLargeResponse";
|
||||
export { FormMultipartEditor } from "./components/http/FormMultipartEditor";
|
||||
export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor";
|
||||
@@ -55,7 +57,10 @@ export {
|
||||
REQUEST_TAB_PARAMS,
|
||||
RequestBodyEditor,
|
||||
} from "./components/http/HttpRequestPaneView";
|
||||
export type { ExtraPaneTab, HttpResponsePaneViewProps } from "./components/http/HttpResponsePaneView";
|
||||
export type {
|
||||
ExtraPaneTab,
|
||||
HttpResponsePaneViewProps,
|
||||
} from "./components/http/HttpResponsePaneView";
|
||||
export {
|
||||
HttpResponsePaneView,
|
||||
RESPONSE_TAB_BODY,
|
||||
@@ -97,10 +102,7 @@ export { PairEditor, PairEditorRow } from "./components/pairs/PairEditor";
|
||||
export { ensurePairId } from "./components/pairs/PairEditor.util";
|
||||
export { PairOrBulkEditor } from "./components/pairs/PairOrBulkEditor";
|
||||
export { PillButton } from "./components/PillButton";
|
||||
export type {
|
||||
RadioDropdownItem,
|
||||
RadioDropdownProps,
|
||||
} from "./components/RadioDropdown";
|
||||
export type { RadioDropdownItem, RadioDropdownProps } from "./components/RadioDropdown";
|
||||
export { RadioDropdown } from "./components/RadioDropdown";
|
||||
export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
|
||||
export { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
@@ -126,8 +128,46 @@ export { Overlay } from "./components/Overlay";
|
||||
export type { PlainInputProps } from "./components/PlainInput";
|
||||
export { PlainInput } from "./components/PlainInput";
|
||||
export { Portal } from "./components/Portal";
|
||||
export type { SelectProps } from "./components/Select";
|
||||
export { Select } from "./components/Select";
|
||||
export type { SeparatorAction } from "./components/Separator";
|
||||
export { Separator } from "./components/Separator";
|
||||
export type { SettingRowBaseProps, SettingSnippetLanguage } from "./components/settings/SettingRow";
|
||||
export {
|
||||
SettingCommand,
|
||||
SettingOverrideRow,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowNumber,
|
||||
SettingRowSelect,
|
||||
SettingRowText,
|
||||
SettingSelectControl,
|
||||
SettingSnippet,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
SettingValue,
|
||||
} from "./components/settings/SettingRow";
|
||||
export type {
|
||||
SettingsDialogProps,
|
||||
SettingsTabDef,
|
||||
SettingsTitlebar,
|
||||
} from "./components/settings/SettingsDialog";
|
||||
export {
|
||||
SETTINGS_TAB_GENERAL,
|
||||
SETTINGS_TAB_INTERFACE,
|
||||
SETTINGS_TAB_LICENSE,
|
||||
SETTINGS_TAB_THEME,
|
||||
SettingsDialog,
|
||||
} from "./components/settings/SettingsDialog";
|
||||
export type { SettingsGeneralProps } from "./components/settings/SettingsGeneral";
|
||||
export { SettingsGeneral, SettingsUpdatesSection } from "./components/settings/SettingsGeneral";
|
||||
export type { SettingsInterfaceProps } from "./components/settings/SettingsInterface";
|
||||
export { SettingsInterface } from "./components/settings/SettingsInterface";
|
||||
export type { SettingsLicenseProps } from "./components/settings/SettingsLicense";
|
||||
export { SettingsLicense } from "./components/settings/SettingsLicense";
|
||||
export type { SettingsThemeProps } from "./components/settings/SettingsTheme";
|
||||
export { SettingsTheme } from "./components/settings/SettingsTheme";
|
||||
export * from "./components/settings/types";
|
||||
export type { ResizeHandleEvent } from "./components/ResizeHandle";
|
||||
export { ResizeHandle } from "./components/ResizeHandle";
|
||||
export { SidebarLayout } from "./components/SidebarLayout";
|
||||
@@ -219,6 +259,7 @@ export { languageFromContent, languageFromContentType } from "./lib/contentType"
|
||||
export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "./lib/mimeType";
|
||||
export { textLikelyContainsJsonComments } from "./lib/jsonComments";
|
||||
export { validateHttpHeader } from "./lib/validateHttpHeader";
|
||||
export { isYaakUrl } from "./lib/yaakUrl";
|
||||
export { computeSideForDragMove } from "./lib/dnd";
|
||||
export { docFingerprint } from "./lib/docFingerprint";
|
||||
export { getNodeText } from "./lib/getNodeText";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user