Files
yaak/src-web/components/Settings/SettingsPlugins.tsx
2025-06-23 14:09:09 -07:00

282 lines
9.2 KiB
TypeScript

import { useMutation, useQuery } from '@tanstack/react-query';
import { openUrl } from '@tauri-apps/plugin-opener';
import type { Plugin } from '@yaakapp-internal/models';
import { pluginsAtom } from '@yaakapp-internal/models';
import type { PluginVersion } from '@yaakapp-internal/plugins';
import { checkPluginUpdates, installPlugin, searchPlugins } from '@yaakapp-internal/plugins';
import type { PluginUpdatesResponse } from '@yaakapp-internal/plugins/bindings/gen_api';
import { useAtomValue } from 'jotai';
import React, { useState } from 'react';
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useInstallPlugin } from '../../hooks/useInstallPlugin';
import { usePluginInfo } from '../../hooks/usePluginInfo';
import { useRefreshPlugins } from '../../hooks/usePlugins';
import { useUninstallPlugin } from '../../hooks/useUninstallPlugin';
import { Button } from '../core/Button';
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';
export function SettingsPlugins() {
const [directory, setDirectory] = React.useState<string | null>(null);
const createPlugin = useInstallPlugin();
const refreshPlugins = useRefreshPlugins();
const [tab, setTab] = useState<string>();
return (
<div className="h-full">
<Tabs
value={tab}
label="Plugins"
onChangeValue={setTab}
addBorders
tabListClassName="!-ml-3"
tabs={[
{ label: 'Marketplace', value: 'search' },
{ label: 'Installed', value: 'installed' },
]}
>
<TabContent value="search">
<PluginSearch />
</TabContent>
<TabContent value="installed">
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto]">
<InstalledPlugins />
<footer className="grid grid-cols-[minmax(0,1fr)_auto] -mx-4 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://feedback.yaak.app/help/articles/6911763-quick-start')
}
/>
</HStack>
</footer>
</div>
</TabContent>
</Tabs>
</div>
);
}
function PluginTableRow({
plugin,
updates,
}: {
plugin: Plugin;
updates: PluginUpdatesResponse | null;
}) {
const pluginInfo = usePluginInfo(plugin.id);
const uninstallPlugin = useUninstallPlugin();
const latestVersion = updates?.plugins.find((u) => u.name === pluginInfo.data?.name)?.version;
const installPluginMutation = useMutation({
mutationKey: ['install_plugin', plugin.id],
mutationFn: (name: string) => installPlugin(name, null),
});
if (pluginInfo.data == null) return null;
return (
<TableRow>
<TableCell className="font-semibold">
{plugin.url ? (
<Link noUnderline href={plugin.url}>
{pluginInfo.data.displayName}
</Link>
) : (
pluginInfo.data.displayName
)}
</TableCell>
<TableCell>
<InlineCode>{pluginInfo.data?.version}</InlineCode>
</TableCell>
<TableCell className="w-full text-text-subtle">{pluginInfo.data.description}</TableCell>
<TableCell>
<HStack>
{latestVersion != null && (
<Button
variant="border"
color="success"
title={`Update to ${latestVersion}`}
size="xs"
isLoading={installPluginMutation.isPending}
onClick={() => installPluginMutation.mutate(pluginInfo.data.name)}
>
Update
</Button>
)}
<IconButton
size="sm"
icon="trash"
title="Uninstall plugin"
onClick={async () => {
uninstallPlugin.mutate({ pluginId: plugin.id, name: pluginInfo.data.displayName });
}}
/>
</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 overflow-auto">
{results.data == null ? (
<EmptyStateText>
<LoadingIcon size="xl" className="text-text-subtlest" />
</EmptyStateText>
) : (results.data.plugins ?? []).length === 0 ? (
<EmptyStateText>No plugins found</EmptyStateText>
) : (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<TableBody>
{results.data.plugins.map((plugin) => (
<TableRow key={plugin.id}>
<TableCell className="font-semibold">
<Link noUnderline href={plugin.url}>
{plugin.displayName}
</Link>
</TableCell>
<TableCell>
<InlineCode>{plugin.version}</InlineCode>
</TableCell>
<TableCell className="w-full text-text-subtle">
{plugin.description ?? 'n/a'}
</TableCell>
<TableCell>
<InstallPluginButton plugin={plugin} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
);
}
function InstallPluginButton({ plugin }: { plugin: PluginVersion }) {
const plugins = useAtomValue(pluginsAtom);
const uninstallPlugin = useUninstallPlugin();
const installed = plugins?.some((p) => p.id === plugin.id);
const installPluginMutation = useMutation({
mutationKey: ['install_plugin', plugin.id],
mutationFn: (pv: PluginVersion) => installPlugin(pv.name, null),
});
return (
<Button
size="xs"
variant="border"
color={installed ? 'secondary' : 'primary'}
className="ml-auto"
isLoading={installPluginMutation.isPending}
onClick={async () => {
if (installed) {
uninstallPlugin.mutate({ pluginId: plugin.id, name: plugin.displayName });
} else {
installPluginMutation.mutate(plugin);
}
}}
>
{installed ? 'Uninstall' : 'Install'}
</Button>
);
}
function InstalledPlugins() {
const plugins = useAtomValue(pluginsAtom);
const updates = useQuery({
queryKey: ['plugin_updates'],
queryFn: () => checkPluginUpdates(),
});
return plugins.length === 0 ? (
<div className="pb-4">
<EmptyStateText className="text-center">
Plugins extend the functionality of Yaak.
<br />
Add your first plugin to get started.
</EmptyStateText>
</div>
) : (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<tbody className="divide-y divide-surface-highlight">
{plugins.map((p) => {
return <PluginTableRow key={p.id} plugin={p} updates={updates.data ?? null} />;
})}
</tbody>
</Table>
);
}