mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-31 22:43:11 +02:00
Refactor desktop app into separate client and proxy apps
This commit is contained in:
159
apps/yaak-client/components/Settings/Settings.tsx
Normal file
159
apps/yaak-client/components/Settings/Settings.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useSearch } from '@tanstack/react-router';
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import { useLicense } from '@yaakapp-internal/license';
|
||||
import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { appInfo } from '../../lib/appInfo';
|
||||
import { capitalize } from '../../lib/capitalize';
|
||||
import { CountBadge } from '../core/CountBadge';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { HStack } from '../core/Stacks';
|
||||
import { TabContent, type TabItem, Tabs } from '../core/Tabs/Tabs';
|
||||
import { HeaderSize } from '../HeaderSize';
|
||||
import { SettingsCertificates } from './SettingsCertificates';
|
||||
import { SettingsGeneral } from './SettingsGeneral';
|
||||
import { SettingsHotkeys } from './SettingsHotkeys';
|
||||
import { SettingsInterface } from './SettingsInterface';
|
||||
import { SettingsLicense } from './SettingsLicense';
|
||||
import { SettingsPlugins } from './SettingsPlugins';
|
||||
import { SettingsProxy } from './SettingsProxy';
|
||||
import { SettingsTheme } from './SettingsTheme';
|
||||
|
||||
interface Props {
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
const TAB_GENERAL = 'general';
|
||||
const TAB_INTERFACE = 'interface';
|
||||
const TAB_THEME = 'theme';
|
||||
const TAB_SHORTCUTS = 'shortcuts';
|
||||
const TAB_PROXY = 'proxy';
|
||||
const TAB_CERTIFICATES = 'certificates';
|
||||
const TAB_PLUGINS = 'plugins';
|
||||
const TAB_LICENSE = 'license';
|
||||
const tabs = [
|
||||
TAB_GENERAL,
|
||||
TAB_THEME,
|
||||
TAB_INTERFACE,
|
||||
TAB_SHORTCUTS,
|
||||
TAB_PLUGINS,
|
||||
TAB_CERTIFICATES,
|
||||
TAB_PROXY,
|
||||
TAB_LICENSE,
|
||||
] as const;
|
||||
export type SettingsTab = (typeof tabs)[number];
|
||||
|
||||
export default function Settings({ hide }: Props) {
|
||||
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
|
||||
// Parse tab and subtab (e.g., "plugins:installed")
|
||||
const [mainTab, subtab] = tabFromQuery?.split(':') ?? [];
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const licenseCheck = useLicense();
|
||||
|
||||
// Close settings window on escape
|
||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||
useKeyPressEvent('Escape', async () => {
|
||||
if (hide != null) {
|
||||
// It's being shown in a dialog, so close the dialog
|
||||
hide();
|
||||
} else {
|
||||
// It's being shown in a window, so close the window
|
||||
await getCurrentWebviewWindow().close();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classNames('grid grid-rows-[auto_minmax(0,1fr)] h-full')}>
|
||||
{hide ? (
|
||||
<span />
|
||||
) : (
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
ignoreControlsSpacing
|
||||
onlyXWindowControl
|
||||
size="md"
|
||||
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
||||
>
|
||||
<HStack
|
||||
space={2}
|
||||
justifyContent="center"
|
||||
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
||||
>
|
||||
<div className={classNames(type() === 'macos' ? 'text-center' : 'pl-2')}>Settings</div>
|
||||
</HStack>
|
||||
</HeaderSize>
|
||||
)}
|
||||
<Tabs
|
||||
layout="horizontal"
|
||||
defaultValue={mainTab || tabFromQuery}
|
||||
addBorders
|
||||
tabListClassName="min-w-[10rem] 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,
|
||||
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_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_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>
|
||||
);
|
||||
}
|
||||
253
apps/yaak-client/components/Settings/SettingsCertificates.tsx
Normal file
253
apps/yaak-client/components/Settings/SettingsCertificates.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { ClientCertificate } from '@yaakapp-internal/models';
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRef } from 'react';
|
||||
import { showConfirmDelete } from '../../lib/confirm';
|
||||
import { Button } from '../core/Button';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { DetailsBanner } from '../core/DetailsBanner';
|
||||
import { Heading } from '../core/Heading';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
import { SelectFile } from '../SelectFile';
|
||||
|
||||
function createEmptyCertificate(): ClientCertificate {
|
||||
return {
|
||||
host: '',
|
||||
port: null,
|
||||
crtFile: null,
|
||||
keyFile: null,
|
||||
pfxFile: null,
|
||||
passphrase: null,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
interface CertificateEditorProps {
|
||||
certificate: ClientCertificate;
|
||||
index: number;
|
||||
onUpdate: (index: number, cert: ClientCertificate) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
function CertificateEditor({ certificate, index, onUpdate, onRemove }: CertificateEditorProps) {
|
||||
const updateField = <K extends keyof ClientCertificate>(
|
||||
field: K,
|
||||
value: ClientCertificate[K],
|
||||
) => {
|
||||
onUpdate(index, { ...certificate, [field]: value });
|
||||
};
|
||||
|
||||
const hasPfx = Boolean(certificate.pfxFile && certificate.pfxFile.length > 0);
|
||||
const hasCrtKey = Boolean(
|
||||
(certificate.crtFile && certificate.crtFile.length > 0) ||
|
||||
(certificate.keyFile && certificate.keyFile.length > 0),
|
||||
);
|
||||
|
||||
// Determine certificate type for display
|
||||
const certType = hasPfx ? 'PFX' : hasCrtKey ? 'CERT' : null;
|
||||
const defaultOpen = useRef<boolean>(!certificate.host);
|
||||
|
||||
return (
|
||||
<DetailsBanner
|
||||
defaultOpen={defaultOpen.current}
|
||||
summary={
|
||||
<HStack alignItems="center" justifyContent="between" space={2} className="w-full">
|
||||
<HStack space={1.5}>
|
||||
<Checkbox
|
||||
className="ml-1"
|
||||
checked={certificate.enabled ?? true}
|
||||
title={certificate.enabled ? 'Disable certificate' : 'Enable certificate'}
|
||||
hideLabel
|
||||
onChange={(enabled) => updateField('enabled', enabled)}
|
||||
/>
|
||||
|
||||
{certificate.host ? (
|
||||
<InlineCode>
|
||||
{certificate.host || <> </>}
|
||||
{certificate.port != null && `:${certificate.port}`}
|
||||
</InlineCode>
|
||||
) : (
|
||||
<span className="italic text-sm text-text-subtlest">Configure Certificate</span>
|
||||
)}
|
||||
{certType && <InlineCode>{certType}</InlineCode>}
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="sm"
|
||||
title="Remove certificate"
|
||||
className="text-text-subtlest -mr-2"
|
||||
onClick={() => onRemove(index)}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<VStack space={3} className="mt-2">
|
||||
<HStack space={2} alignItems="end">
|
||||
<PlainInput
|
||||
leftSlot={
|
||||
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
|
||||
https://
|
||||
</div>
|
||||
}
|
||||
validate={(value) => {
|
||||
if (!value) return false;
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(value)) return false;
|
||||
return true;
|
||||
}}
|
||||
label="Host"
|
||||
placeholder="example.com"
|
||||
size="sm"
|
||||
required
|
||||
defaultValue={certificate.host}
|
||||
onChange={(host) => updateField('host', host)}
|
||||
/>
|
||||
<PlainInput
|
||||
label="Port"
|
||||
hideLabel
|
||||
validate={(value) => {
|
||||
if (!value) return true;
|
||||
if (Number.isNaN(parseInt(value, 10))) return false;
|
||||
return true;
|
||||
}}
|
||||
placeholder="443"
|
||||
leftSlot={
|
||||
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
|
||||
:
|
||||
</div>
|
||||
}
|
||||
size="sm"
|
||||
className="w-24"
|
||||
defaultValue={certificate.port?.toString() ?? ''}
|
||||
onChange={(port) => updateField('port', port ? parseInt(port, 10) : null)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<VStack space={2}>
|
||||
<SelectFile
|
||||
label="CRT File"
|
||||
noun="Cert"
|
||||
filePath={certificate.crtFile ?? null}
|
||||
size="sm"
|
||||
disabled={hasPfx}
|
||||
onChange={({ filePath }) => updateField('crtFile', filePath)}
|
||||
/>
|
||||
<SelectFile
|
||||
label="KEY File"
|
||||
noun="Key"
|
||||
filePath={certificate.keyFile ?? null}
|
||||
size="sm"
|
||||
disabled={hasPfx}
|
||||
onChange={({ filePath }) => updateField('keyFile', filePath)}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<SelectFile
|
||||
label="PFX File"
|
||||
noun="Key"
|
||||
filePath={certificate.pfxFile ?? null}
|
||||
size="sm"
|
||||
disabled={hasCrtKey}
|
||||
onChange={({ filePath }) => updateField('pfxFile', filePath)}
|
||||
/>
|
||||
|
||||
<PlainInput
|
||||
label="Passphrase"
|
||||
size="sm"
|
||||
type="password"
|
||||
defaultValue={certificate.passphrase ?? ''}
|
||||
onChange={(passphrase) => updateField('passphrase', passphrase || null)}
|
||||
/>
|
||||
</VStack>
|
||||
</DetailsBanner>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsCertificates() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const certificates = settings.clientCertificates ?? [];
|
||||
|
||||
const updateCertificates = async (newCertificates: ClientCertificate[]) => {
|
||||
await patchModel(settings, { clientCertificates: newCertificates });
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
const newCert = createEmptyCertificate();
|
||||
await updateCertificates([...certificates, newCert]);
|
||||
};
|
||||
|
||||
const handleUpdate = async (index: number, cert: ClientCertificate) => {
|
||||
const newCertificates = [...certificates];
|
||||
newCertificates[index] = cert;
|
||||
await updateCertificates(newCertificates);
|
||||
};
|
||||
|
||||
const handleRemove = async (index: number) => {
|
||||
const cert = certificates[index];
|
||||
if (cert == null) return;
|
||||
|
||||
const host = cert.host || 'this certificate';
|
||||
const port = cert.port != null ? `:${cert.port}` : '';
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: 'confirm-remove-certificate',
|
||||
title: 'Delete Certificate',
|
||||
description: (
|
||||
<>
|
||||
Permanently delete certificate for{' '}
|
||||
<InlineCode>
|
||||
{host}
|
||||
{port}
|
||||
</InlineCode>
|
||||
?
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const newCertificates = certificates.filter((_, i) => i !== index);
|
||||
|
||||
await updateCertificates(newCertificates);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={3}>
|
||||
<div className="mb-3">
|
||||
<HStack justifyContent="between" alignItems="start">
|
||||
<div>
|
||||
<Heading>Client Certificates</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Add and manage TLS certificates on a per domain basis
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
|
||||
Add Certificate
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{certificates.length > 0 && (
|
||||
<VStack space={3}>
|
||||
{certificates.map((cert, index) => (
|
||||
<CertificateEditor
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Index is fine here
|
||||
key={index}
|
||||
certificate={cert}
|
||||
index={index}
|
||||
onUpdate={handleUpdate}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
176
apps/yaak-client/components/Settings/SettingsGeneral.tsx
Normal file
176
apps/yaak-client/components/Settings/SettingsGeneral.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
||||
import { useCheckForUpdates } from '../../hooks/useCheckForUpdates';
|
||||
import { appInfo } from '../../lib/appInfo';
|
||||
import { revealInFinderText } from '../../lib/reveal';
|
||||
import { CargoFeature } from '../CargoFeature';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { Heading } from '../core/Heading';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { KeyValueRow, KeyValueRows } from '../core/KeyValueRow';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { Select } from '../core/Select';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { VStack } from '../core/Stacks';
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const checkForUpdates = useCheckForUpdates();
|
||||
|
||||
if (settings == null || workspace == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-4">
|
||||
<Heading>General</Heading>
|
||||
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
||||
</div>
|
||||
<CargoFeature feature="updater">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
|
||||
<Select
|
||||
name="updateChannel"
|
||||
label="Update Channel"
|
||||
labelPosition="left"
|
||||
labelClassName="w-[14rem]"
|
||||
size="sm"
|
||||
value={settings.updateChannel}
|
||||
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
|
||||
options={[
|
||||
{ label: 'Stable', value: 'stable' },
|
||||
{ label: 'Beta (more frequent)', value: 'beta' },
|
||||
]}
|
||||
/>
|
||||
<IconButton
|
||||
variant="border"
|
||||
size="sm"
|
||||
title="Check for updates"
|
||||
icon="refresh"
|
||||
spin={checkForUpdates.isPending}
|
||||
onClick={() => checkForUpdates.mutateAsync()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
name="autoupdate"
|
||||
value={settings.autoupdate ? 'auto' : 'manual'}
|
||||
label="Update Behavior"
|
||||
labelPosition="left"
|
||||
size="sm"
|
||||
labelClassName="w-[14rem]"
|
||||
onChange={(v) => patchModel(settings, { autoupdate: v === 'auto' })}
|
||||
options={[
|
||||
{ label: 'Automatic', value: 'auto' },
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
]}
|
||||
/>
|
||||
<Checkbox
|
||||
className="pl-2 mt-1 ml-[14rem]"
|
||||
checked={settings.autoDownloadUpdates}
|
||||
disabled={!settings.autoupdate}
|
||||
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
|
||||
title="Automatically download updates"
|
||||
onChange={(autoDownloadUpdates) => patchModel(settings, { autoDownloadUpdates })}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
className="pl-2 mt-1 ml-[14rem]"
|
||||
checked={settings.checkNotifications}
|
||||
title="Check for notifications"
|
||||
help="Periodically ping Yaak servers to check for relevant notifications."
|
||||
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
||||
/>
|
||||
<Checkbox
|
||||
disabled
|
||||
className="pl-2 mt-1 ml-[14rem]"
|
||||
checked={false}
|
||||
title="Send anonymous usage statistics"
|
||||
help="Yaak is local-first and does not collect analytics or usage data 🔐"
|
||||
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
||||
/>
|
||||
</CargoFeature>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<Heading level={2}>
|
||||
Workspace{' '}
|
||||
<div className="inline-block ml-1 bg-surface-highlight px-2 py-0.5 rounded text text-shrink">
|
||||
{workspace.name}
|
||||
</div>
|
||||
</Heading>
|
||||
<VStack className="mt-1 w-full" space={3}>
|
||||
<PlainInput
|
||||
required
|
||||
size="sm"
|
||||
name="requestTimeout"
|
||||
label="Request Timeout (ms)"
|
||||
labelClassName="w-[14rem]"
|
||||
placeholder="0"
|
||||
labelPosition="left"
|
||||
defaultValue={`${workspace.settingRequestTimeout}`}
|
||||
validate={(value) => Number.parseInt(value, 10) >= 0}
|
||||
onChange={(v) =>
|
||||
patchModel(workspace, { settingRequestTimeout: Number.parseInt(v, 10) || 0 })
|
||||
}
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={workspace.settingValidateCertificates}
|
||||
help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
|
||||
title="Validate TLS certificates"
|
||||
onChange={(settingValidateCertificates) =>
|
||||
patchModel(workspace, { settingValidateCertificates })
|
||||
}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={workspace.settingFollowRedirects}
|
||||
title="Follow redirects"
|
||||
onChange={(settingFollowRedirects) =>
|
||||
patchModel(workspace, {
|
||||
settingFollowRedirects,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<Heading level={2}>App Info</Heading>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Version">{appInfo.version}</KeyValueRow>
|
||||
<KeyValueRow
|
||||
label="Data Directory"
|
||||
rightSlot={
|
||||
<IconButton
|
||||
title={revealInFinderText}
|
||||
icon="folder_open"
|
||||
size="2xs"
|
||||
onClick={() => revealItemInDir(appInfo.appDataDir)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{appInfo.appDataDir}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow
|
||||
label="Logs Directory"
|
||||
rightSlot={
|
||||
<IconButton
|
||||
title={revealInFinderText}
|
||||
icon="folder_open"
|
||||
size="2xs"
|
||||
onClick={() => revealItemInDir(appInfo.appLogDir)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{appInfo.appLogDir}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
357
apps/yaak-client/components/Settings/SettingsHotkeys.tsx
Normal file
357
apps/yaak-client/components/Settings/SettingsHotkeys.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { fuzzyMatch } from 'fuzzbunny';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
defaultHotkeys,
|
||||
formatHotkeyString,
|
||||
getHotkeyScope,
|
||||
type HotkeyAction,
|
||||
hotkeyActions,
|
||||
hotkeysAtom,
|
||||
useHotkeyLabel,
|
||||
} from '../../hooks/useHotKey';
|
||||
import { capitalize } from '../../lib/capitalize';
|
||||
import { showDialog } from '../../lib/dialog';
|
||||
import { Button } from '../core/Button';
|
||||
import { Dropdown, type DropdownItem } from '../core/Dropdown';
|
||||
import { Heading } from '../core/Heading';
|
||||
import { HotkeyRaw } from '../core/Hotkey';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
|
||||
|
||||
const HOLD_KEYS = ['Shift', 'Control', 'Alt', 'Meta'];
|
||||
const LAYOUT_INSENSITIVE_KEYS = [
|
||||
'Equal',
|
||||
'Minus',
|
||||
'BracketLeft',
|
||||
'BracketRight',
|
||||
'Backquote',
|
||||
'Space',
|
||||
];
|
||||
|
||||
/** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */
|
||||
function eventToHotkeyString(e: KeyboardEvent): string | null {
|
||||
// Don't capture modifier-only key presses
|
||||
if (HOLD_KEYS.includes(e.key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Add modifiers in consistent order (Meta, Control, Alt, Shift)
|
||||
if (e.metaKey) {
|
||||
parts.push('Meta');
|
||||
}
|
||||
if (e.ctrlKey) {
|
||||
parts.push('Control');
|
||||
}
|
||||
if (e.altKey) {
|
||||
parts.push('Alt');
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
parts.push('Shift');
|
||||
}
|
||||
|
||||
// Get the main key - use the same logic as useHotKey.ts
|
||||
const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key;
|
||||
parts.push(key);
|
||||
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
export function SettingsHotkeys() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const hotkeys = useAtomValue(hotkeysAtom);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filteredActions = useMemo(() => {
|
||||
if (!filter.trim()) {
|
||||
return hotkeyActions;
|
||||
}
|
||||
return hotkeyActions.filter((action) => {
|
||||
const scope = getHotkeyScope(action).replace(/_/g, ' ');
|
||||
const label = action.replace(/[_.]/g, ' ');
|
||||
const searchText = `${scope} ${label}`;
|
||||
return fuzzyMatch(searchText, filter) != null;
|
||||
});
|
||||
}, [filter]);
|
||||
|
||||
if (settings == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Keyboard Shortcuts</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Click the menu button to add, remove, or reset keyboard shortcuts.
|
||||
</p>
|
||||
</div>
|
||||
<PlainInput
|
||||
label="Filter"
|
||||
placeholder="Filter shortcuts..."
|
||||
defaultValue={filter}
|
||||
onChange={setFilter}
|
||||
hideLabel
|
||||
containerClassName="max-w-xs"
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Scope</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
<TableHeaderCell>Shortcut</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
||||
<TableBody key={filter}>
|
||||
{filteredActions.map((action) => (
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action]}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
if (arraysEqual(keys, defaultHotkeys[action])) {
|
||||
// Remove from settings if it matches default (use default)
|
||||
delete newHotkeys[action];
|
||||
} else {
|
||||
// Store the keys (including empty array to disable)
|
||||
newHotkeys[action] = keys;
|
||||
}
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
onReset={async () => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
delete newHotkeys[action];
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
interface HotkeyRowProps {
|
||||
action: HotkeyAction;
|
||||
currentKeys: string[];
|
||||
defaultKeys: string[];
|
||||
onSave: (keys: string[]) => Promise<void>;
|
||||
onReset: () => Promise<void>;
|
||||
}
|
||||
|
||||
function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) {
|
||||
const label = useHotkeyLabel(action);
|
||||
const scope = capitalize(getHotkeyScope(action).replace(/_/g, ' '));
|
||||
const isCustomized = !arraysEqual(currentKeys, defaultKeys);
|
||||
const isDisabled = currentKeys.length === 0;
|
||||
|
||||
const handleStartRecording = useCallback(() => {
|
||||
showDialog({
|
||||
id: `record-hotkey-${action}`,
|
||||
title: label,
|
||||
size: 'sm',
|
||||
render: ({ hide }) => (
|
||||
<RecordHotkeyDialog
|
||||
label={label}
|
||||
onSave={async (key) => {
|
||||
await onSave([...currentKeys, key]);
|
||||
hide();
|
||||
}}
|
||||
onCancel={hide}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [action, label, currentKeys, onSave]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
async (keyToRemove: string) => {
|
||||
const newKeys = currentKeys.filter((k) => k !== keyToRemove);
|
||||
await onSave(newKeys);
|
||||
},
|
||||
[currentKeys, onSave],
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(async () => {
|
||||
await onSave([]);
|
||||
}, [onSave]);
|
||||
|
||||
// Build dropdown items dynamically
|
||||
const dropdownItems: DropdownItem[] = [
|
||||
{
|
||||
label: 'Add Keyboard Shortcut',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: handleStartRecording,
|
||||
},
|
||||
];
|
||||
|
||||
// Add remove options for each existing shortcut
|
||||
if (!isDisabled) {
|
||||
currentKeys.forEach((key) => {
|
||||
dropdownItems.push({
|
||||
label: (
|
||||
<HStack space={1.5}>
|
||||
<span>Remove</span>
|
||||
<HotkeyRaw labelParts={formatHotkeyString(key)} variant="with-bg" className="text-xs" />
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => handleRemove(key),
|
||||
});
|
||||
});
|
||||
|
||||
if (currentKeys.length > 1) {
|
||||
dropdownItems.push(
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Remove All Shortcuts',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: handleClearAll,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isCustomized) {
|
||||
dropdownItems.push({
|
||||
type: 'separator',
|
||||
});
|
||||
dropdownItems.push({
|
||||
label: 'Reset to Default',
|
||||
leftSlot: <Icon icon="refresh" />,
|
||||
onSelect: onReset,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span className="text-sm text-text-subtlest">{scope}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{label}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HStack space={1.5} className="py-1">
|
||||
{isDisabled ? (
|
||||
<span className="text-text-subtlest">Disabled</span>
|
||||
) : (
|
||||
currentKeys.map((k) => (
|
||||
<HotkeyRaw key={k} labelParts={formatHotkeyString(k)} variant="with-bg" />
|
||||
))
|
||||
)}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Dropdown items={dropdownItems}>
|
||||
<IconButton
|
||||
icon="ellipsis_vertical"
|
||||
size="sm"
|
||||
title="Hotkey actions"
|
||||
className="ml-auto text-text-subtlest"
|
||||
/>
|
||||
</Dropdown>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((v, i) => v === sortedB[i]);
|
||||
}
|
||||
|
||||
interface RecordHotkeyDialogProps {
|
||||
label: string;
|
||||
onSave: (key: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) {
|
||||
const [recordedKey, setRecordedKey] = useState<string | null>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const hotkeyString = eventToHotkeyString(e);
|
||||
if (hotkeyString) {
|
||||
setRecordedKey(hotkeyString);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [isFocused, onCancel]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (recordedKey) {
|
||||
onSave(recordedKey);
|
||||
}
|
||||
}, [recordedKey, onSave]);
|
||||
|
||||
return (
|
||||
<VStack space={4}>
|
||||
<div>
|
||||
<p className="text-text-subtle mb-2">
|
||||
Record a key combination for <span className="font-semibold">{label}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-disable-hotkey
|
||||
aria-label="Keyboard shortcut input"
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
className={classNames(
|
||||
'flex items-center justify-center',
|
||||
'px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full',
|
||||
'border-border-subtle focus:border-border-focus',
|
||||
)}
|
||||
>
|
||||
{recordedKey ? (
|
||||
<HotkeyRaw labelParts={formatHotkeyString(recordedKey)} />
|
||||
) : (
|
||||
<span className="text-text-subtlest">Press keys...</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onClick={handleSave} disabled={!recordedKey}>
|
||||
Save
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
245
apps/yaak-client/components/Settings/SettingsInterface.tsx
Normal file
245
apps/yaak-client/components/Settings/SettingsInterface.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import { useFonts } from '@yaakapp-internal/fonts';
|
||||
import { useLicense } from '@yaakapp-internal/license';
|
||||
import type { EditorKeymap, Settings } from '@yaakapp-internal/models';
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
||||
import { clamp } from '../../lib/clamp';
|
||||
import { showConfirm } from '../../lib/confirm';
|
||||
import { invokeCmd } from '../../lib/tauri';
|
||||
import { CargoFeature } from '../CargoFeature';
|
||||
import { Button } from '../core/Button';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { Heading } from '../core/Heading';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { Link } from '../core/Link';
|
||||
import { Select } from '../core/Select';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
|
||||
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() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const fonts = useFonts();
|
||||
|
||||
if (settings == null || workspace == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Interface</Heading>
|
||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
||||
</div>
|
||||
<Select
|
||||
name="switchWorkspaceBehavior"
|
||||
label="Open workspace behavior"
|
||||
size="sm"
|
||||
help="When opening a workspace, should it open in the current window or a new window?"
|
||||
value={
|
||||
settings.openWorkspaceNewWindow === true
|
||||
? 'new'
|
||||
: settings.openWorkspaceNewWindow === false
|
||||
? 'current'
|
||||
: 'ask'
|
||||
}
|
||||
onChange={async (v) => {
|
||||
if (v === 'current') await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||
else if (v === 'new') await patchModel(settings, { openWorkspaceNewWindow: true });
|
||||
else await patchModel(settings, { openWorkspaceNewWindow: null });
|
||||
}}
|
||||
options={[
|
||||
{ label: 'Always ask', value: 'ask' },
|
||||
{ label: 'Open in current window', value: 'current' },
|
||||
{ label: 'Open in new window', value: 'new' },
|
||||
]}
|
||||
/>
|
||||
<HStack space={2} alignItems="end">
|
||||
{fonts.data && (
|
||||
<Select
|
||||
size="sm"
|
||||
name="uiFont"
|
||||
label="Interface font"
|
||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
||||
options={[
|
||||
{ label: 'System default', value: NULL_FONT_VALUE },
|
||||
...(fonts.data.uiFonts.map((f) => ({
|
||||
label: f,
|
||||
value: f,
|
||||
})) ?? []),
|
||||
// Some people like monospace fonts for the UI
|
||||
...(fonts.data.editorFonts.map((f) => ({
|
||||
label: f,
|
||||
value: f,
|
||||
})) ?? []),
|
||||
]}
|
||||
onChange={async (v) => {
|
||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
||||
await patchModel(settings, { interfaceFont });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
hideLabel
|
||||
size="sm"
|
||||
name="interfaceFontSize"
|
||||
label="Interface Font Size"
|
||||
defaultValue="14"
|
||||
value={`${settings.interfaceFontSize}`}
|
||||
options={fontSizeOptions}
|
||||
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
||||
/>
|
||||
</HStack>
|
||||
<HStack space={2} alignItems="end">
|
||||
{fonts.data && (
|
||||
<Select
|
||||
size="sm"
|
||||
name="editorFont"
|
||||
label="Editor font"
|
||||
value={settings.editorFont ?? 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 });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
hideLabel
|
||||
size="sm"
|
||||
name="editorFontSize"
|
||||
label="Editor Font Size"
|
||||
defaultValue="12"
|
||||
value={`${settings.editorFontSize}`}
|
||||
options={fontSizeOptions}
|
||||
onChange={(v) =>
|
||||
patchModel(settings, { editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30) })
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
<Select
|
||||
leftSlot={<Icon icon="keyboard" color="secondary" />}
|
||||
size="sm"
|
||||
name="editorKeymap"
|
||||
label="Editor keymap"
|
||||
value={`${settings.editorKeymap}`}
|
||||
options={keymaps}
|
||||
onChange={(v) => patchModel(settings, { editorKeymap: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={settings.editorSoftWrap}
|
||||
title="Wrap editor lines"
|
||||
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={settings.coloredMethods}
|
||||
title="Colorize request methods"
|
||||
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
|
||||
/>
|
||||
<CargoFeature feature="license">
|
||||
<LicenseSettings settings={settings} />
|
||||
</CargoFeature>
|
||||
|
||||
<NativeTitlebarSetting settings={settings} />
|
||||
|
||||
{type() !== 'macos' && (
|
||||
<Checkbox
|
||||
checked={settings.hideWindowControls}
|
||||
title="Hide window controls"
|
||||
help="Hide the close/maximize/minimize controls on Windows or Linux"
|
||||
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
||||
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
||||
return (
|
||||
<div className="flex gap-1 overflow-hidden h-2xs">
|
||||
<Checkbox
|
||||
checked={nativeTitlebar}
|
||||
title="Native title bar"
|
||||
help="Use the operating system's standard title bar and window controls"
|
||||
onChange={setNativeTitlebar}
|
||||
/>
|
||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="2xs"
|
||||
onClick={async () => {
|
||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
||||
await invokeCmd('cmd_restart');
|
||||
}}
|
||||
>
|
||||
Apply and Restart
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseSettings({ settings }: { settings: Settings }) {
|
||||
const license = useLicense();
|
||||
if (license.check.data?.status !== 'personal_use') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={settings.hideLicenseBadge}
|
||||
title="Hide personal use badge"
|
||||
onChange={async (hideLicenseBadge) => {
|
||||
if (hideLicenseBadge) {
|
||||
const confirmed = await showConfirm({
|
||||
id: 'hide-license-badge',
|
||||
title: 'Confirm Personal Use',
|
||||
confirmText: 'Confirm',
|
||||
description: (
|
||||
<VStack space={3}>
|
||||
<p>Hey there 👋🏼</p>
|
||||
<p>
|
||||
Yaak is free for personal projects and learning.{' '}
|
||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Licenses help keep Yaak independent and sustainable.{' '}
|
||||
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
requireTyping: 'Personal Use',
|
||||
color: 'info',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return; // Cancel
|
||||
}
|
||||
}
|
||||
await patchModel(settings, { hideLicenseBadge });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
191
apps/yaak-client/components/Settings/SettingsLicense.tsx
Normal file
191
apps/yaak-client/components/Settings/SettingsLicense.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { useLicense } from '@yaakapp-internal/license';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { formatDate } from 'date-fns/format';
|
||||
import { useState } from 'react';
|
||||
import { useToggle } from '../../hooks/useToggle';
|
||||
import { pluralizeCount } from '../../lib/pluralize';
|
||||
import { CargoFeature } from '../CargoFeature';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { Button } from '../core/Button';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { Link } from '../core/Link';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
import { LocalImage } from '../LocalImage';
|
||||
|
||||
export function SettingsLicense() {
|
||||
return (
|
||||
<CargoFeature feature="license">
|
||||
<SettingsLicenseCmp />
|
||||
</CargoFeature>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsLicenseCmp() {
|
||||
const { check, activate, deactivate } = useLicense();
|
||||
const [key, setKey] = useState<string>('');
|
||||
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
|
||||
|
||||
if (check.isPending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderBanner = () => {
|
||||
if (!check.data) return null;
|
||||
|
||||
switch (check.data.status) {
|
||||
case 'active':
|
||||
return <Banner color="success">Your license is active 🥳</Banner>;
|
||||
|
||||
case 'trialing':
|
||||
return (
|
||||
<Banner color="info" className="max-w-lg">
|
||||
<p className="w-full">
|
||||
<strong>
|
||||
{pluralizeCount('day', differenceInDays(check.data.data.end, new Date()))}
|
||||
</strong>{' '}
|
||||
left to evaluate Yaak for commercial use.
|
||||
<br />
|
||||
<span className="opacity-50">Personal use is always free, forever.</span>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${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={`https://yaak.app/pricing?s=learn&t=${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={() => openUrl('https://yaak.app/dashboard?s=support&ref=app.yaak.desktop')}
|
||||
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={() =>
|
||||
openUrl(
|
||||
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ''}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
419
apps/yaak-client/components/Settings/SettingsPlugins.tsx
Normal file
419
apps/yaak-client/components/Settings/SettingsPlugins.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import type { Plugin } from '@yaakapp-internal/models';
|
||||
import { patchModel, pluginsAtom } from '@yaakapp-internal/models';
|
||||
import type { PluginVersion } from '@yaakapp-internal/plugins';
|
||||
import {
|
||||
checkPluginUpdates,
|
||||
installPlugin,
|
||||
searchPlugins,
|
||||
uninstallPlugin,
|
||||
} from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
|
||||
import { useInstallPlugin } from '../../hooks/useInstallPlugin';
|
||||
import { usePluginInfo } from '../../hooks/usePluginInfo';
|
||||
import { usePluginsKey, useRefreshPlugins } from '../../hooks/usePlugins';
|
||||
import { showConfirmDelete } from '../../lib/confirm';
|
||||
import { minPromiseMillis } from '../../lib/minPromiseMillis';
|
||||
import { Button } from '../core/Button';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { CountBadge } from '../core/CountBadge';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { Link } from '../core/Link';
|
||||
import { LoadingIcon } from '../core/LoadingIcon';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { HStack } from '../core/Stacks';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
|
||||
import { TabContent, Tabs } from '../core/Tabs/Tabs';
|
||||
import { EmptyStateText } from '../EmptyStateText';
|
||||
import { SelectFile } from '../SelectFile';
|
||||
|
||||
interface SettingsPluginsProps {
|
||||
defaultSubtab?: string;
|
||||
}
|
||||
|
||||
export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
||||
const [directory, setDirectory] = useState<string | null>(null);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const bundledPlugins = plugins.filter((p) => p.source === 'bundled');
|
||||
const installedPlugins = plugins.filter((p) => p.source !== 'bundled');
|
||||
const createPlugin = useInstallPlugin();
|
||||
const refreshPlugins = useRefreshPlugins();
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Tabs
|
||||
defaultValue={defaultSubtab}
|
||||
label="Plugins"
|
||||
addBorders
|
||||
tabListClassName="px-6 pt-2"
|
||||
tabs={[
|
||||
{ label: 'Discover', value: 'search' },
|
||||
{
|
||||
label: 'Installed',
|
||||
value: 'installed',
|
||||
rightSlot: <CountBadge count={installedPlugins.length} />,
|
||||
},
|
||||
{
|
||||
label: 'Bundled',
|
||||
value: 'bundled',
|
||||
rightSlot: <CountBadge count={bundledPlugins.length} />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TabContent value="search" className="px-6">
|
||||
<PluginSearch />
|
||||
</TabContent>
|
||||
<TabContent value="installed" className="pb-0">
|
||||
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto]">
|
||||
<InstalledPlugins plugins={installedPlugins} className="px-6" />
|
||||
<footer className="grid grid-cols-[minmax(0,1fr)_auto] py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
|
||||
<SelectFile
|
||||
size="xs"
|
||||
noun="Plugin"
|
||||
directory
|
||||
onChange={({ filePath }) => setDirectory(filePath)}
|
||||
filePath={directory}
|
||||
/>
|
||||
<HStack>
|
||||
{directory && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="primary"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
if (directory == null) return;
|
||||
createPlugin.mutate(directory);
|
||||
setDirectory(null);
|
||||
}}
|
||||
>
|
||||
Add Plugin
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon="refresh"
|
||||
title="Reload plugins"
|
||||
spin={refreshPlugins.isPending}
|
||||
onClick={() => refreshPlugins.mutate()}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon="help"
|
||||
title="View documentation"
|
||||
onClick={() =>
|
||||
openUrl('https://yaak.app/docs/plugin-development/plugins-quick-start')
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
</footer>
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="bundled" className="pb-0 px-6">
|
||||
<BundledPlugins plugins={bundledPlugins} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRowForInstalledPlugin({ plugin }: { plugin: Plugin }) {
|
||||
const info = usePluginInfo(plugin.id).data;
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginTableRow
|
||||
plugin={plugin}
|
||||
version={info.version}
|
||||
name={info.name}
|
||||
displayName={info.displayName}
|
||||
url={plugin.url}
|
||||
showCheckbox={true}
|
||||
showUninstall={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRowForBundledPlugin({ plugin }: { plugin: Plugin }) {
|
||||
const info = usePluginInfo(plugin.id).data;
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginTableRow
|
||||
plugin={plugin}
|
||||
version={info.version}
|
||||
name={info.name}
|
||||
displayName={info.displayName}
|
||||
url={plugin.url}
|
||||
showCheckbox={true}
|
||||
showUninstall={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRowForRemotePluginVersion({ pluginVersion }: { pluginVersion: PluginVersion }) {
|
||||
const plugin = useAtomValue(pluginsAtom).find((p) => p.id === pluginVersion.id);
|
||||
const pluginInfo = usePluginInfo(plugin?.id ?? null).data;
|
||||
|
||||
return (
|
||||
<PluginTableRow
|
||||
plugin={plugin ?? null}
|
||||
version={pluginInfo?.version ?? pluginVersion.version}
|
||||
name={pluginVersion.name}
|
||||
displayName={pluginVersion.displayName}
|
||||
url={pluginVersion.url}
|
||||
showCheckbox={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRow({
|
||||
plugin,
|
||||
name,
|
||||
version,
|
||||
displayName,
|
||||
url,
|
||||
showCheckbox = true,
|
||||
showUninstall = true,
|
||||
}: {
|
||||
plugin: Plugin | null;
|
||||
name: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
url: string | null;
|
||||
showCheckbox?: boolean;
|
||||
showUninstall?: boolean;
|
||||
}) {
|
||||
const updates = usePluginUpdates();
|
||||
const latestVersion = updates.data?.plugins.find((u) => u.name === name)?.version;
|
||||
const installPluginMutation = useMutation({
|
||||
mutationKey: ['install_plugin', name],
|
||||
mutationFn: (name: string) => installPlugin(name, null),
|
||||
});
|
||||
const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
|
||||
const refreshPlugins = useRefreshPlugins();
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{showCheckbox && (
|
||||
<TableCell className="!py-0">
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title={plugin?.enabled ? 'Disable plugin' : 'Enable plugin'}
|
||||
checked={plugin?.enabled ?? false}
|
||||
disabled={plugin == null}
|
||||
onChange={async (enabled) => {
|
||||
if (plugin) {
|
||||
await patchModel(plugin, { enabled });
|
||||
refreshPlugins.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="font-semibold">
|
||||
{url ? (
|
||||
<Link noUnderline href={url}>
|
||||
{displayName}
|
||||
</Link>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<InlineCode>{name}</InlineCode>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HStack space={1.5}>
|
||||
<InlineCode>{version}</InlineCode>
|
||||
{latestVersion != null && (
|
||||
<InlineCode className="text-success flex items-center gap-1">
|
||||
<Icon icon="arrow_up" size="sm" />
|
||||
{latestVersion}
|
||||
</InlineCode>
|
||||
)}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
<TableCell className="!py-0">
|
||||
<HStack justifyContent="end" space={1.5}>
|
||||
{plugin != null && latestVersion != null ? (
|
||||
<Button
|
||||
variant="border"
|
||||
color="success"
|
||||
title={`Update to ${latestVersion}`}
|
||||
size="xs"
|
||||
isLoading={installPluginMutation.isPending}
|
||||
onClick={() => installPluginMutation.mutate(name)}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
) : plugin == null ? (
|
||||
<Button
|
||||
variant="border"
|
||||
color="primary"
|
||||
title={`Install ${version}`}
|
||||
size="xs"
|
||||
isLoading={installPluginMutation.isPending}
|
||||
onClick={() => installPluginMutation.mutate(name)}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
{showUninstall && uninstall != null && (
|
||||
<Button
|
||||
size="xs"
|
||||
title="Uninstall plugin"
|
||||
variant="border"
|
||||
isLoading={uninstall.isPending}
|
||||
onClick={() => uninstall.mutate()}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginSearch() {
|
||||
const [query, setQuery] = useState<string>('');
|
||||
const debouncedQuery = useDebouncedValue(query);
|
||||
const results = useQuery({
|
||||
queryKey: ['plugins', debouncedQuery],
|
||||
queryFn: () => searchPlugins(query),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<HStack space={1.5}>
|
||||
<PlainInput
|
||||
hideLabel
|
||||
label="Search"
|
||||
placeholder="Search plugins..."
|
||||
onChange={setQuery}
|
||||
defaultValue={query}
|
||||
/>
|
||||
</HStack>
|
||||
<div className="w-full h-full">
|
||||
{results.data == null ? (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon size="xl" className="text-text-subtlest" />
|
||||
</EmptyStateText>
|
||||
) : (results.data.plugins ?? []).length === 0 ? (
|
||||
<EmptyStateText>No plugins found</EmptyStateText>
|
||||
) : (
|
||||
<Table scrollable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{results.data.plugins.map((p) => (
|
||||
<PluginTableRowForRemotePluginVersion key={p.id} pluginVersion={p} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className?: string }) {
|
||||
return plugins.length === 0 ? (
|
||||
<div className={classNames(className, 'pb-4')}>
|
||||
<EmptyStateText className="text-center">
|
||||
Plugins extend the functionality of Yaak.
|
||||
<br />
|
||||
Add your first plugin to get started.
|
||||
</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<Table scrollable className={className}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
|
||||
return plugins.length === 0 ? (
|
||||
<div className="pb-4">
|
||||
<EmptyStateText className="text-center">No bundled plugins found.</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<Table scrollable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForBundledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function usePromptUninstall(pluginId: string | null, name: string) {
|
||||
const mut = useMutation({
|
||||
mutationKey: ['uninstall_plugin', pluginId],
|
||||
mutationFn: async () => {
|
||||
if (pluginId == null) return;
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: `uninstall-plugin-${pluginId}`,
|
||||
title: 'Uninstall Plugin',
|
||||
confirmText: 'Uninstall',
|
||||
description: (
|
||||
<>
|
||||
Permanently uninstall <InlineCode>{name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await minPromiseMillis(uninstallPlugin(pluginId), 700);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return pluginId == null ? null : mut;
|
||||
}
|
||||
|
||||
function usePluginUpdates() {
|
||||
return useQuery({
|
||||
queryKey: ['plugin_updates', usePluginsKey()],
|
||||
queryFn: () => checkPluginUpdates(),
|
||||
});
|
||||
}
|
||||
208
apps/yaak-client/components/Settings/SettingsProxy.tsx
Normal file
208
apps/yaak-client/components/Settings/SettingsProxy.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { Heading } from '../core/Heading';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { Select } from '../core/Select';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
|
||||
export function SettingsProxy() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Proxy</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
|
||||
traffic, or routing through specific infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
name="proxy"
|
||||
label="Proxy"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={settings.proxy?.type ?? 'automatic'}
|
||||
onChange={async (v) => {
|
||||
if (v === 'automatic') {
|
||||
await patchModel(settings, { proxy: undefined });
|
||||
} else if (v === 'enabled') {
|
||||
await patchModel(settings, {
|
||||
proxy: {
|
||||
disabled: false,
|
||||
type: 'enabled',
|
||||
http: '',
|
||||
https: '',
|
||||
auth: { user: '', password: '' },
|
||||
bypass: '',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await patchModel(settings, { proxy: { type: 'disabled' } });
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: 'Automatic proxy detection', value: 'automatic' },
|
||||
{ label: 'Custom proxy configuration', value: 'enabled' },
|
||||
{ label: 'No proxy', value: 'disabled' },
|
||||
]}
|
||||
/>
|
||||
{settings.proxy?.type === 'enabled' && (
|
||||
<VStack space={1.5}>
|
||||
<Checkbox
|
||||
className="my-3"
|
||||
checked={!settings.proxy.disabled}
|
||||
title="Enable proxy"
|
||||
help="Use this to temporarily disable the proxy without losing the configuration"
|
||||
onChange={async (enabled) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
|
||||
const disabled = !enabled;
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<HStack space={1.5}>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label={
|
||||
<>
|
||||
Proxy for <InlineCode>http://</InlineCode> traffic
|
||||
</>
|
||||
}
|
||||
placeholder="localhost:9090"
|
||||
defaultValue={settings.proxy?.http}
|
||||
onChange={async (http) => {
|
||||
const { proxy } = settings;
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
await patchModel(settings, {
|
||||
proxy: {
|
||||
type: 'enabled',
|
||||
http,
|
||||
https,
|
||||
auth,
|
||||
disabled,
|
||||
bypass,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label={
|
||||
<>
|
||||
Proxy for <InlineCode>https://</InlineCode> traffic
|
||||
</>
|
||||
}
|
||||
placeholder="localhost:9090"
|
||||
defaultValue={settings.proxy?.https}
|
||||
onChange={async (https) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<Separator className="my-6" />
|
||||
<Checkbox
|
||||
checked={settings.proxy.auth != null}
|
||||
title="Enable authentication"
|
||||
onChange={async (enabled) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const auth = enabled ? { user: '', password: '' } : null;
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{settings.proxy.auth != null && (
|
||||
<HStack space={1.5}>
|
||||
<PlainInput
|
||||
required
|
||||
size="sm"
|
||||
label="User"
|
||||
placeholder="myUser"
|
||||
defaultValue={settings.proxy.auth.user}
|
||||
onChange={async (user) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const password = proxy?.type === 'enabled' ? (proxy.auth?.password ?? '') : '';
|
||||
const auth = { user, password };
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="s3cretPassw0rd"
|
||||
defaultValue={settings.proxy.auth.password}
|
||||
onChange={async (password) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const user = proxy?.type === 'enabled' ? (proxy.auth?.user ?? '') : '';
|
||||
const auth = { user, password };
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
{settings.proxy.type === 'enabled' && (
|
||||
<>
|
||||
<Separator className="my-6" />
|
||||
<PlainInput
|
||||
label="Proxy Bypass"
|
||||
help="Comma-separated list to bypass the proxy."
|
||||
defaultValue={settings.proxy.bypass}
|
||||
placeholder="127.0.0.1, *.example.com, localhost:3000"
|
||||
onChange={async (bypass) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
const user = proxy?.type === 'enabled' ? (proxy.auth?.user ?? '') : '';
|
||||
const password = proxy?.type === 'enabled' ? (proxy.auth?.password ?? '') : '';
|
||||
const auth = { user, password };
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
175
apps/yaak-client/components/Settings/SettingsTheme.tsx
Normal file
175
apps/yaak-client/components/Settings/SettingsTheme.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
||||
import { useResolvedAppearance } from '../../hooks/useResolvedAppearance';
|
||||
import { useResolvedTheme } from '../../hooks/useResolvedTheme';
|
||||
import type { ButtonProps } from '../core/Button';
|
||||
import { Heading } from '../core/Heading';
|
||||
import type { IconProps } from '../core/Icon';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { Link } from '../core/Link';
|
||||
import type { SelectProps } from '../core/Select';
|
||||
import { Select } from '../core/Select';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
|
||||
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() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const appearance = useResolvedAppearance();
|
||||
const activeTheme = useResolvedTheme();
|
||||
|
||||
if (settings == null || workspace == null || activeTheme.data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lightThemes: SelectProps<string>['options'] = activeTheme.data.themes
|
||||
.filter((theme) => !theme.dark)
|
||||
.map((theme) => ({
|
||||
label: theme.label,
|
||||
value: theme.id,
|
||||
}));
|
||||
|
||||
const darkThemes: SelectProps<string>['options'] = activeTheme.data.themes
|
||||
.filter((theme) => theme.dark)
|
||||
.map((theme) => ({
|
||||
label: theme.label,
|
||||
value: theme.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<VStack space={3} 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>
|
||||
<Select
|
||||
name="appearance"
|
||||
label="Appearance"
|
||||
labelPosition="top"
|
||||
size="sm"
|
||||
value={settings.appearance}
|
||||
onChange={(appearance) => patchModel(settings, { appearance })}
|
||||
options={[
|
||||
{ label: 'Automatic', value: 'system' },
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
]}
|
||||
/>
|
||||
<HStack space={2}>
|
||||
{(settings.appearance === 'system' || settings.appearance === 'light') && (
|
||||
<Select
|
||||
hideLabel
|
||||
leftSlot={<Icon icon="sun" color="secondary" />}
|
||||
name="lightTheme"
|
||||
label="Light Theme"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
value={activeTheme.data.light.id}
|
||||
options={lightThemes}
|
||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
||||
/>
|
||||
)}
|
||||
{(settings.appearance === 'system' || settings.appearance === 'dark') && (
|
||||
<Select
|
||||
hideLabel
|
||||
name="darkTheme"
|
||||
className="flex-1"
|
||||
label="Dark Theme"
|
||||
leftSlot={<Icon icon="moon" color="secondary" />}
|
||||
size="sm"
|
||||
value={activeTheme.data.dark.id}
|
||||
options={darkThemes}
|
||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<VStack
|
||||
space={3}
|
||||
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded 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>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user