mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-23 18:01:08 +01:00
Add .oxfmtignore to skip generated bindings and wasm-pack output. Add npm format script, update DEVELOPMENT.md for Vite+ toolchain, and format all non-generated files with oxfmt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
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(),
|
|
});
|
|
}
|