Dynamic plugins (#68)

This commit is contained in:
Gregory Schier
2024-09-06 10:43:25 -07:00
committed by GitHub
parent f7949c9909
commit b3adbc1860
37 changed files with 533 additions and 184 deletions

View File

@@ -18,6 +18,7 @@ import { httpResponsesQueryKey } from '../hooks/useHttpResponses';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useNotificationToast } from '../hooks/useNotificationToast';
import { pluginsAtom } from '../hooks/usePlugins';
import { useRecentCookieJars } from '../hooks/useRecentCookieJars';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
@@ -67,6 +68,7 @@ export function GlobalHooks() {
const setSettings = useSetAtom(settingsAtom);
const setWorkspaces = useSetAtom(workspacesAtom);
const setPlugins = useSetAtom(pluginsAtom);
const setHttpRequests = useSetAtom(httpRequestsAtom);
const setGrpcRequests = useSetAtom(grpcRequestsAtom);
const setEnvironments = useSetAtom(environmentsAtom);
@@ -100,6 +102,8 @@ export function GlobalHooks() {
if (model.model === 'workspace') {
setWorkspaces(updateModelList(model, pushToFront));
} else if (model.model === 'plugin') {
setPlugins(updateModelList(model, pushToFront));
} else if (model.model === 'http_request') {
setHttpRequests(updateModelList(model, pushToFront));
} else if (model.model === 'grpc_request') {
@@ -129,6 +133,8 @@ export function GlobalHooks() {
if (model.model === 'workspace') {
setWorkspaces(removeById(model));
} else if (model.model === 'plugin') {
setPlugins(removeById(model));
} else if (model.model === 'http_request') {
setHttpRequests(removeById(model));
} else if (model.model === 'http_response') {

View File

@@ -9,20 +9,31 @@ import { HStack } from './core/Stacks';
type Props = ButtonProps & {
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
filePath: string | null;
directory?: boolean;
inline?: boolean;
noun?: string;
};
// Special character to insert ltr text in rtl element
const rtlEscapeChar = <>&#x200E;</>;
export function SelectFile({ onChange, filePath, inline, className }: Props) {
export function SelectFile({
onChange,
filePath,
inline,
className,
directory,
noun,
size = 'sm',
...props
}: Props) {
const handleClick = async () => {
const filePath = await open({
title: 'Select File',
multiple: false,
directory,
});
if (filePath == null) return;
const contentType = filePath ? mime.getType(filePath) : null;
onChange({ filePath, contentType });
};
@@ -31,31 +42,41 @@ export function SelectFile({ onChange, filePath, inline, className }: Props) {
onChange({ filePath: null, contentType: null });
};
const itemLabel = noun ?? (directory ? 'Folder' : 'File');
const selectOrChange = (filePath ? 'Change ' : 'Select ') + itemLabel;
return (
<HStack space={1.5} className="group relative justify-stretch">
<HStack space={1.5} className="group relative justify-stretch overflow-hidden">
<Button
className={classNames(className, 'font-mono text-xs rtl', inline && 'w-full')}
color="secondary"
size="sm"
onClick={handleClick}
size={size}
{...props}
>
{rtlEscapeChar}
{inline ? <>{filePath || 'Select File'}</> : <>Select File</>}
{inline ? filePath || selectOrChange : selectOrChange}
</Button>
{!inline && (
<>
{filePath && (
<IconButton
size="sm"
size={size}
variant="border"
icon="x"
title="Unset File"
title={'Unset ' + itemLabel}
onClick={handleClear}
/>
)}
<div className="text-sm font-mono truncate rtl pr-3 text">
<div
className={classNames(
'font-mono truncate rtl pl-1.5 pr-3 text-text',
size === 'xs' && 'text-xs',
size === 'sm' && 'text-sm',
)}
>
{rtlEscapeChar}
{filePath ?? 'No file selected'}
{filePath ?? `No ${itemLabel.toLowerCase()} selected`}
</div>
</>
)}

View File

@@ -10,13 +10,15 @@ import { HeaderSize } from '../HeaderSize';
import { WindowControls } from '../WindowControls';
import { SettingsAppearance } from './SettingsAppearance';
import { SettingsGeneral } from './SettingsGeneral';
import { SettingsPlugins } from './SettingsPlugins';
enum Tab {
General = 'general',
Appearance = 'appearance',
Plugins = 'plugins',
}
const tabs = [Tab.General, Tab.Appearance];
const tabs = [Tab.General, Tab.Appearance, Tab.Plugins];
export const Settings = () => {
const osInfo = useOsInfo();
@@ -58,6 +60,9 @@ export const Settings = () => {
<TabContent value={Tab.Appearance} className="pt-3 overflow-y-auto h-full px-4">
<SettingsAppearance />
</TabContent>
<TabContent value={Tab.Plugins} className="pt-3 overflow-y-auto h-full px-4">
<SettingsPlugins />
</TabContent>
</Tabs>
</div>
);

View File

@@ -0,0 +1,95 @@
import type { Plugin } from '@yaakapp/api';
import React from 'react';
import { useCreatePlugin } from '../../hooks/useCreatePlugin';
import { usePluginInfo } from '../../hooks/usePluginInfo';
import { usePlugins, useRefreshPlugins } from '../../hooks/usePlugins';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { IconButton } from '../core/IconButton';
import { InlineCode } from '../core/InlineCode';
import { HStack } from '../core/Stacks';
import { SelectFile } from '../SelectFile';
export function SettingsPlugins() {
const [directory, setDirectory] = React.useState<string | null>(null);
const plugins = usePlugins();
const createPlugin = useCreatePlugin();
const refreshPlugins = useRefreshPlugins();
return (
<div className="grid grid-rows-[minmax(0,1fr)_auto] h-full">
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th></th>
<th className="py-2 text-left">Plugin</th>
<th className="py-2 text-right">Version</th>
<th></th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{plugins.map((p) => (
<PluginInfo key={p.id} plugin={p} />
))}
</tbody>
</table>
<form
onSubmit={(e) => {
e.preventDefault();
if (directory == null) return;
createPlugin.mutate(directory);
setDirectory(null);
}}
>
<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" type="submit" color="primary" className="ml-auto">
Add Plugin
</Button>
)}
<IconButton
size="sm"
icon="refresh"
title="Reload plugins"
spin={refreshPlugins.isPending}
onClick={() => refreshPlugins.mutate()}
/>
</HStack>
</footer>
</form>
</div>
);
}
function PluginInfo({ plugin }: { plugin: Plugin }) {
const pluginInfo = usePluginInfo(plugin.id);
if (pluginInfo.data == null) return null;
return (
<tr className="group">
<td className="pr-2">
<Checkbox hideLabel checked={true} title="foo" onChange={() => null} />
</td>
<td className="py-2 select-text cursor-text w-full">
<InlineCode>{pluginInfo.data?.name}</InlineCode>
</td>
<td className="py-2 select-text cursor-text text-right">
<InlineCode>{pluginInfo.data?.version}</InlineCode>
</td>
<td className="py-2 select-text cursor-text pl-2">
<IconButton
size="sm"
icon="trash"
title="Uninstall plugin"
className="text-text-subtlest"
/>
</td>
</tr>
);
}

View File

@@ -73,7 +73,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
size === 'md' && 'h-md px-3 rounded-md',
size === 'sm' && 'h-sm px-2.5 rounded-md',
size === 'xs' && 'h-xs px-2 text-sm rounded-md',
size === '2xs' && 'h-5 px-1 text-xs rounded',
size === '2xs' && 'h-2xs px-1 text-xs rounded',
// Solids
variant === 'solid' && 'border-transparent',

View File

@@ -58,9 +58,9 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
'!px-0',
color === 'custom' && 'text-text-subtle',
color === 'default' && 'text-text-subtle',
size === 'md' && 'w-9',
size === 'sm' && 'w-8',
size === 'xs' && 'w-6',
size === 'md' && 'w-md',
size === 'sm' && 'w-sm',
size === 'xs' && 'w-xs',
size === '2xs' && 'w-5',
)}
{...props}

View File

@@ -6,6 +6,7 @@ export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanEleme
<code
className={classNames(
className,
'select-text cursor-text',
'font-mono text-shrink bg-surface-highlight border border-border-subtle',
'px-1.5 py-0.5 rounded text shadow-inner break-words',
)}

View File

@@ -14,9 +14,11 @@ export function useActiveWorkspaceChangedToast() {
setId(activeWorkspace?.id ?? null);
// Don't notify on first load
// Don't notify on the first load
if (id === null) return;
console.log('HELLO?', activeWorkspace?.id, id, window.location);
toast.show({
id: 'workspace-changed',
timeout: 3000,

View File

@@ -0,0 +1,13 @@
import { useMutation } from '@tanstack/react-query';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';
export function useCreatePlugin() {
return useMutation<void, unknown, string>({
mutationKey: ['create_plugin'],
mutationFn: async (directory: string) => {
await invokeCmd('cmd_create_plugin', { directory });
},
onSettled: () => trackEvent('plugin', 'create'),
});
}

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import type { BootResponse } from '@yaakapp/api';
import { invokeCmd } from '../lib/tauri';
export function usePluginInfo(id: string) {
return useQuery({
queryKey: ['plugin_info', id],
queryFn: async () => {
const info = (await invokeCmd('cmd_plugin_info', { id })) as BootResponse;
return info;
},
});
}

View File

@@ -0,0 +1,23 @@
import { useMutation } from '@tanstack/react-query';
import type { Plugin } from '@yaakapp/api';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { minPromiseMillis } from '../lib/minPromiseMillis';
import { listPlugins } from '../lib/store';
const plugins = await listPlugins();
export const pluginsAtom = atom<Plugin[]>(plugins);
export function usePlugins() {
return useAtomValue(pluginsAtom);
}
export function useRefreshPlugins() {
const setPlugins = useSetAtom(pluginsAtom);
return useMutation({
mutationKey: ['refresh_plugins'],
mutationFn: async () => {
const plugins = await minPromiseMillis(listPlugins());
setPlugins(plugins);
},
});
}

View File

@@ -13,6 +13,7 @@ export type TrackResource =
| 'http_request'
| 'http_response'
| 'key_value'
| 'plugin'
| 'setting'
| 'sidebar'
| 'theme'

View File

@@ -1,7 +1,7 @@
import { sleep } from './sleep';
/** Ensures a promise takes at least a certain number of milliseconds to resolve */
export async function minPromiseMillis<T>(promise: Promise<T>, millis: number) {
export async function minPromiseMillis<T>(promise: Promise<T>, millis = 300) {
const start = Date.now();
let result;
let err;

View File

@@ -4,6 +4,7 @@ import type {
Folder,
GrpcRequest,
HttpRequest,
Plugin,
Settings,
Workspace,
} from '@yaakapp/api';
@@ -63,6 +64,11 @@ export async function listWorkspaces(): Promise<Workspace[]> {
return workspaces;
}
export async function listPlugins(): Promise<Plugin[]> {
const plugins: Plugin[] = (await invokeCmd('cmd_list_plugins')) ?? [];
return plugins;
}
export async function getCookieJar(id: string | null): Promise<CookieJar | null> {
if (id === null) return null;
const cookieJar: CookieJar = (await invokeCmd('cmd_get_cookie_jar', { id })) ?? null;

View File

@@ -6,6 +6,7 @@ type TauriCmd =
| 'cmd_check_for_updates'
| 'cmd_create_cookie_jar'
| 'cmd_create_environment'
| 'cmd_create_plugin'
| 'cmd_template_tokens_to_string'
| 'cmd_create_folder'
| 'cmd_create_grpc_request'
@@ -47,11 +48,13 @@ type TauriCmd =
| 'cmd_list_grpc_requests'
| 'cmd_list_http_requests'
| 'cmd_list_http_responses'
| 'cmd_list_plugins'
| 'cmd_list_workspaces'
| 'cmd_metadata'
| 'cmd_new_nested_window'
| 'cmd_new_window'
| 'cmd_parse_template'
| 'cmd_plugin_info'
| 'cmd_render_template'
| 'cmd_save_response'
| 'cmd_send_ephemeral_request'