Move request to another workspace (#52)

This commit is contained in:
Gregory Schier
2024-06-21 09:01:18 -07:00
committed by GitHub
parent bc35195ca8
commit 3b784378bf
19 changed files with 253 additions and 77 deletions

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO http_requests (\n id, workspace_id, folder_id, name, url, url_parameters, method, body, body_type,\n authentication, authentication_type, headers, sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n url_parameters = excluded.url_parameters,\n sort_priority = excluded.sort_priority\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "11394af12419cca3be3a26dff9275514ea2a44504e3c7a568a9578c64b5713d1"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO grpc_requests (\n id, name, workspace_id, folder_id, sort_priority, url, service, method, message,\n authentication_type, authentication, metadata\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n sort_priority = excluded.sort_priority,\n url = excluded.url,\n service = excluded.service,\n method = excluded.method,\n message = excluded.message,\n authentication_type = excluded.authentication_type,\n authentication = excluded.authentication,\n metadata = excluded.metadata\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "467b87ad1209a4653b1dc8462d79236a655240c5b402fa9fd75c12ebd9bb6b86"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO grpc_requests (\n id, name, workspace_id, folder_id, sort_priority, url, service, method, message,\n authentication_type, authentication, metadata\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n workspace_id = excluded.workspace_id,\n name = excluded.name,\n folder_id = excluded.folder_id,\n sort_priority = excluded.sort_priority,\n url = excluded.url,\n service = excluded.service,\n method = excluded.method,\n message = excluded.message,\n authentication_type = excluded.authentication_type,\n authentication = excluded.authentication,\n metadata = excluded.metadata\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "5af82cd333895d3d7d67a92f37b0feb338f615b88aea2bd09cb5809008c645a3"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO http_requests (\n id, workspace_id, folder_id, name, url, url_parameters, method, body, body_type,\n authentication, authentication_type, headers, sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n workspace_id = excluded.workspace_id,\n name = excluded.name,\n folder_id = excluded.folder_id,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n url_parameters = excluded.url_parameters,\n sort_priority = excluded.sort_priority\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "5f2f40062abbe93e23b38876319cf16d4d2b3f8d0be32ffe7848528c725e1429"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id, model, created_at, updated_at, theme, appearance,\n theme_dark, theme_light, update_channel,\n interface_font_size, interface_scale, editor_font_size, editor_soft_wrap, \n open_workspace_new_window\n FROM settings\n WHERE id = 'default'\n ",
"query": "\n SELECT\n id, model, created_at, updated_at, theme, appearance,\n theme_dark, theme_light, update_channel,\n interface_font_size, interface_scale, editor_font_size, editor_soft_wrap,\n open_workspace_new_window\n FROM settings\n WHERE id = 'default'\n ",
"describe": {
"columns": [
{
@@ -94,5 +94,5 @@
true
]
},
"hash": "05dca7fe15ab1bf03952e94498ef3130e16f752da72782783696eb2cca4736d5"
"hash": "daa61066517df649e7c80a8ce407839ad502e8e5e43aa8c02e049865acbbae75"
}

View File

@@ -562,6 +562,7 @@ pub async fn upsert_grpc_request(
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
workspace_id = excluded.workspace_id,
name = excluded.name,
folder_id = excluded.folder_id,
sort_priority = excluded.sort_priority,
@@ -892,7 +893,7 @@ async fn get_settings(mgr: &impl Manager<Wry>) -> Result<Settings, sqlx::Error>
SELECT
id, model, created_at, updated_at, theme, appearance,
theme_dark, theme_light, update_channel,
interface_font_size, interface_scale, editor_font_size, editor_soft_wrap,
interface_font_size, interface_scale, editor_font_size, editor_soft_wrap,
open_workspace_new_window
FROM settings
WHERE id = 'default'
@@ -1125,6 +1126,7 @@ pub async fn upsert_http_request(
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
workspace_id = excluded.workspace_id,
name = excluded.name,
folder_id = excluded.folder_id,
method = excluded.method,

View File

@@ -1,16 +1,21 @@
import { Outlet } from 'react-router-dom';
import { DialogProvider } from './DialogContext';
import { GlobalHooks } from './GlobalHooks';
import { ToastProvider } from './ToastContext';
import classNames from 'classnames';
import { useOsInfo } from '../hooks/useOsInfo';
import { motion } from 'framer-motion';
import { Outlet } from 'react-router-dom';
import { useOsInfo } from '../hooks/useOsInfo';
import { DialogProvider, Dialogs } from './DialogContext';
import { GlobalHooks } from './GlobalHooks';
import { ToastProvider, Toasts } from './ToastContext';
export function DefaultLayout() {
const osInfo = useOsInfo();
return (
<DialogProvider>
<ToastProvider>
<>
{/* Must be inside all the providers, so they have access to them */}
<Toasts />
<Dialogs />
</>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}

View File

@@ -46,14 +46,7 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
actions,
};
return (
<DialogContext.Provider value={state}>
{children}
{dialogs.map((props: DialogEntry) => (
<DialogInstance key={props.id} {...props} />
))}
</DialogContext.Provider>
);
return <DialogContext.Provider value={state}>{children}</DialogContext.Provider>;
};
function DialogInstance({ id, render, ...props }: DialogEntry) {
@@ -67,3 +60,14 @@ function DialogInstance({ id, render, ...props }: DialogEntry) {
}
export const useDialog = () => useContext(DialogContext).actions;
export function Dialogs() {
const { dialogs } = useContext(DialogContext);
return (
<>
{dialogs.map((props: DialogEntry) => (
<DialogInstance key={props.id} {...props} />
))}
</>
);
}

View File

@@ -1,6 +1,7 @@
import { useQueryClient } from '@tanstack/react-query';
import { getCurrent } from '@tauri-apps/api/webviewWindow';
import { useEffect } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useClipboardText } from '../hooks/useClipboardText';
import { useCommandPalette } from '../hooks/useCommandPalette';
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
@@ -32,8 +33,12 @@ import { monokaiProDefault } from '../lib/theme/themes/monokai-pro';
import { rosePineDefault } from '../lib/theme/themes/rose-pine';
import { yaakDark } from '../lib/theme/themes/yaak';
import { getThemeCSS } from '../lib/theme/window';
import { InlineCode } from './core/InlineCode';
import { useToast } from './ToastContext';
export function GlobalHooks() {
const toast = useToast();
// Include here so they always update, even if no component references them
useRecentWorkspaces();
useRecentEnvironments();
@@ -44,6 +49,21 @@ export function GlobalHooks() {
useCommandPalette();
useNotificationToast();
const activeWorkspace = useActiveWorkspace();
useEffect(() => {
if (activeWorkspace == null) return;
toast.show({
id: 'workspace-changed',
timeout: 3000,
message: (
<>
Switched workspace to <InlineCode>{activeWorkspace.name}</InlineCode>
</>
),
});
}, [activeWorkspace, toast]);
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);

View File

@@ -63,9 +63,9 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
firstSlot={() =>
activeConnection && (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
<HStack className="pl-3 mb-1 font-mono">
<HStack className="pl-3 mb-1 font-mono text-sm">
<HStack space={2}>
<span>{events.length} messages</span>
<span>{events.length} Messages</span>
{isResponseLoading(activeConnection) && (
<Icon icon="refresh" size="sm" spin className="text-fg-subtler" />
)}

View File

@@ -13,7 +13,7 @@ import { BearerAuth } from './BearerAuth';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { PairEditor } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { RadioDropdown } from './core/RadioDropdown';
import { HStack, VStack } from './core/Stacks';
import type { TabItem } from './core/Tabs/Tabs';
@@ -209,7 +209,7 @@ export function GrpcConnectionSetupPane({
rightSlot={<Icon className="text-fg-subtler" size="sm" icon="chevronDown" />}
disabled={isStreaming || services == null}
className={classNames(
'font-mono text-sm min-w-[5rem] !ring-0',
'font-mono text-editor min-w-[5rem] !ring-0',
paneSize < 400 && 'flex-1',
)}
>
@@ -291,7 +291,8 @@ export function GrpcConnectionSetupPane({
)}
</TabContent>
<TabContent value="metadata">
<PairEditor
<PairOrBulkEditor
preferenceName="grpc_metadata"
valueAutocompleteVariables
nameAutocompleteVariables
pairs={activeRequest.metadata}

View File

@@ -0,0 +1,97 @@
import { useQueryClient } from '@tanstack/react-query';
import React, { useState } from 'react';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
import { httpRequestsQueryKey } from '../hooks/useHttpRequests';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import type { GrpcRequest, HttpRequest } from '../lib/models';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { Select } from './core/Select';
import { VStack } from './core/Stacks';
import { useToast } from './ToastContext';
interface Props {
activeWorkspaceId: string;
request: HttpRequest | GrpcRequest;
onDone: () => void;
}
export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Props) {
const workspaces = useWorkspaces();
const queryClient = useQueryClient();
const updateHttpRequest = useUpdateAnyHttpRequest();
const updateGrpcRequest = useUpdateAnyGrpcRequest();
const toast = useToast();
const routes = useAppRoutes();
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(activeWorkspaceId);
return (
<VStack space={4} className="mb-4">
<Select
label="Workspace"
name="workspace"
value={selectedWorkspaceId}
onChange={setSelectedWorkspaceId}
options={workspaces.map((w) => ({
label: w.id === activeWorkspaceId ? `${w.name} (current)` : w.name,
value: w.id,
}))}
/>
<Button
color="primary"
disabled={selectedWorkspaceId === activeWorkspaceId}
onClick={async () => {
const args = {
id: request.id,
update: { workspaceId: selectedWorkspaceId, folderId: null },
};
if (request.model === 'http_request') {
await updateHttpRequest.mutateAsync(args);
queryClient.invalidateQueries({
queryKey: httpRequestsQueryKey({ workspaceId: activeWorkspaceId }),
});
} else if (request.model === 'grpc_request') {
await updateGrpcRequest.mutateAsync(args);
queryClient.invalidateQueries({
queryKey: grpcRequestsQueryKey({ workspaceId: activeWorkspaceId }),
});
}
// Hide after a moment, to give time for request to disappear
setTimeout(onDone, 100);
toast.show({
id: 'workspace-moved',
message: (
<>
<InlineCode>{fallbackRequestName(request)}</InlineCode> moved to{' '}
<InlineCode>
{workspaces.find((w) => w.id === selectedWorkspaceId)?.name ?? 'unknown'}
</InlineCode>
</>
),
action: (
<Button
size="xs"
color="secondary"
className="mr-auto min-w-[5rem]"
onClick={() => {
toast.hide('workspace-moved');
routes.navigate('workspace', { workspaceId: selectedWorkspaceId });
}}
>
Switch to Workspace
</Button>
),
});
}}
>
Change Workspace
</Button>
</VStack>
);
}

View File

@@ -20,6 +20,7 @@ import { useHotKey } from '../hooks/useHotKey';
import { useKeyValue } from '../hooks/useKeyValue';
import { useLatestGrpcConnection } from '../hooks/useLatestGrpcConnection';
import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse';
import { useMoveToWorkspace } from '../hooks/useMoveToWorkspace';
import { usePrompt } from '../hooks/usePrompt';
import { useRequests } from '../hooks/useRequests';
import { useSendManyRequests } from '../hooks/useSendFolder';
@@ -30,6 +31,7 @@ import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import type { Folder, GrpcRequest, HttpRequest, Workspace } from '../lib/models';
import { isResponseLoading } from '../lib/models';
@@ -608,10 +610,12 @@ const SidebarItem = forwardRef(function SidebarItem(
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true });
const copyAsCurl = useCopyAsCurl(itemId);
const sendRequest = useSendRequest(itemId);
const moveToWorkspace = useMoveToWorkspace(itemId);
const sendManyRequests = useSendManyRequests();
const latestHttpResponse = useLatestHttpResponse(itemId);
const latestGrpcConnection = useLatestGrpcConnection(itemId);
const updateHttpRequest = useUpdateHttpRequest(itemId);
const workspaces = useWorkspaces();
const updateGrpcRequest = useUpdateGrpcRequest(itemId);
const updateAnyFolder = useUpdateAnyFolder();
const prompt = usePrompt();
@@ -732,7 +736,7 @@ const SidebarItem = forwardRef(function SidebarItem(
hotKeyAction: 'http_request.send',
hotKeyLabelOnly: true, // Already bound in URL bar
leftSlot: <Icon icon="sendHorizontal" />,
onSelect: () => sendRequest.mutate(),
onSelect: sendRequest.mutate,
},
{
key: 'copyCurl',
@@ -783,6 +787,13 @@ const SidebarItem = forwardRef(function SidebarItem(
: duplicateGrpcRequest.mutate();
},
},
{
key: 'moveWorkspace',
label: 'Change Workspace',
leftSlot: <Icon icon="house" />,
hidden: workspaces.length <= 1,
onSelect: moveToWorkspace.mutate,
},
{
key: 'deleteRequest',
variant: 'danger',

View File

@@ -9,7 +9,7 @@ import { AnimatePresence } from 'framer-motion';
type ToastEntry = {
id?: string;
message: ReactNode;
timeout?: number | null;
timeout?: 3000 | 5000 | 8000 | null;
onClose?: ToastProps['onClose'];
} & Omit<ToastProps, 'onClose' | 'open' | 'children' | 'timeout'>;
@@ -29,14 +29,14 @@ interface Actions {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ToastContext = createContext<State>({} as State);
export const ToastContext = createContext<State>({} as State);
export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
const [toasts, setToasts] = useState<State['toasts']>([]);
const timeoutRef = useRef<NodeJS.Timeout>();
const actions = useMemo<Actions>(
() => ({
show({ id, timeout = 4000, ...props }: ToastEntry) {
show({ id, timeout = 5000, ...props }: ToastEntry) {
id = id ?? generateId();
if (timeout != null) {
timeoutRef.current = setTimeout(() => this.hide(id), timeout);
@@ -62,21 +62,7 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
);
const state: State = { toasts, actions };
return (
<ToastContext.Provider value={state}>
{children}
<Portal name="toasts">
<div className="absolute right-0 bottom-0 z-10">
<AnimatePresence>
{toasts.map((props: PrivateToastEntry) => (
<ToastInstance key={props.id} {...props} />
))}
</AnimatePresence>
</div>
</Portal>
</ToastContext.Provider>
);
return <ToastContext.Provider value={state}>{children}</ToastContext.Provider>;
};
function ToastInstance({ id, message, timeout, ...props }: PrivateToastEntry) {
@@ -96,3 +82,18 @@ function ToastInstance({ id, message, timeout, ...props }: PrivateToastEntry) {
}
export const useToast = () => useContext(ToastContext).actions;
export const Toasts = () => {
const { toasts } = useContext(ToastContext);
return (
<Portal name="toasts">
<div className="absolute right-0 bottom-0 z-10">
<AnimatePresence>
{toasts.map((props: PrivateToastEntry) => (
<ToastInstance key={props.id} {...props} />
))}
</AnimatePresence>
</div>
</Portal>
);
};

View File

@@ -5,9 +5,6 @@ import { memo } from 'react';
const icons = {
alert: lucide.AlertTriangleIcon,
text: lucide.FileTextIcon,
table: lucide.TableIcon,
fileCode: lucide.FileCodeIcon,
archive: lucide.ArchiveIcon,
arrowBigDownDash: lucide.ArrowBigDownDashIcon,
arrowBigLeftDash: lucide.ArrowBigLeftDashIcon,
@@ -33,19 +30,22 @@ const icons = {
externalLink: lucide.ExternalLinkIcon,
eye: lucide.EyeIcon,
eyeClosed: lucide.EyeOffIcon,
fileCode: lucide.FileCodeIcon,
filter: lucide.FilterIcon,
flask: lucide.FlaskConicalIcon,
folderInput: lucide.FolderInputIcon,
folderOutput: lucide.FolderOutputIcon,
gripVertical: lucide.GripVerticalIcon,
hand: lucide.HandIcon,
house: lucide.HomeIcon,
info: lucide.InfoIcon,
keyboard: lucide.KeyboardIcon,
leftPanelHidden: lucide.PanelLeftOpenIcon,
leftPanelVisible: lucide.PanelLeftCloseIcon,
magicWand: lucide.Wand2Icon,
minus: lucide.MinusIcon,
moreVertical: lucide.MoreVerticalIcon,
moon: lucide.MoonIcon,
moreVertical: lucide.MoreVerticalIcon,
paste: lucide.ClipboardPasteIcon,
pencil: lucide.PencilIcon,
pin: lucide.PinIcon,
@@ -61,6 +61,8 @@ const icons = {
settings: lucide.SettingsIcon,
sparkles: lucide.SparklesIcon,
sun: lucide.SunIcon,
table: lucide.TableIcon,
text: lucide.FileTextIcon,
trash: lucide.Trash2Icon,
unpin: lucide.PinOffIcon,
update: lucide.RefreshCcwIcon,

View File

@@ -6,6 +6,7 @@ import { useKey } from 'react-use';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { VStack } from './Stacks';
export interface ToastProps {
children: ReactNode;
@@ -63,7 +64,7 @@ export function Toast({
'text-fg',
)}
>
<div className="px-3 py-2 flex items-center gap-2">
<div className="px-3 py-3 flex items-center gap-2">
{variant != null && (
<Icon
icon={ICONS[variant]}
@@ -75,10 +76,10 @@ export function Toast({
)}
/>
)}
<div className="flex flex-col gap-1 w-full">
<VStack space={2}>
<div>{children}</div>
{action}
</div>
</VStack>
</div>
<IconButton

View File

@@ -49,7 +49,7 @@ export const routePaths = {
};
export function useAppRoutes() {
const workspaceId = useActiveWorkspaceId();
const activeWorkspaceId = useActiveWorkspaceId();
const requestId = useActiveRequestId();
const nav = useNavigate();
@@ -66,22 +66,22 @@ export function useAppRoutes() {
const setEnvironment = useCallback(
(environment: Environment | null) => {
if (workspaceId == null) {
if (activeWorkspaceId == null) {
navigate('workspaces');
} else if (requestId == null) {
navigate('workspace', {
workspaceId: workspaceId,
workspaceId: activeWorkspaceId,
environmentId: environment == null ? undefined : environment.id,
});
} else {
navigate('request', {
workspaceId,
workspaceId: activeWorkspaceId,
environmentId: environment == null ? undefined : environment.id,
requestId,
});
}
},
[navigate, workspaceId, requestId],
[navigate, activeWorkspaceId, requestId],
);
return {

View File

@@ -57,7 +57,6 @@ export function useGrpc(
)) as ReflectResponseService[],
});
console.log('CONN', conn);
return {
go,
reflect,

View File

@@ -0,0 +1,33 @@
import { useMutation } from '@tanstack/react-query';
import React from 'react';
import { useDialog } from '../components/DialogContext';
import { MoveToWorkspaceDialog } from '../components/MoveToWorkspaceDialog';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useRequests } from './useRequests';
export function useMoveToWorkspace(id: string) {
const dialog = useDialog();
const requests = useRequests();
const request = requests.find((r) => r.id === id);
const activeWorkspaceId = useActiveWorkspaceId();
return useMutation<void, unknown>({
mutationKey: ['moveWorkspace'],
mutationFn: async () => {
if (request == null || activeWorkspaceId == null) return;
dialog.show({
id: 'change-workspace',
title: 'Change Workspace',
size: 'sm',
render: ({ hide }) => (
<MoveToWorkspaceDialog
onDone={hide}
request={request}
activeWorkspaceId={activeWorkspaceId}
/>
),
});
},
});
}