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 { platform } from "@yaakapp-internal/platform";
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui";
|
import {
|
||||||
import classNames from "classnames";
|
CountBadge,
|
||||||
|
SETTINGS_TAB_GENERAL,
|
||||||
|
SETTINGS_TAB_INTERFACE,
|
||||||
|
SETTINGS_TAB_LICENSE,
|
||||||
|
SETTINGS_TAB_THEME,
|
||||||
|
SettingsDialog,
|
||||||
|
type SettingsTabDef,
|
||||||
|
} from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useKeyPressEvent } from "react-use";
|
import { useKeyPressEvent } from "react-use";
|
||||||
import { appInfo } from "../../lib/appInfo";
|
import { appInfo } from "../../lib/appInfo";
|
||||||
import { capitalize } from "../../lib/capitalize";
|
import { capitalize } from "../../lib/capitalize";
|
||||||
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
|
|
||||||
import { SettingsAgents } from "./SettingsAgents";
|
import { SettingsAgents } from "./SettingsAgents";
|
||||||
import { SettingsCertificates } from "./SettingsCertificates";
|
import { SettingsCertificates } from "./SettingsCertificates";
|
||||||
import { SettingsGeneral } from "./SettingsGeneral";
|
import { SettingsGeneral } from "./SettingsGeneral";
|
||||||
@@ -24,15 +30,15 @@ interface Props {
|
|||||||
hide?: () => void;
|
hide?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_GENERAL = "general";
|
const TAB_GENERAL = SETTINGS_TAB_GENERAL;
|
||||||
const TAB_INTERFACE = "interface";
|
const TAB_INTERFACE = SETTINGS_TAB_INTERFACE;
|
||||||
const TAB_THEME = "theme";
|
const TAB_THEME = SETTINGS_TAB_THEME;
|
||||||
const TAB_SHORTCUTS = "shortcuts";
|
const TAB_SHORTCUTS = "shortcuts";
|
||||||
const TAB_PROXY = "proxy";
|
const TAB_PROXY = "proxy";
|
||||||
const TAB_CERTIFICATES = "certificates";
|
const TAB_CERTIFICATES = "certificates";
|
||||||
const TAB_PLUGINS = "plugins";
|
const TAB_PLUGINS = "plugins";
|
||||||
const TAB_AGENTS = "agents";
|
const TAB_AGENTS = "agents";
|
||||||
const TAB_LICENSE = "license";
|
const TAB_LICENSE = SETTINGS_TAB_LICENSE;
|
||||||
const tabs = [
|
const tabs = [
|
||||||
TAB_GENERAL,
|
TAB_GENERAL,
|
||||||
TAB_THEME,
|
TAB_THEME,
|
||||||
@@ -60,105 +66,57 @@ export default function Settings({ tab, hide }: Props) {
|
|||||||
if (hide == null) await platform.window.close();
|
if (hide == null) await platform.window.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tabDefs: Record<SettingsTab, Omit<SettingsTabDef, "value" | "label">> = {
|
||||||
|
[TAB_GENERAL]: { icon: "settings", content: <SettingsGeneral /> },
|
||||||
|
[TAB_THEME]: { icon: "palette", content: <SettingsTheme /> },
|
||||||
|
[TAB_INTERFACE]: { icon: "columns_2", content: <SettingsInterface /> },
|
||||||
|
[TAB_SHORTCUTS]: { icon: "keyboard", content: <SettingsHotkeys /> },
|
||||||
|
[TAB_PLUGINS]: {
|
||||||
|
icon: "puzzle",
|
||||||
|
rightSlot: <CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />,
|
||||||
|
content: <SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />,
|
||||||
|
contentClassName: "h-full grid grid-rows-1",
|
||||||
|
},
|
||||||
|
[TAB_AGENTS]: {
|
||||||
|
icon: "sparkles",
|
||||||
|
hidden: !platform.capabilities.agents,
|
||||||
|
content: <SettingsAgents />,
|
||||||
|
},
|
||||||
|
[TAB_CERTIFICATES]: {
|
||||||
|
icon: "shield_check",
|
||||||
|
rightSlot: <CountBadge count={settings.clientCertificates.length} />,
|
||||||
|
content: <SettingsCertificates />,
|
||||||
|
},
|
||||||
|
[TAB_PROXY]: {
|
||||||
|
icon: "wifi",
|
||||||
|
rightSlot: settings.proxy?.type === "enabled" ? <CountBadge count /> : null,
|
||||||
|
content: <SettingsProxy />,
|
||||||
|
},
|
||||||
|
[TAB_LICENSE]: {
|
||||||
|
icon: "key_round",
|
||||||
|
hidden: !appInfo.featureLicense,
|
||||||
|
rightSlot:
|
||||||
|
licenseCheck.check.data?.status === "personal_use" ? (
|
||||||
|
<CountBadge count color="notice" />
|
||||||
|
) : null,
|
||||||
|
content: <SettingsLicense />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames("grid grid-rows-[auto_minmax(0,1fr)] h-full")}>
|
<SettingsDialog
|
||||||
{hide ? (
|
defaultTab={mainTab}
|
||||||
<span />
|
titlebar={
|
||||||
) : (
|
hide
|
||||||
<HeaderSize
|
? null
|
||||||
data-tauri-drag-region
|
: {
|
||||||
ignoreControlsSpacing
|
osType: platform.osType(),
|
||||||
onlyXWindowControl
|
hideWindowControls: settings.hideWindowControls,
|
||||||
size="md"
|
useNativeTitlebar: settings.useNativeTitlebar,
|
||||||
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
interfaceScale: settings.interfaceScale,
|
||||||
osType={platform.osType()}
|
}
|
||||||
hideWindowControls={settings.hideWindowControls}
|
}
|
||||||
useNativeTitlebar={settings.useNativeTitlebar}
|
tabs={tabs.map((value) => ({ value, label: capitalize(value), ...tabDefs[value] }))}
|
||||||
interfaceScale={settings.interfaceScale}
|
/>
|
||||||
>
|
|
||||||
<HStack
|
|
||||||
space={2}
|
|
||||||
justifyContent="center"
|
|
||||||
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
|
||||||
>
|
|
||||||
<div className={classNames(platform.osType() === "macos" ? "text-center" : "pl-2")}>Settings</div>
|
|
||||||
</HStack>
|
|
||||||
</HeaderSize>
|
|
||||||
)}
|
|
||||||
<Tabs
|
|
||||||
layout="horizontal"
|
|
||||||
defaultValue={mainTab}
|
|
||||||
addBorders
|
|
||||||
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
|
||||||
label="Settings"
|
|
||||||
tabs={tabs.map(
|
|
||||||
(value): TabItem => ({
|
|
||||||
value,
|
|
||||||
label: capitalize(value),
|
|
||||||
hidden:
|
|
||||||
(!appInfo.featureLicense && value === TAB_LICENSE) ||
|
|
||||||
(!platform.capabilities.agents && value === TAB_AGENTS),
|
|
||||||
leftSlot:
|
|
||||||
value === TAB_GENERAL ? (
|
|
||||||
<Icon icon="settings" className="text-secondary" />
|
|
||||||
) : value === TAB_THEME ? (
|
|
||||||
<Icon icon="palette" className="text-secondary" />
|
|
||||||
) : value === TAB_INTERFACE ? (
|
|
||||||
<Icon icon="columns_2" className="text-secondary" />
|
|
||||||
) : value === TAB_SHORTCUTS ? (
|
|
||||||
<Icon icon="keyboard" className="text-secondary" />
|
|
||||||
) : value === TAB_CERTIFICATES ? (
|
|
||||||
<Icon icon="shield_check" className="text-secondary" />
|
|
||||||
) : value === TAB_PROXY ? (
|
|
||||||
<Icon icon="wifi" className="text-secondary" />
|
|
||||||
) : value === TAB_PLUGINS ? (
|
|
||||||
<Icon icon="puzzle" className="text-secondary" />
|
|
||||||
) : value === TAB_AGENTS ? (
|
|
||||||
<Icon icon="sparkles" className="text-secondary" />
|
|
||||||
) : value === TAB_LICENSE ? (
|
|
||||||
<Icon icon="key_round" className="text-secondary" />
|
|
||||||
) : null,
|
|
||||||
rightSlot:
|
|
||||||
value === TAB_CERTIFICATES ? (
|
|
||||||
<CountBadge count={settings.clientCertificates.length} />
|
|
||||||
) : value === TAB_PLUGINS ? (
|
|
||||||
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
|
|
||||||
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
|
|
||||||
<CountBadge count />
|
|
||||||
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
|
|
||||||
<CountBadge count color="notice" />
|
|
||||||
) : null,
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!">
|
|
||||||
<SettingsGeneral />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!">
|
|
||||||
<SettingsInterface />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!">
|
|
||||||
<SettingsTheme />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!">
|
|
||||||
<SettingsHotkeys />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
|
|
||||||
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_AGENTS} className="overflow-y-auto h-full px-6 py-4!">
|
|
||||||
<SettingsAgents />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
|
|
||||||
<SettingsProxy />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!">
|
|
||||||
<SettingsCertificates />
|
|
||||||
</TabContent>
|
|
||||||
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!">
|
|
||||||
<SettingsLicense />
|
|
||||||
</TabContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,32 +1,17 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
import { SettingsGeneral as SharedSettingsGeneral } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||||
import { appInfo } from "../../lib/appInfo";
|
|
||||||
import { revealInFinderText } from "../../lib/reveal";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||||
import { DismissibleBanner } from "../core/DismissibleBanner";
|
import { DismissibleBanner } from "../core/DismissibleBanner";
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import {
|
|
||||||
ModelSettingRowBoolean,
|
|
||||||
ModelSettingSelectControl,
|
|
||||||
SettingValue,
|
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
||||||
|
|
||||||
export function SettingsGeneral() {
|
export function SettingsGeneral() {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const checkForUpdates = useCheckForUpdates();
|
const bound = useSettingsView();
|
||||||
|
|
||||||
if (settings == null) {
|
if (settings == null || bound == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,147 +19,24 @@ export function SettingsGeneral() {
|
|||||||
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={1.5} className="mb-4">
|
<SharedSettingsGeneral
|
||||||
<div>
|
view={bound.view}
|
||||||
<Heading>General</Heading>
|
callbacks={bound.callbacks}
|
||||||
<p className="text-text-subtle">
|
banner={<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />}
|
||||||
Configure general settings for update behavior and more.
|
extraSections={
|
||||||
</p>
|
showWorkspaceSettingsMovedBanner && (
|
||||||
</div>
|
|
||||||
<div className="mt-3 mb-5">
|
|
||||||
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<CargoFeature feature="updater">
|
|
||||||
<SettingsSection title="Updates">
|
|
||||||
<SettingRow
|
|
||||||
title="Update Channel"
|
|
||||||
description="Choose whether Yaak should use stable releases or beta releases."
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-[12rem_auto] gap-1">
|
|
||||||
<ModelSettingSelectControl
|
|
||||||
model={settings}
|
|
||||||
modelKey="updateChannel"
|
|
||||||
label="Update Channel"
|
|
||||||
selectClassName="w-full!"
|
|
||||||
options={[
|
|
||||||
{ label: "Stable", value: "stable" },
|
|
||||||
{ label: "Beta", value: "beta" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
title="Check for updates"
|
|
||||||
icon="refresh"
|
|
||||||
spin={checkForUpdates.isPending}
|
|
||||||
onClick={() => checkForUpdates.mutateAsync()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRowSelect
|
|
||||||
title="Update Behavior"
|
|
||||||
description="Choose whether updates are installed automatically or manually."
|
|
||||||
name="autoupdate"
|
|
||||||
value={settings.autoupdate ? "auto" : "manual"}
|
|
||||||
onChange={(v) =>
|
|
||||||
patchModel(settings, { autoupdate: v === "auto" })
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic", value: "auto" },
|
|
||||||
{ label: "Manual", value: "manual" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="autoDownloadUpdates"
|
|
||||||
title="Automatically download updates"
|
|
||||||
description="Download Yaak updates in the background so they are ready to install."
|
|
||||||
disabled={!settings.autoupdate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="checkNotifications"
|
|
||||||
title="Check for notifications"
|
|
||||||
description="Periodically ping Yaak servers to check for relevant notifications."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRowBoolean
|
|
||||||
title="Send anonymous usage statistics"
|
|
||||||
description="Yaak is local-first and does not collect analytics or usage data."
|
|
||||||
disabled
|
|
||||||
checked={false}
|
|
||||||
onChange={() => {}}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</CargoFeature>
|
|
||||||
|
|
||||||
<CargoFeature feature="license">
|
|
||||||
<SettingsSection title="Feedback">
|
|
||||||
<SettingRowBoolean
|
|
||||||
title="Prompt for feedback"
|
|
||||||
description="Show rare one-time prompts asking how new features are working."
|
|
||||||
checked={settings.promptFeedback}
|
|
||||||
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</CargoFeature>
|
|
||||||
|
|
||||||
{showWorkspaceSettingsMovedBanner && (
|
|
||||||
<DismissibleBanner
|
<DismissibleBanner
|
||||||
id="workspace-settings-moved-2026-06-30"
|
id="workspace-settings-moved-2026-06-30"
|
||||||
color="info"
|
color="info"
|
||||||
className="w-full p-4 max-w-xl mr-auto"
|
className="w-full p-4 max-w-xl mr-auto"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Workspace specific settings have moved to{" "}
|
Workspace specific settings have moved to <b>Workspace Settings</b>, accessible from
|
||||||
<b>Workspace Settings</b>, accessible from the workspace switcher
|
the workspace switcher menu.
|
||||||
menu.
|
|
||||||
</p>
|
</p>
|
||||||
</DismissibleBanner>
|
</DismissibleBanner>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
<SettingsSection title="App Info">
|
/>
|
||||||
<SettingRow title="Version" description="Current Yaak version.">
|
|
||||||
<SettingValue value={appInfo.version} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
title="Data Directory"
|
|
||||||
description="Where Yaak stores application data."
|
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
|
||||||
>
|
|
||||||
<SettingValue
|
|
||||||
value={appInfo.appDataDir}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
title: revealInFinderText,
|
|
||||||
icon: "folder_open",
|
|
||||||
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
title="Logs Directory"
|
|
||||||
description="Where Yaak writes application logs."
|
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
|
||||||
>
|
|
||||||
<SettingValue
|
|
||||||
value={appInfo.appLogDir}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
title: revealInFinderText,
|
|
||||||
icon: "folder_open",
|
|
||||||
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,59 +1,30 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import { useFonts } from "@yaakapp-internal/fonts";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
import type { Settings } from "@yaakapp-internal/models";
|
||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
|
import { SettingsInterface as SharedSettingsInterface, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useState } from "react";
|
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||||
|
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||||
import { showConfirm } from "../../lib/confirm";
|
import { showConfirm } from "../../lib/confirm";
|
||||||
import { pricingUrl } from "../../lib/pricingUrl";
|
import { pricingUrl } from "../../lib/pricingUrl";
|
||||||
import { rpc } from "../../lib/rpc";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { Checkbox } from "../core/Checkbox";
|
|
||||||
import { Link } from "../core/Link";
|
import { Link } from "../core/Link";
|
||||||
import {
|
import { SettingRowBoolean, SettingRowSelect, SettingsSection } from "../core/SettingRow";
|
||||||
ModelSettingRowBoolean,
|
|
||||||
ModelSettingRowSelect,
|
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingSelectControl,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const NULL_FONT_VALUE = "__NULL_FONT__";
|
|
||||||
|
|
||||||
const fontSizeOptions = [
|
|
||||||
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
|
|
||||||
].map((n) => ({ label: `${n}`, value: `${n}` }));
|
|
||||||
|
|
||||||
const keymaps: { value: EditorKeymap; label: string }[] = [
|
|
||||||
{ value: "default", label: "Default" },
|
|
||||||
{ value: "vim", label: "Vim" },
|
|
||||||
{ value: "vscode", label: "VSCode" },
|
|
||||||
{ value: "emacs", label: "Emacs" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsInterface() {
|
export function SettingsInterface() {
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const fonts = useFonts();
|
const bound = useSettingsView();
|
||||||
|
|
||||||
if (settings == null || workspace == null) {
|
if (settings == null || workspace == null || bound == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={1.5} className="mb-4">
|
<SharedSettingsInterface
|
||||||
<div className="mb-3">
|
view={bound.view}
|
||||||
<Heading>Interface</Heading>
|
callbacks={bound.callbacks}
|
||||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
leadingSections={
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<SettingsSection title="Workspaces">
|
<SettingsSection title="Workspaces">
|
||||||
<SettingRowSelect
|
<SettingRowSelect
|
||||||
title="Open workspace behavior"
|
title="Open workspace behavior"
|
||||||
@@ -78,151 +49,13 @@ export function SettingsInterface() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
}
|
||||||
<SettingsSection title="Fonts">
|
trailingSections={
|
||||||
<SettingRow
|
|
||||||
title="Interface font"
|
|
||||||
description="Font used for Yaak interface controls."
|
|
||||||
controlClassName="gap-1"
|
|
||||||
>
|
|
||||||
{fonts.data && (
|
|
||||||
<SettingSelectControl
|
|
||||||
name="uiFont"
|
|
||||||
label="Interface font"
|
|
||||||
selectClassName="w-72!"
|
|
||||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
|
||||||
defaultValue={NULL_FONT_VALUE}
|
|
||||||
options={[
|
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
|
||||||
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
]}
|
|
||||||
onChange={async (v) => {
|
|
||||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
|
||||||
await patchModel(settings, { interfaceFont });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SettingSelectControl
|
|
||||||
name="interfaceFontSize"
|
|
||||||
label="Interface Font Size"
|
|
||||||
selectClassName="w-20!"
|
|
||||||
value={`${settings.interfaceFontSize}`}
|
|
||||||
defaultValue="14"
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Editor font"
|
|
||||||
description="Font used in request and response editors."
|
|
||||||
controlClassName="gap-1"
|
|
||||||
>
|
|
||||||
{fonts.data && (
|
|
||||||
<SettingSelectControl
|
|
||||||
name="editorFont"
|
|
||||||
label="Editor font"
|
|
||||||
selectClassName="w-72!"
|
|
||||||
value={settings.editorFont ?? NULL_FONT_VALUE}
|
|
||||||
defaultValue={NULL_FONT_VALUE}
|
|
||||||
options={[
|
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
|
||||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
]}
|
|
||||||
onChange={async (v) => {
|
|
||||||
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
|
||||||
await patchModel(settings, { editorFont });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SettingSelectControl
|
|
||||||
name="editorFontSize"
|
|
||||||
label="Editor Font Size"
|
|
||||||
selectClassName="w-20!"
|
|
||||||
value={`${settings.editorFontSize}`}
|
|
||||||
defaultValue="12"
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) =>
|
|
||||||
patchModel(settings, {
|
|
||||||
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Editor">
|
|
||||||
<ModelSettingRowSelect
|
|
||||||
model={settings}
|
|
||||||
modelKey="editorKeymap"
|
|
||||||
title="Editor keymap"
|
|
||||||
description="Keyboard shortcut preset used by text editors."
|
|
||||||
options={keymaps}
|
|
||||||
/>
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="editorSoftWrap"
|
|
||||||
title="Wrap editor lines"
|
|
||||||
description="Wrap long lines in request and response editors."
|
|
||||||
/>
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="coloredMethods"
|
|
||||||
title="Colorize request methods"
|
|
||||||
description="Use method-specific colors for HTTP request methods."
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Window">
|
|
||||||
<NativeTitlebarSetting settings={settings} />
|
|
||||||
{platform.osType() !== "macos" && (
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="hideWindowControls"
|
|
||||||
title="Hide window controls"
|
|
||||||
description="Hide the close, maximize, and minimize controls on Windows or Linux."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<CargoFeature feature="license">
|
<CargoFeature feature="license">
|
||||||
<LicenseSettings settings={settings} />
|
<LicenseSettings settings={settings} />
|
||||||
</CargoFeature>
|
</CargoFeature>
|
||||||
</SettingsList>
|
}
|
||||||
</VStack>
|
/>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
|
||||||
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
title="Native title bar"
|
|
||||||
description="Use the operating system's standard title bar and window controls."
|
|
||||||
controlClassName="gap-2"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
size="md"
|
|
||||||
checked={nativeTitlebar}
|
|
||||||
title="Native title bar"
|
|
||||||
onChange={setNativeTitlebar}
|
|
||||||
/>
|
|
||||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
onClick={async () => {
|
|
||||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
|
||||||
await rpc("cmd_restart");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply and Restart
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</SettingRow>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { SettingsLicense as SharedSettingsLicense } from "@yaakapp-internal/ui";
|
||||||
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||||
import { differenceInDays } from "date-fns";
|
|
||||||
import { formatDate } from "date-fns/format";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useToggle } from "../../hooks/useToggle";
|
|
||||||
import { pricingUrl } from "../../lib/pricingUrl";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { Link } from "../core/Link";
|
|
||||||
import { PlainInput } from "../core/PlainInput";
|
|
||||||
import { Separator } from "../core/Separator";
|
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
|
||||||
|
|
||||||
export function SettingsLicense() {
|
export function SettingsLicense() {
|
||||||
return (
|
return (
|
||||||
@@ -22,166 +11,7 @@ export function SettingsLicense() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SettingsLicenseCmp() {
|
function SettingsLicenseCmp() {
|
||||||
const { check, activate, deactivate } = useLicense();
|
const bound = useSettingsView();
|
||||||
const [key, setKey] = useState<string>("");
|
if (bound == null) return null;
|
||||||
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
|
return <SharedSettingsLicense view={bound.view} callbacks={bound.callbacks} />;
|
||||||
|
|
||||||
if (check.isPending) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderBanner = () => {
|
|
||||||
if (!check.data) return null;
|
|
||||||
|
|
||||||
switch (check.data.status) {
|
|
||||||
case "active":
|
|
||||||
return <Banner color="success">Your license is active 🥳</Banner>;
|
|
||||||
|
|
||||||
case "trialing":
|
|
||||||
return (
|
|
||||||
<Banner color="info" className="max-w-lg">
|
|
||||||
<p className="w-full">
|
|
||||||
<strong>
|
|
||||||
{pluralizeCount("day", differenceInDays(check.data.data.end, new Date()))}
|
|
||||||
</strong>{" "}
|
|
||||||
left to evaluate Yaak for commercial use.
|
|
||||||
<br />
|
|
||||||
<span className="opacity-50">Personal use is always free, forever.</span>
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
|
||||||
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
|
|
||||||
Learn More
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</p>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "personal_use":
|
|
||||||
return (
|
|
||||||
<Banner color="notice" className="max-w-lg">
|
|
||||||
<p className="w-full">
|
|
||||||
Your commercial-use trial has ended.
|
|
||||||
<br />
|
|
||||||
<span className="opacity-50">
|
|
||||||
You may continue using Yaak for personal use only.
|
|
||||||
<br />A license is required for commercial use.
|
|
||||||
</span>
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
|
||||||
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
|
|
||||||
Learn More
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</p>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "inactive":
|
|
||||||
return (
|
|
||||||
<Banner color="danger">
|
|
||||||
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link>{" "}
|
|
||||||
for more details
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "expired":
|
|
||||||
return (
|
|
||||||
<Banner color="notice">
|
|
||||||
Your license expired{" "}
|
|
||||||
<strong>{formatDate(check.data.data.periodEnd, "MMMM dd, yyyy")}</strong>. Please{" "}
|
|
||||||
<Link href="https://yaak.app/dashboard">Resubscribe</Link> to continue receiving
|
|
||||||
updates.
|
|
||||||
{check.data.data.changesUrl && (
|
|
||||||
<>
|
|
||||||
<br />
|
|
||||||
<Link href={check.data.data.changesUrl}>What's new in latest builds</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "past_due":
|
|
||||||
return (
|
|
||||||
<Banner color="danger">
|
|
||||||
<strong>Your payment method needs attention.</strong>
|
|
||||||
<br />
|
|
||||||
To re-activate your license, please{" "}
|
|
||||||
<Link href={check.data.data.billingUrl}>update your billing info</Link>.
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "error":
|
|
||||||
return (
|
|
||||||
<Banner color="danger">
|
|
||||||
License check failed: {check.data.data.message} (Code: {check.data.data.code})
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6 max-w-xl">
|
|
||||||
{renderBanner()}
|
|
||||||
|
|
||||||
{check.error && <Banner color="danger">{check.error}</Banner>}
|
|
||||||
{activate.error && <Banner color="danger">{activate.error}</Banner>}
|
|
||||||
|
|
||||||
{check.data?.status === "active" ? (
|
|
||||||
<HStack space={2}>
|
|
||||||
<Button variant="border" color="secondary" size="sm" onClick={() => deactivate.mutate()}>
|
|
||||||
Deactivate License
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
|
|
||||||
rightSlot={<Icon icon="external_link" />}
|
|
||||||
>
|
|
||||||
Direct Support
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
) : (
|
|
||||||
<HStack space={2}>
|
|
||||||
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}>
|
|
||||||
Activate License
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
rightSlot={<Icon icon="external_link" />}
|
|
||||||
onClick={() =>
|
|
||||||
platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Purchase License
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activateFormVisible && (
|
|
||||||
<VStack
|
|
||||||
as="form"
|
|
||||||
space={3}
|
|
||||||
className="max-w-sm"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await activate.mutateAsync({ licenseKey: key });
|
|
||||||
toggleActivateFormVisible();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlainInput
|
|
||||||
autoFocus
|
|
||||||
label="License Key"
|
|
||||||
name="key"
|
|
||||||
onChange={setKey}
|
|
||||||
placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX"
|
|
||||||
/>
|
|
||||||
<Button type="submit" color="primary" size="sm" isLoading={activate.isPending}>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,173 +1,15 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
import { SettingsTheme as SharedSettingsTheme } from "@yaakapp-internal/ui";
|
||||||
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||||
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
|
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||||
import { useResolvedTheme } from "../../hooks/useResolvedTheme";
|
|
||||||
import type { ButtonProps } from "../core/Button";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { Link } from "../core/Link";
|
|
||||||
import type { SelectProps } from "../core/Select";
|
|
||||||
import {
|
|
||||||
ModelSettingRowSelect,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
|
|
||||||
|
|
||||||
const buttonColors: ButtonProps["color"][] = [
|
|
||||||
"primary",
|
|
||||||
"info",
|
|
||||||
"success",
|
|
||||||
"notice",
|
|
||||||
"warning",
|
|
||||||
"danger",
|
|
||||||
"secondary",
|
|
||||||
"default",
|
|
||||||
];
|
|
||||||
|
|
||||||
const icons: IconProps["icon"][] = [
|
|
||||||
"info",
|
|
||||||
"box",
|
|
||||||
"update",
|
|
||||||
"alert_triangle",
|
|
||||||
"arrow_big_right_dash",
|
|
||||||
"download",
|
|
||||||
"copy",
|
|
||||||
"magic_wand",
|
|
||||||
"settings",
|
|
||||||
"trash",
|
|
||||||
"sparkles",
|
|
||||||
"pencil",
|
|
||||||
"paste",
|
|
||||||
"search",
|
|
||||||
"send_horizontal",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsTheme() {
|
export function SettingsTheme() {
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||||
const settings = useAtomValue(settingsAtom);
|
const bound = useSettingsView();
|
||||||
const appearance = useResolvedAppearance();
|
|
||||||
const activeTheme = useResolvedTheme();
|
|
||||||
|
|
||||||
if (settings == null || workspace == null || activeTheme.data == null) {
|
if (workspace == null || bound == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
return <SharedSettingsTheme view={bound.view} callbacks={bound.callbacks} />;
|
||||||
.filter((theme) => !theme.dark)
|
|
||||||
.map((theme) => ({
|
|
||||||
label: theme.label,
|
|
||||||
value: theme.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const darkThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
|
||||||
.filter((theme) => theme.dark)
|
|
||||||
.map((theme) => ({
|
|
||||||
label: theme.label,
|
|
||||||
value: theme.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack space={1.5} className="mb-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Heading>Theme</Heading>
|
|
||||||
<p className="text-text-subtle">
|
|
||||||
Make Yaak your own by selecting a theme, or{" "}
|
|
||||||
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
|
|
||||||
Create Your Own
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
<SettingsSection title="Theme">
|
|
||||||
<ModelSettingRowSelect
|
|
||||||
model={settings}
|
|
||||||
modelKey="appearance"
|
|
||||||
title="Appearance"
|
|
||||||
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
|
|
||||||
options={[
|
|
||||||
{ label: "Automatic", value: "system" },
|
|
||||||
{ label: "Light", value: "light" },
|
|
||||||
{ label: "Dark", value: "dark" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{(settings.appearance === "system" || settings.appearance === "light") && (
|
|
||||||
<SettingRowSelect
|
|
||||||
name="lightTheme"
|
|
||||||
title="Light theme"
|
|
||||||
description="Theme used when Yaak is in light mode."
|
|
||||||
value={activeTheme.data.light.id}
|
|
||||||
options={lightThemes}
|
|
||||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
|
||||||
<SettingRowSelect
|
|
||||||
name="darkTheme"
|
|
||||||
title="Dark theme"
|
|
||||||
description="Theme used when Yaak is in dark mode."
|
|
||||||
value={activeTheme.data.dark.id}
|
|
||||||
options={darkThemes}
|
|
||||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Preview">
|
|
||||||
<VStack
|
|
||||||
space={3}
|
|
||||||
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto"
|
|
||||||
>
|
|
||||||
<HStack className="text" space={1.5}>
|
|
||||||
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
|
|
||||||
<strong>{activeTheme.data.active.label}</strong>
|
|
||||||
<em>(preview)</em>
|
|
||||||
</HStack>
|
|
||||||
<HStack space={1.5} className="w-full">
|
|
||||||
{buttonColors.map((c, i) => (
|
|
||||||
<IconButton
|
|
||||||
key={c}
|
|
||||||
color={c}
|
|
||||||
size="2xs"
|
|
||||||
iconSize="xs"
|
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
|
||||||
iconClassName="text"
|
|
||||||
title={`${c}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{buttonColors.map((c, i) => (
|
|
||||||
<IconButton
|
|
||||||
key={c}
|
|
||||||
color={c}
|
|
||||||
variant="border"
|
|
||||||
size="2xs"
|
|
||||||
iconSize="xs"
|
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
|
||||||
iconClassName="text"
|
|
||||||
title={`${c}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
<Suspense>
|
|
||||||
<Editor
|
|
||||||
defaultValue={[
|
|
||||||
"let foo = { // Demo code editor",
|
|
||||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
|
||||||
" baz: [1, 10.2, null, false, true],",
|
|
||||||
"};",
|
|
||||||
].join("\n")}
|
|
||||||
heightMode="auto"
|
|
||||||
language="javascript"
|
|
||||||
stateKey={null}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</VStack>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -3,16 +3,43 @@ import { platform } from "@yaakapp-internal/platform";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { HTMLAttributes } from "react";
|
import type { HTMLAttributes } from "react";
|
||||||
import { appInfo } from "../../lib/appInfo";
|
import { appInfo } from "../../lib/appInfo";
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
import { ExternalLink, isYaakUrl } from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
interface Props extends HTMLAttributes<HTMLAnchorElement> {
|
interface Props extends HTMLAttributes<HTMLAnchorElement> {
|
||||||
href: string;
|
href: string;
|
||||||
noUnderline?: boolean;
|
noUnderline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tags a link to yaak.app with which app it came from */
|
||||||
|
export function withYaakRef(href: string): string {
|
||||||
|
if (!isYaakUrl(href)) return href;
|
||||||
|
const url = new URL(href);
|
||||||
|
url.searchParams.set("ref", appInfo.identifier);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens a link outside the app, tagged as `Link` tags it */
|
||||||
|
export function openExternalUrl(href: string) {
|
||||||
|
void platform.openUrl(withYaakRef(href));
|
||||||
|
}
|
||||||
|
|
||||||
export function Link({ href, children, noUnderline, className, ...other }: Props) {
|
export function Link({ href, children, noUnderline, className, ...other }: Props) {
|
||||||
const isExternal = href.match(/^https?:\/\//);
|
const isExternal = href.match(/^https?:\/\//);
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
|
return (
|
||||||
|
<ExternalLink
|
||||||
|
href={withYaakRef(href)}
|
||||||
|
noUnderline={noUnderline}
|
||||||
|
onOpen={openExternalUrl}
|
||||||
|
className={className}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ExternalLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
className = classNames(
|
className = classNames(
|
||||||
className,
|
className,
|
||||||
"relative",
|
"relative",
|
||||||
@@ -20,37 +47,6 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
|
|||||||
!noUnderline && "underline",
|
!noUnderline && "underline",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isExternal) {
|
|
||||||
const isYaakLink = href.startsWith("https://yaak.app");
|
|
||||||
let finalHref = href;
|
|
||||||
if (isYaakLink) {
|
|
||||||
const url = new URL(href);
|
|
||||||
url.searchParams.set("ref", appInfo.identifier);
|
|
||||||
finalHref = url.toString();
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
// eslint-disable-next-line react/jsx-no-target-blank
|
|
||||||
<a
|
|
||||||
href={finalHref}
|
|
||||||
target="_blank"
|
|
||||||
rel={isYaakLink ? undefined : "noopener noreferrer"}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void platform.openUrl(finalHref);
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
{...other}
|
|
||||||
>
|
|
||||||
<span className="pr-5">{children}</span>
|
|
||||||
<Icon
|
|
||||||
className="inline absolute right-0.5 top-[0.3em] opacity-70 group-hover:opacity-100"
|
|
||||||
size="xs"
|
|
||||||
icon="external_link"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterLink to={href} className={className} {...other}>
|
<RouterLink to={href} className={className} {...other}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,148 +1,2 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
export type { SelectProps } from "@yaakapp-internal/ui";
|
||||||
import { HStack, Label } from "@yaakapp-internal/ui";
|
export { Select } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { ButtonProps } from "./Button";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
|
||||||
import { RadioDropdown } from "./RadioDropdown";
|
|
||||||
|
|
||||||
export interface SelectProps<T extends string> {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
labelPosition?: "top" | "left";
|
|
||||||
labelClassName?: string;
|
|
||||||
hideLabel?: boolean;
|
|
||||||
value: T;
|
|
||||||
help?: ReactNode;
|
|
||||||
leftSlot?: ReactNode;
|
|
||||||
options: RadioDropdownItem<T>[];
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
defaultValue?: T;
|
|
||||||
size?: ButtonProps["size"];
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
filterable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Select<T extends string>({
|
|
||||||
labelPosition = "top",
|
|
||||||
name,
|
|
||||||
help,
|
|
||||||
labelClassName,
|
|
||||||
disabled,
|
|
||||||
hideLabel,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
options,
|
|
||||||
leftSlot,
|
|
||||||
onChange,
|
|
||||||
className,
|
|
||||||
defaultValue,
|
|
||||||
filterable,
|
|
||||||
size = "md",
|
|
||||||
}: SelectProps<T>) {
|
|
||||||
const [focused, setFocused] = useState<boolean>(false);
|
|
||||||
const id = `input-${name}`;
|
|
||||||
const isInvalidSelection = options.find((o) => "value" in o && o.value === value) == null;
|
|
||||||
|
|
||||||
const handleChange = (value: T) => {
|
|
||||||
onChange?.(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"x-theme-input",
|
|
||||||
"w-full",
|
|
||||||
"pointer-events-auto", // Just in case we're placing in disabled parent
|
|
||||||
labelPosition === "left" && "grid grid-cols-[auto_1fr] items-center gap-2",
|
|
||||||
labelPosition === "top" && "flex-row gap-0.5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
{platform.osType() === "macos" && !filterable ? (
|
|
||||||
<HStack
|
|
||||||
space={2}
|
|
||||||
className={classNames(
|
|
||||||
"w-full rounded-md text text-sm font-mono",
|
|
||||||
"pl-2",
|
|
||||||
"border",
|
|
||||||
focused && !disabled ? "border-border-focus" : "border-border",
|
|
||||||
disabled && "border-dotted",
|
|
||||||
isInvalidSelection && "border-danger",
|
|
||||||
size === "xs" && "h-xs",
|
|
||||||
size === "sm" && "h-sm",
|
|
||||||
size === "md" && "h-md",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{leftSlot && <div>{leftSlot}</div>}
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
style={selectBackgroundStyles}
|
|
||||||
onChange={(e) => handleChange(e.target.value as T)}
|
|
||||||
onFocus={() => setFocused(true)}
|
|
||||||
onBlur={() => setFocused(false)}
|
|
||||||
disabled={disabled}
|
|
||||||
className={classNames(
|
|
||||||
"pr-7 w-full outline-hidden bg-transparent disabled:opacity-disabled",
|
|
||||||
"leading-none rounded-none", // Center the text better vertically
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isInvalidSelection && <option value={"__NONE__"}>-- Select an Option --</option>}
|
|
||||||
{options.map((o) => {
|
|
||||||
if (o.type === "separator") return null;
|
|
||||||
return (
|
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
{o.value === defaultValue && " (default)"}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</HStack>
|
|
||||||
) : (
|
|
||||||
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
|
|
||||||
// light mode
|
|
||||||
<RadioDropdown
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
items={options.map((o) =>
|
|
||||||
o.type === "separator" || o.value !== defaultValue
|
|
||||||
? o
|
|
||||||
: { ...o, label: <>{o.label} (default)</> },
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
className={classNames(
|
|
||||||
"w-full text-sm font-mono",
|
|
||||||
disabled && "border-dotted",
|
|
||||||
isInvalidSelection && "border-danger",
|
|
||||||
)}
|
|
||||||
justify="start"
|
|
||||||
variant="input"
|
|
||||||
size={size}
|
|
||||||
leftSlot={leftSlot}
|
|
||||||
disabled={disabled}
|
|
||||||
forDropdown
|
|
||||||
>
|
|
||||||
{options.find((o) => o.type !== "separator" && o.value === value)?.label ?? "--"}
|
|
||||||
</Button>
|
|
||||||
</RadioDropdown>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectBackgroundStyles: CSSProperties = {
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
|
|
||||||
backgroundPosition: "right 0.3rem center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "1.5em 1.5em",
|
|
||||||
appearance: "none",
|
|
||||||
printColorAdjust: "exact",
|
|
||||||
};
|
|
||||||
@@ -1,260 +1,36 @@
|
|||||||
import type { AnyModel } from "@yaakapp-internal/models";
|
import type { AnyModel } from "@yaakapp-internal/models";
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
import { patchModel } from "@yaakapp-internal/models";
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
import {
|
||||||
import classNames from "classnames";
|
SettingRow,
|
||||||
import type { ReactNode } from "react";
|
type SettingRowBaseProps,
|
||||||
import { CopyIconButton } from "../CopyIconButton";
|
SettingRowBoolean,
|
||||||
import { Checkbox } from "./Checkbox";
|
SettingRowNumber,
|
||||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
SettingRowSelect,
|
||||||
import { PlainInput } from "./PlainInput";
|
SettingRowText,
|
||||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
SettingSelectControl,
|
||||||
import { Select } from "./Select";
|
} from "@yaakapp-internal/ui";
|
||||||
import { SelectFile } from "../SelectFile";
|
import { SelectFile } from "../SelectFile";
|
||||||
|
|
||||||
|
export type { SettingRowBaseProps, SettingSnippetLanguage } from "@yaakapp-internal/ui";
|
||||||
|
export {
|
||||||
|
SettingCommand,
|
||||||
|
SettingOverrideRow,
|
||||||
|
SettingRow,
|
||||||
|
SettingRowBoolean,
|
||||||
|
SettingRowNumber,
|
||||||
|
SettingRowSelect,
|
||||||
|
SettingRowText,
|
||||||
|
SettingSelectControl,
|
||||||
|
SettingSnippet,
|
||||||
|
SettingsList,
|
||||||
|
SettingsSection,
|
||||||
|
SettingValue,
|
||||||
|
} from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
type ModelKeyOfValue<T, V> = {
|
type ModelKeyOfValue<T, V> = {
|
||||||
[K in keyof T]-?: T[K] extends V ? K : never;
|
[K in keyof T]-?: T[K] extends V ? K : never;
|
||||||
}[keyof T];
|
}[keyof T];
|
||||||
|
|
||||||
type SettingRowBaseProps = {
|
|
||||||
className?: string;
|
|
||||||
controlClassName?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
title: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
|
|
||||||
return <div className={classNames("w-full", className)}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsSection({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
description,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
title: ReactNode | null;
|
|
||||||
}) {
|
|
||||||
const showHeader = title != null || description != null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={classNames(className, "w-full")}>
|
|
||||||
{showHeader && (
|
|
||||||
<div className="border-b border-border-subtle pb-2">
|
|
||||||
{title != null && <div className="text-text-subtle">{title}</div>}
|
|
||||||
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="[&>*:last-child]:border-b-0">{children}</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRow({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
controlClassName,
|
|
||||||
description,
|
|
||||||
disabled,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-disabled={disabled || undefined}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"@container border-b border-border-subtle py-4",
|
|
||||||
disabled && "opacity-disabled",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"grid grid-cols-1 gap-2",
|
|
||||||
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text text-text">{title}</div>
|
|
||||||
{description != null && (
|
|
||||||
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
|
|
||||||
controlClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingValue({
|
|
||||||
actions,
|
|
||||||
className,
|
|
||||||
copyText,
|
|
||||||
enableCopy = true,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
actions?: SettingValueAction[];
|
|
||||||
className?: string;
|
|
||||||
copyText?: string;
|
|
||||||
enableCopy?: boolean;
|
|
||||||
value: ReactNode;
|
|
||||||
}) {
|
|
||||||
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
|
|
||||||
const textToCopy = copyText ?? textValue;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
{actions?.map((action) => (
|
|
||||||
<IconButton
|
|
||||||
key={action.title}
|
|
||||||
icon={action.icon}
|
|
||||||
title={action.title}
|
|
||||||
size="2xs"
|
|
||||||
iconSize="sm"
|
|
||||||
onClick={action.onClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{enableCopy && textToCopy != null && (
|
|
||||||
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command or config to copy, set as a block: its title, an optional line
|
|
||||||
* saying which file it goes in, then the snippet at full width with the copy
|
|
||||||
* button on it. One-line shell commands and multi-line JSON get the same block.
|
|
||||||
*/
|
|
||||||
export function SettingCommand({
|
|
||||||
className,
|
|
||||||
description,
|
|
||||||
disabled,
|
|
||||||
done,
|
|
||||||
language,
|
|
||||||
snippet,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
description?: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
/** A short word shown with a check beside the title once the step is detected as done */
|
|
||||||
done?: ReactNode;
|
|
||||||
language: SettingSnippetLanguage;
|
|
||||||
snippet: string;
|
|
||||||
title: ReactNode;
|
|
||||||
} & Pick<SettingRowBaseProps, "className">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-disabled={disabled || undefined}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"border-b border-border-subtle py-4",
|
|
||||||
disabled && "opacity-disabled",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2 text text-text">
|
|
||||||
<span>{title}</span>
|
|
||||||
{done != null && done !== false && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-sm text-success">
|
|
||||||
<Icon icon="check" size="sm" />
|
|
||||||
{done}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{description != null && (
|
|
||||||
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
|
|
||||||
)}
|
|
||||||
<SettingSnippet className="mt-3" language={language} snippet={snippet} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SettingSnippetLanguage = "shell" | "json" | "text";
|
|
||||||
|
|
||||||
/** The snippet half of a `SettingCommand`, for places with their own title, like a banner. */
|
|
||||||
export function SettingSnippet({
|
|
||||||
className,
|
|
||||||
language,
|
|
||||||
snippet,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
language: SettingSnippetLanguage;
|
|
||||||
snippet: string;
|
|
||||||
}) {
|
|
||||||
const prompt = language === "shell";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
"flex items-start gap-2 rounded-md border border-border-subtle bg-surface-highlight/40 py-1.5 pr-1.5 pl-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex min-h-sm min-w-0 flex-1 items-center font-mono text-editor text-text">
|
|
||||||
<span className="cursor-text select-text whitespace-pre-wrap [overflow-wrap:anywhere]">
|
|
||||||
{prompt && (
|
|
||||||
<span aria-hidden className="mr-2 select-none text-text-subtlest">
|
|
||||||
$
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{snippet}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<CopyIconButton size="sm" variant="border" title="Copy" text={snippet} className="shrink-0" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettingValueAction = {
|
|
||||||
icon: IconButtonProps["icon"];
|
|
||||||
onClick: () => void;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SettingRowBoolean({
|
|
||||||
checked,
|
|
||||||
checkboxSize = "md",
|
|
||||||
onChange,
|
|
||||||
title,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
checkboxSize?: "sm" | "md";
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
size={checkboxSize}
|
|
||||||
checked={checked}
|
|
||||||
disabled={props.disabled}
|
|
||||||
title={title}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
|
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
|
||||||
model,
|
model,
|
||||||
modelKey,
|
modelKey,
|
||||||
@@ -272,50 +48,6 @@ export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfV
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingRowNumber({
|
|
||||||
inputClassName,
|
|
||||||
inputWidthClassName = "w-48!",
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
title,
|
|
||||||
type = "number",
|
|
||||||
validate,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
inputClassName?: string;
|
|
||||||
inputWidthClassName?: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
type?: "number";
|
|
||||||
validate?: (value: string) => boolean;
|
|
||||||
value: number;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<PlainInput
|
|
||||||
required={required}
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
placeholder={placeholder}
|
|
||||||
defaultValue={`${value}`}
|
|
||||||
validate={validate}
|
|
||||||
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
|
|
||||||
type={type}
|
|
||||||
className={inputClassName}
|
|
||||||
containerClassName={inputWidthClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
|
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
|
||||||
model,
|
model,
|
||||||
modelKey,
|
modelKey,
|
||||||
@@ -334,47 +66,6 @@ export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfVa
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingRowText({
|
|
||||||
inputClassName,
|
|
||||||
inputWidthClassName = "w-80!",
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
title,
|
|
||||||
type = "text",
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
inputClassName?: string;
|
|
||||||
inputWidthClassName?: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
type?: "text" | "password";
|
|
||||||
value: string;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<PlainInput
|
|
||||||
required={required}
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
placeholder={placeholder}
|
|
||||||
defaultValue={value}
|
|
||||||
onChange={onChange}
|
|
||||||
type={type}
|
|
||||||
className={inputClassName}
|
|
||||||
containerClassName={inputWidthClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
|
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
|
||||||
model,
|
model,
|
||||||
modelKey,
|
modelKey,
|
||||||
@@ -438,74 +129,6 @@ export function SettingRowDirectory({
|
|||||||
return <SettingRowFile directory noun={noun} {...props} />;
|
return <SettingRowFile directory noun={noun} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingRowSelect<T extends string>({
|
|
||||||
defaultValue,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
selectClassName = "w-48!",
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
defaultValue?: T;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: RadioDropdownItem<T>[];
|
|
||||||
selectClassName?: string;
|
|
||||||
value: T;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<SettingSelectControl
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
value={value}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
selectClassName={selectClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingSelectControl<T extends string>({
|
|
||||||
defaultValue,
|
|
||||||
disabled,
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
selectClassName = "w-48!",
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
defaultValue?: T;
|
|
||||||
disabled?: boolean;
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: RadioDropdownItem<T>[];
|
|
||||||
selectClassName?: string;
|
|
||||||
value: T;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name={name}
|
|
||||||
value={value}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
label={label}
|
|
||||||
size="sm"
|
|
||||||
className={selectClassName}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingSelectControl<
|
export function ModelSettingSelectControl<
|
||||||
M extends AnyModel,
|
M extends AnyModel,
|
||||||
K extends ModelKeyOfValue<M, string>,
|
K extends ModelKeyOfValue<M, string>,
|
||||||
@@ -549,51 +172,3 @@ export function ModelSettingRowSelect<
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingOverrideRow({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
controlClassName,
|
|
||||||
description,
|
|
||||||
disabled,
|
|
||||||
onResetOverride,
|
|
||||||
overridden,
|
|
||||||
resetTitle = "Reset override",
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
controlClassName?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
onResetOverride: () => void;
|
|
||||||
overridden: boolean;
|
|
||||||
resetTitle?: string;
|
|
||||||
title: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
className={className}
|
|
||||||
controlClassName={controlClassName}
|
|
||||||
description={description}
|
|
||||||
disabled={disabled}
|
|
||||||
title={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{title}
|
|
||||||
{overridden && (
|
|
||||||
<IconButton
|
|
||||||
icon="undo_2"
|
|
||||||
size="2xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title={resetTitle}
|
|
||||||
className="text-text-subtle"
|
|
||||||
onClick={onResetOverride}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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",
|
"name": "@yaakapp-internal/ui",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"hexy": "^0.4.0",
|
"hexy": "^0.4.0",
|
||||||
"jotai-family": "^1.0.1"
|
"jotai-family": "^1.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"./useToggle": "./src/hooks/useToggle.ts"
|
"./useToggle": "./src/hooks/useToggle.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"hexy": "^0.4.0",
|
"hexy": "^0.4.0",
|
||||||
"jotai-family": "^1.0.1"
|
"jotai-family": "^1.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { useEditorHost } from "./EditorHost";
|
||||||
|
import { isYaakUrl } from "../lib/yaakUrl";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
export interface ExternalLinkProps extends HTMLAttributes<HTMLAnchorElement> {
|
||||||
|
href: string;
|
||||||
|
noUnderline?: boolean;
|
||||||
|
/** Opens the link; defaults to the host's `openUrl`, then a new browser tab */
|
||||||
|
onOpen?: (href: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A link that leaves the app, marked with an external-link icon */
|
||||||
|
export function ExternalLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
noUnderline,
|
||||||
|
className,
|
||||||
|
onOpen,
|
||||||
|
...other
|
||||||
|
}: ExternalLinkProps) {
|
||||||
|
const host = useEditorHost();
|
||||||
|
const open = onOpen ?? host.openUrl;
|
||||||
|
const isYaakLink = isYaakUrl(href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line react/jsx-no-target-blank
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel={isYaakLink ? undefined : "noopener noreferrer"}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (open == null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
open(href);
|
||||||
|
}}
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
"relative",
|
||||||
|
"inline-flex items-center hover:underline group",
|
||||||
|
!noUnderline && "underline",
|
||||||
|
)}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
<span className="pr-5">{children}</span>
|
||||||
|
<Icon
|
||||||
|
className="inline absolute right-0.5 top-[0.3em] opacity-70 group-hover:opacity-100"
|
||||||
|
size="xs"
|
||||||
|
icon="external_link"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
} from "./components/EditorHost";
|
||||||
export { EditorHostProvider, useEditorHost } from "./components/EditorHost";
|
export { EditorHostProvider, useEditorHost } from "./components/EditorHost";
|
||||||
export { EmptyStateText } from "./components/EmptyStateText";
|
export { EmptyStateText } from "./components/EmptyStateText";
|
||||||
|
export type { ExternalLinkProps } from "./components/ExternalLink";
|
||||||
|
export { ExternalLink } from "./components/ExternalLink";
|
||||||
export { ConfirmLargeResponse } from "./components/http/ConfirmLargeResponse";
|
export { ConfirmLargeResponse } from "./components/http/ConfirmLargeResponse";
|
||||||
export { FormMultipartEditor } from "./components/http/FormMultipartEditor";
|
export { FormMultipartEditor } from "./components/http/FormMultipartEditor";
|
||||||
export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor";
|
export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor";
|
||||||
@@ -55,7 +57,10 @@ export {
|
|||||||
REQUEST_TAB_PARAMS,
|
REQUEST_TAB_PARAMS,
|
||||||
RequestBodyEditor,
|
RequestBodyEditor,
|
||||||
} from "./components/http/HttpRequestPaneView";
|
} from "./components/http/HttpRequestPaneView";
|
||||||
export type { ExtraPaneTab, HttpResponsePaneViewProps } from "./components/http/HttpResponsePaneView";
|
export type {
|
||||||
|
ExtraPaneTab,
|
||||||
|
HttpResponsePaneViewProps,
|
||||||
|
} from "./components/http/HttpResponsePaneView";
|
||||||
export {
|
export {
|
||||||
HttpResponsePaneView,
|
HttpResponsePaneView,
|
||||||
RESPONSE_TAB_BODY,
|
RESPONSE_TAB_BODY,
|
||||||
@@ -97,10 +102,7 @@ export { PairEditor, PairEditorRow } from "./components/pairs/PairEditor";
|
|||||||
export { ensurePairId } from "./components/pairs/PairEditor.util";
|
export { ensurePairId } from "./components/pairs/PairEditor.util";
|
||||||
export { PairOrBulkEditor } from "./components/pairs/PairOrBulkEditor";
|
export { PairOrBulkEditor } from "./components/pairs/PairOrBulkEditor";
|
||||||
export { PillButton } from "./components/PillButton";
|
export { PillButton } from "./components/PillButton";
|
||||||
export type {
|
export type { RadioDropdownItem, RadioDropdownProps } from "./components/RadioDropdown";
|
||||||
RadioDropdownItem,
|
|
||||||
RadioDropdownProps,
|
|
||||||
} from "./components/RadioDropdown";
|
|
||||||
export { RadioDropdown } from "./components/RadioDropdown";
|
export { RadioDropdown } from "./components/RadioDropdown";
|
||||||
export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
|
export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
|
||||||
export { ErrorBoundary } from "./components/ErrorBoundary";
|
export { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
@@ -126,8 +128,46 @@ export { Overlay } from "./components/Overlay";
|
|||||||
export type { PlainInputProps } from "./components/PlainInput";
|
export type { PlainInputProps } from "./components/PlainInput";
|
||||||
export { PlainInput } from "./components/PlainInput";
|
export { PlainInput } from "./components/PlainInput";
|
||||||
export { Portal } from "./components/Portal";
|
export { Portal } from "./components/Portal";
|
||||||
|
export type { SelectProps } from "./components/Select";
|
||||||
|
export { Select } from "./components/Select";
|
||||||
export type { SeparatorAction } from "./components/Separator";
|
export type { SeparatorAction } from "./components/Separator";
|
||||||
export { Separator } from "./components/Separator";
|
export { Separator } from "./components/Separator";
|
||||||
|
export type { SettingRowBaseProps, SettingSnippetLanguage } from "./components/settings/SettingRow";
|
||||||
|
export {
|
||||||
|
SettingCommand,
|
||||||
|
SettingOverrideRow,
|
||||||
|
SettingRow,
|
||||||
|
SettingRowBoolean,
|
||||||
|
SettingRowNumber,
|
||||||
|
SettingRowSelect,
|
||||||
|
SettingRowText,
|
||||||
|
SettingSelectControl,
|
||||||
|
SettingSnippet,
|
||||||
|
SettingsList,
|
||||||
|
SettingsSection,
|
||||||
|
SettingValue,
|
||||||
|
} from "./components/settings/SettingRow";
|
||||||
|
export type {
|
||||||
|
SettingsDialogProps,
|
||||||
|
SettingsTabDef,
|
||||||
|
SettingsTitlebar,
|
||||||
|
} from "./components/settings/SettingsDialog";
|
||||||
|
export {
|
||||||
|
SETTINGS_TAB_GENERAL,
|
||||||
|
SETTINGS_TAB_INTERFACE,
|
||||||
|
SETTINGS_TAB_LICENSE,
|
||||||
|
SETTINGS_TAB_THEME,
|
||||||
|
SettingsDialog,
|
||||||
|
} from "./components/settings/SettingsDialog";
|
||||||
|
export type { SettingsGeneralProps } from "./components/settings/SettingsGeneral";
|
||||||
|
export { SettingsGeneral, SettingsUpdatesSection } from "./components/settings/SettingsGeneral";
|
||||||
|
export type { SettingsInterfaceProps } from "./components/settings/SettingsInterface";
|
||||||
|
export { SettingsInterface } from "./components/settings/SettingsInterface";
|
||||||
|
export type { SettingsLicenseProps } from "./components/settings/SettingsLicense";
|
||||||
|
export { SettingsLicense } from "./components/settings/SettingsLicense";
|
||||||
|
export type { SettingsThemeProps } from "./components/settings/SettingsTheme";
|
||||||
|
export { SettingsTheme } from "./components/settings/SettingsTheme";
|
||||||
|
export * from "./components/settings/types";
|
||||||
export type { ResizeHandleEvent } from "./components/ResizeHandle";
|
export type { ResizeHandleEvent } from "./components/ResizeHandle";
|
||||||
export { ResizeHandle } from "./components/ResizeHandle";
|
export { ResizeHandle } from "./components/ResizeHandle";
|
||||||
export { SidebarLayout } from "./components/SidebarLayout";
|
export { SidebarLayout } from "./components/SidebarLayout";
|
||||||
@@ -219,6 +259,7 @@ export { languageFromContent, languageFromContentType } from "./lib/contentType"
|
|||||||
export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "./lib/mimeType";
|
export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "./lib/mimeType";
|
||||||
export { textLikelyContainsJsonComments } from "./lib/jsonComments";
|
export { textLikelyContainsJsonComments } from "./lib/jsonComments";
|
||||||
export { validateHttpHeader } from "./lib/validateHttpHeader";
|
export { validateHttpHeader } from "./lib/validateHttpHeader";
|
||||||
|
export { isYaakUrl } from "./lib/yaakUrl";
|
||||||
export { computeSideForDragMove } from "./lib/dnd";
|
export { computeSideForDragMove } from "./lib/dnd";
|
||||||
export { docFingerprint } from "./lib/docFingerprint";
|
export { docFingerprint } from "./lib/docFingerprint";
|
||||||
export { getNodeText } from "./lib/getNodeText";
|
export { getNodeText } from "./lib/getNodeText";
|
||||||
|
|||||||
@@ -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