Merge pull request #227

* Search and install plugins PoC

* Checksum

* Tab sidebar for settings

* Fix nested tabs, and tweaks

* Table for plugin results

* Deep links working

* Focus window during deep links

* Merge branch 'master' into plugin-directory

* More stuff
This commit is contained in:
Gregory Schier
2025-06-22 07:06:43 -07:00
committed by GitHub
parent b8e6dbc7c7
commit b5620fcdf3
56 changed files with 1222 additions and 444 deletions

View File

@@ -269,7 +269,7 @@ export function GrpcRequestPane({
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2 !mb-1.5"
tabListClassName="mt-1 !mb-1.5"
>
<TabContent value="message">
<GrpcEditor

View File

@@ -350,7 +350,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2 !mb-1.5"
tabListClassName="mt-1 !mb-1.5"
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />

View File

@@ -153,7 +153,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3"
tabListClassName="mt-1.5"
tabListClassName="mt-0.5"
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">

View File

@@ -11,6 +11,7 @@ interface Props {
export function ImportDataDialog({ importData }: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [filePath, setFilePath] = useLocalStorage<string | null>('importFilePath', null);
return (
<VStack space={5} className="pb-4">
<VStack space={1}>

View File

@@ -66,8 +66,10 @@ export default function Settings({ hide }: Props) {
</HeaderSize>
)}
<Tabs
layout="horizontal"
value={tab}
addBorders
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border"
label="Settings"
onChangeValue={setTab}
tabs={tabs.map((value) => ({ value, label: capitalize(value) }))}
@@ -81,7 +83,7 @@ export default function Settings({ hide }: Props) {
<TabContent value={TAB_THEME} className="pt-3 overflow-y-auto h-full px-4">
<SettingsTheme />
</TabContent>
<TabContent value={TAB_PLUGINS} className="pt-3 overflow-y-auto h-full px-4">
<TabContent value={TAB_PLUGINS} className="pt-3 h-full px-4 grid grid-rows-1">
<SettingsPlugins />
</TabContent>
<TabContent value={TAB_PROXY} className="pt-3 overflow-y-auto h-full px-4">

View File

@@ -1,8 +1,11 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { openUrl } from '@tauri-apps/plugin-opener';
import type { Plugin} from '@yaakapp-internal/models';
import type { Plugin } from '@yaakapp-internal/models';
import { pluginsAtom } from '@yaakapp-internal/models';
import { installPlugin, PluginVersion, searchPlugins } from '@yaakapp-internal/plugins';
import { useAtomValue } from 'jotai';
import React from 'react';
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';
@@ -10,86 +13,86 @@ import { useUninstallPlugin } from '../../hooks/useUninstallPlugin';
import { Button } from '../core/Button';
import { IconButton } from '../core/IconButton';
import { InlineCode } from '../core/InlineCode';
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 plugins = useAtomValue(pluginsAtom);
const createPlugin = useInstallPlugin();
const refreshPlugins = useRefreshPlugins();
const [tab, setTab] = useState<string>();
return (
<div className="grid grid-rows-[minmax(0,1fr)_auto] h-full">
{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 className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead>
<tr>
<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);
}}
<div className="h-full">
<Tabs
value={tab}
label="Plugins"
onChangeValue={setTab}
addBorders
tabListClassName="!-ml-3"
tabs={[
{ label: 'Marketplace', value: 'search' },
{ label: 'Installed', value: 'installed' },
]}
>
<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()}
/>
<IconButton
size="sm"
icon="help"
title="View documentation"
onClick={() => openUrl('https://feedback.yaak.app/help/articles/6911763-quick-start')}
/>
</HStack>
</footer>
</form>
<TabContent value="search">
<PluginSearch />
</TabContent>
<TabContent value="installed">
<InstalledPlugins />
<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()}
/>
<IconButton
size="sm"
icon="help"
title="View documentation"
onClick={() =>
openUrl('https://feedback.yaak.app/help/articles/6911763-quick-start')
}
/>
</HStack>
</footer>
</form>
</TabContent>
</Tabs>
</div>
);
}
function PluginInfo({ plugin }: { plugin: Plugin }) {
const pluginInfo = usePluginInfo(plugin.id);
const deletePlugin = useUninstallPlugin(plugin.id);
const deletePlugin = useUninstallPlugin();
return (
<tr className="group">
<td className="py-2 select-text cursor-text w-full">{pluginInfo.data?.name}</td>
@@ -101,9 +104,127 @@ function PluginInfo({ plugin }: { plugin: Plugin }) {
size="sm"
icon="trash"
title="Uninstall plugin"
onClick={() => deletePlugin.mutate()}
onClick={() => deletePlugin.mutate(plugin.id)}
/>
</td>
</tr>
);
}
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.results ?? []).length === 0 ? (
<EmptyStateText>No plugins found</EmptyStateText>
) : (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell children="" />
</TableRow>
</TableHead>
<TableBody>
{results.data.results.map((plugin) => {
return (
<TableRow key={plugin.id}>
<TableCell className="font-semibold">{plugin.displayName}</TableCell>
<TableCell className="text-text-subtle">
<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 deletePlugin = useUninstallPlugin();
const installed = plugins?.some((p) => p.id === plugin.id);
const installPluginMutation = useMutation({
mutationKey: ['install_plugin', plugin.id],
mutationFn: installPlugin,
});
return (
<Button
size="xs"
variant={installed ? 'solid' : 'border'}
color={installed ? 'primary' : 'secondary'}
className="ml-auto"
isLoading={installPluginMutation.isPending}
onClick={async () => {
if (installed) {
deletePlugin.mutate(plugin.id);
} else {
installPluginMutation.mutate(plugin);
}
}}
>
{installed ? 'Uninstall' : 'Install'}
</Button>
);
}
function InstalledPlugins() {
const plugins = useAtomValue(pluginsAtom);
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 className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead>
<tr>
<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>
);
}

View File

@@ -2,12 +2,12 @@ import { openUrl } from '@tauri-apps/plugin-opener';
import { useLicense } from '@yaakapp-internal/license';
import { useRef } from 'react';
import { openSettings } from '../commands/openSettings';
import { appInfo } from '../lib/appInfo';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { appInfo } from '../lib/appInfo';
import { showDialog } from '../lib/dialog';
import { importData } from '../lib/importData';
import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
@@ -15,7 +15,6 @@ import { IconButton } from './core/IconButton';
import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
export function SettingsDropdown() {
const importData = useImportData();
const exportData = useExportData();
const dropdownRef = useRef<DropdownRef>(null);
const checkForUpdates = useCheckForUpdates();

View File

@@ -234,7 +234,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2 !mb-1.5"
tabListClassName="mt-1 !mb-1.5"
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />

View File

@@ -4,14 +4,19 @@ import { useAtomValue } from 'jotai';
import * as m from 'motion/react-m';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useEnsureActiveCookieJar, useSubscribeActiveCookieJarId } from '../hooks/useActiveCookieJar';
import { activeEnvironmentAtom, useSubscribeActiveEnvironmentId } from '../hooks/useActiveEnvironment';
import {
useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId,
} from '../hooks/useActiveCookieJar';
import {
activeEnvironmentAtom,
useSubscribeActiveEnvironmentId,
} from '../hooks/useActiveEnvironment';
import { activeRequestAtom } from '../hooks/useActiveRequest';
import { useSubscribeActiveRequestId } from '../hooks/useActiveRequestId';
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
import { useHotKey } from '../hooks/useHotKey';
import { useImportData } from '../hooks/useImportData';
import { useSubscribeRecentCookieJars } from '../hooks/useRecentCookieJars';
import { useSubscribeRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useSubscribeRecentRequests } from '../hooks/useRecentRequests';
@@ -22,6 +27,7 @@ import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
import { duplicateRequestAndNavigate } from '../lib/duplicateRequestAndNavigate';
import { importData } from '../lib/importData';
import { jotaiStore } from '../lib/jotai';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
@@ -204,7 +210,6 @@ export function Workspace() {
function WorkspaceBody() {
const activeRequest = useAtomValue(activeRequestAtom);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const importData = useImportData();
if (activeWorkspace == null) {
return (

View File

@@ -1,11 +1,9 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { memo, useEffect, useRef } from 'react';
import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
import { HStack } from '../Stacks';
import { ErrorBoundary } from '../../ErrorBoundary';
import { Icon } from '../Icon';
import { RadioDropdown, RadioDropdownProps } from '../RadioDropdown';
export type TabItem =
| {
@@ -28,6 +26,7 @@ interface Props {
className?: string;
children: ReactNode;
addBorders?: boolean;
layout?: 'horizontal' | 'vertical';
}
export function Tabs({
@@ -39,6 +38,7 @@ export function Tabs({
className,
tabListClassName,
addBorders,
layout = 'vertical',
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
@@ -49,7 +49,10 @@ export function Tabs({
const tabs = ref.current?.querySelectorAll<HTMLDivElement>(`[data-tab]`);
for (const tab of tabs ?? []) {
const v = tab.getAttribute('data-tab');
if (v === value) {
let parent = tab.closest('.tabs-container');
if (parent !== ref.current) {
// Tab is part of a nested tab container, so ignore it
} else if (v === value) {
tab.setAttribute('tabindex', '-1');
tab.setAttribute('data-state', 'active');
tab.setAttribute('aria-hidden', 'false');
@@ -67,29 +70,41 @@ export function Tabs({
ref={ref}
className={classNames(
className,
'tabs-container',
'transform-gpu',
'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
'h-full grid',
layout === 'horizontal' && 'grid-rows-1 grid-cols-[auto_minmax(0,1fr)]',
layout === 'vertical' && 'grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
)}
>
<div
aria-label={label}
className={classNames(
tabListClassName,
addBorders && '!-ml-1 h-md mt-2',
'flex items-center overflow-x-auto overflow-y-visible hide-scrollbars mt-1 mb-2',
addBorders && '!-ml-1',
'flex items-center hide-scrollbars mb-2',
layout === 'horizontal' && 'h-full overflow-auto pt-1 px-2',
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
// Give space for button focus states within overflow boundary.
'-ml-5 pl-3 pr-1 py-1',
layout === 'vertical' && 'py-1 -ml-5 pl-3 pr-1',
)}
>
<HStack space={2} className="h-full flex-shrink-0">
<div
className={classNames(
layout === 'horizontal' && 'flex flex-col gap-1 w-full mt-1 pb-3 mb-auto',
layout === 'vertical' && 'flex flex-row flex-shrink-0 gap-2 w-full',
)}
>
{tabs.map((t) => {
const isActive = t.value === value;
const btnClassName = classNames(
'h-full flex items-center rounded',
'h-sm flex items-center rounded',
'!px-2 ml-[1px]',
addBorders && 'border',
isActive ? 'text-text' : 'text-text-subtle hover:text-text',
isActive && addBorders ? 'border-border-subtle' : 'border-transparent',
isActive && addBorders
? 'border-border-subtle bg-surface-active'
: 'border-transparent',
);
if ('options' in t) {
@@ -135,7 +150,7 @@ export function Tabs({
);
}
})}
</HStack>
</div>
</div>
{children}
</div>