From 2836a28988a6eff27fea02eba9f5cdff5fe895a3 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 31 Mar 2023 13:21:02 -0700 Subject: [PATCH] Better model updates --- src-tauri/src/main.rs | 127 +++++++++--------- src-tauri/src/models.rs | 8 +- src-web/components/DialogContext.tsx | 2 +- src-web/components/RequestActionsDropdown.tsx | 10 +- ...pdown.tsx => WorkspaceActionsDropdown.tsx} | 12 +- src-web/components/WorkspaceHeader.tsx | 5 +- src-web/components/core/Dialog.tsx | 25 ++-- src-web/components/core/Heading.tsx | 9 +- src-web/components/core/InlineCode.tsx | 14 ++ src-web/hooks/Confirm.tsx | 31 +++-- src-web/hooks/useConfirm.ts | 26 ++++ src-web/hooks/useConfirm.tsx | 34 ----- src-web/hooks/useCreateRequest.ts | 25 ++-- src-web/hooks/useCreateWorkspace.ts | 14 +- src-web/hooks/useDeleteRequest.ts | 26 ++-- src-web/hooks/useDeleteResponse.ts | 15 ++- src-web/hooks/useDeleteResponses.ts | 2 +- src-web/hooks/useDeleteWorkspace.ts | 22 +-- src-web/hooks/useKeyValue.ts | 2 +- src-web/hooks/useSendRequest.ts | 16 ++- src-web/hooks/useTauriListeners.ts | 49 ++++--- src-web/hooks/useUpdateAnyRequest.ts | 4 +- src-web/hooks/useUpdateRequest.ts | 4 +- src-web/lib/lastLocation.ts | 6 +- 24 files changed, 273 insertions(+), 215 deletions(-) rename src-web/components/{WorkspaceDropdown.tsx => WorkspaceActionsDropdown.tsx} (86%) create mode 100644 src-web/components/core/InlineCode.tsx create mode 100644 src-web/hooks/useConfirm.ts delete mode 100644 src-web/hooks/useConfirm.tsx diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 57c276d8..6d3114ae 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -25,6 +25,7 @@ use tauri::regex::Regex; use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, TitleBarStyle, Window, Wry}; use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent}; use tokio::sync::Mutex; +use tokio::task::spawn_local; use window_ext::WindowExt; @@ -63,18 +64,18 @@ async fn migrate_db( #[tauri::command] async fn send_ephemeral_request( request: models::HttpRequest, - window: Window, + app_handle: AppHandle, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let response = models::HttpResponse::default(); - return actually_send_ephemeral_request(request, response, window, pool).await; + return actually_send_ephemeral_request(request, &response, &app_handle, pool).await; } async fn actually_send_ephemeral_request( request: models::HttpRequest, - mut response: models::HttpResponse, - window: Window, + response: &models::HttpResponse, + app_handle: &AppHandle, pool: &Pool, ) -> Result { let start = std::time::Instant::now(); @@ -183,22 +184,22 @@ async fn actually_send_ephemeral_request( let sendable_req = match sendable_req_result { Ok(r) => r, Err(e) => { - return response_err(response, e.to_string(), window, pool).await; + return response_err(response, e.to_string(), &app_handle, pool).await; } }; - let resp = client.execute(sendable_req).await; + let raw_response = client.execute(sendable_req).await; - let p = window - .app_handle() + let p = app_handle .path_resolver() .resolve_resource("plugins/plugin.ts") .expect("failed to resolve resource"); runtime::run_plugin_sync(p.to_str().unwrap()).unwrap(); - match resp { + match raw_response { Ok(v) => { + let mut response = response.clone(); response.status = v.status().as_u16() as i64; response.status_reason = v.status().canonical_reason().map(|s| s.to_string()); response.headers = Json( @@ -216,12 +217,10 @@ async fn actually_send_ephemeral_request( response = models::update_response_if_id(response, pool) .await .expect("Failed to update response"); - window - .emit_all("updated_model", &response) - .expect("Failed to emit updated_model"); + emit_side_effect(app_handle, "updated_model", &response); Ok(response) } - Err(e) => response_err(response, e.to_string(), window, pool).await, + Err(e) => response_err(response, e.to_string(), app_handle, pool).await, } } @@ -230,7 +229,7 @@ async fn send_request( window: Window, db_instance: State<'_, Mutex>>, request_id: &str, -) -> Result<(), String> { +) -> Result { let pool = &*db_instance.lock().await; let req = models::get_request(request_id, pool) @@ -240,25 +239,31 @@ async fn send_request( let response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool) .await .expect("Failed to create response"); - window - .emit_all("created_model", &response) - .expect("Failed to emit updated_model"); - actually_send_ephemeral_request(req, response, window, pool).await?; - Ok(()) + let response2 = response.clone(); + let app_handle2 = window.app_handle().clone(); + let pool2 = pool.clone(); + tokio::spawn(async move { + actually_send_ephemeral_request(req, &response2, &app_handle2, &pool2) + .await + .expect("Failed to send request"); + }); + + emit_and_return(&window, "created_model", response) } async fn response_err( - mut response: models::HttpResponse, + response: &models::HttpResponse, error: String, - window: Window, + app_handle: &AppHandle, pool: &Pool, ) -> Result { + let mut response = response.clone(); response.error = Some(error.clone()); response = models::update_response_if_id(response, pool) .await .expect("Failed to update response"); - emit_all_others(&window, "updated_model", &response); + emit_side_effect(app_handle, "updated_model", &response); Ok(response) } @@ -280,15 +285,15 @@ async fn set_key_value( value: &str, window: Window, db_instance: State<'_, Mutex>>, -) -> Result<(), String> { +) -> Result { let pool = &*db_instance.lock().await; - let created_key_value = models::set_key_value(namespace, key, value, pool) - .await - .expect("Failed to create key value"); + let (key_value, created) = models::set_key_value(namespace, key, value, pool).await; - emit_all_others(&window, "updated_model", &created_key_value); - - Ok(()) + if created { + emit_and_return(&window, "created_model", key_value) + } else { + emit_and_return(&window, "updated_model", key_value) + } } #[tauri::command] @@ -296,17 +301,13 @@ async fn create_workspace( name: &str, window: Window, db_instance: State<'_, Mutex>>, -) -> Result { +) -> Result { let pool = &*db_instance.lock().await; let created_workspace = models::create_workspace(name, "", pool) .await .expect("Failed to create workspace"); - window - .emit_all("created_model", &created_workspace) - .expect("Failed to emit event"); - - Ok(created_workspace.id) + emit_and_return(&window, "created_model", created_workspace) } #[tauri::command] @@ -316,7 +317,7 @@ async fn create_request( sort_priority: f64, window: Window, db_instance: State<'_, Mutex>>, -) -> Result { +) -> Result { let pool = &*db_instance.lock().await; let headers = Vec::new(); let created_request = models::upsert_request( @@ -336,11 +337,7 @@ async fn create_request( .await .expect("Failed to create request"); - window - .emit_all("created_model", &created_request) - .expect("Failed to emit event"); - - Ok(created_request.id) + emit_and_return(&window, "created_model", created_request) } #[tauri::command] @@ -348,13 +345,12 @@ async fn duplicate_request( id: &str, window: Window, db_instance: State<'_, Mutex>>, -) -> Result { +) -> Result { let pool = &*db_instance.lock().await; let request = models::duplicate_request(id, pool) .await .expect("Failed to duplicate request"); - emit_all_others(&window, "updated_model", &request); - Ok(request.id) + emit_and_return(&window, "updated_model", request) } #[tauri::command] @@ -362,7 +358,7 @@ async fn update_request( request: models::HttpRequest, window: Window, db_instance: State<'_, Mutex>>, -) -> Result<(), String> { +) -> Result { let pool = &*db_instance.lock().await; // TODO: Figure out how to make this better @@ -393,9 +389,7 @@ async fn update_request( .await .expect("Failed to update request"); - emit_all_others(&window, "updated_model", updated_request); - - Ok(()) + emit_and_return(&window, "updated_model", updated_request) } #[tauri::command] @@ -403,13 +397,12 @@ async fn delete_request( window: Window, db_instance: State<'_, Mutex>>, request_id: &str, -) -> Result<(), String> { +) -> Result { let pool = &*db_instance.lock().await; let req = models::delete_request(request_id, pool) .await .expect("Failed to delete request"); - emit_all_others(&window, "deleted_model", req); - Ok(()) + emit_and_return(&window, "deleted_model", req) } #[tauri::command] @@ -450,13 +443,12 @@ async fn delete_response( id: &str, window: Window, db_instance: State<'_, Mutex>>, -) -> Result<(), String> { +) -> Result { let pool = &*db_instance.lock().await; let response = models::delete_response(id, pool) .await .expect("Failed to delete response"); - emit_all_others(&window, "deleted_model", response); - Ok(()) + emit_and_return(&window, "deleted_model", response) } #[tauri::command] @@ -494,13 +486,12 @@ async fn delete_workspace( window: Window, db_instance: State<'_, Mutex>>, id: &str, -) -> Result<(), String> { +) -> Result { let pool = &*db_instance.lock().await; let workspace = models::delete_workspace(id, pool) .await .expect("Failed to delete workspace"); - emit_all_others(&window, "deleted_model", workspace); - Ok(()) + emit_and_return(&window, "deleted_model", workspace) } #[tauri::command] @@ -690,13 +681,17 @@ fn create_window(handle: &AppHandle) -> Window { win } -/// Emit an event to all windows except the current one -fn emit_all_others(current_window: &Window, event: &str, payload: S) { - let windows = current_window.app_handle().windows(); - for window in windows.values() { - if window.label() == current_window.label() { - continue; - } - window.emit(event, &payload).unwrap(); - } +/// Emit an event to all windows, with a source window +fn emit_and_return( + current_window: &Window, + event: &str, + payload: S, +) -> Result { + current_window.emit_all(event, &payload).unwrap(); + Ok(payload) +} + +/// Emit an event to all windows, used for side-effects where there is no source window to attribute. This +fn emit_side_effect(app_handle: &AppHandle, event: &str, payload: S) { + app_handle.emit_all(event, &payload).unwrap(); } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 8f35e207..d538d5f8 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -86,7 +86,8 @@ pub async fn set_key_value( key: &str, value: &str, pool: &Pool, -) -> Option { +) -> (KeyValue, bool) { + let existing = get_key_value(namespace, key, pool).await; sqlx::query!( r#" INSERT INTO key_values (namespace, key, value) @@ -102,7 +103,10 @@ pub async fn set_key_value( .await .expect("Failed to insert key value"); - get_key_value(namespace, key, pool).await + let kv = get_key_value(namespace, key, pool) + .await + .expect("Failed to get key value"); + return (kv, existing.is_none()); } pub async fn get_key_value(namespace: &str, key: &str, pool: &Pool) -> Option { diff --git a/src-web/components/DialogContext.tsx b/src-web/components/DialogContext.tsx index 8f9e09b6..94152a69 100644 --- a/src-web/components/DialogContext.tsx +++ b/src-web/components/DialogContext.tsx @@ -5,7 +5,7 @@ import { Dialog } from './core/Dialog'; type DialogEntry = { id: string; render: ({ hide }: { hide: () => void }) => React.ReactNode; -} & Pick; +} & Pick; type DialogEntryOptionalId = Omit & { id?: string }; diff --git a/src-web/components/RequestActionsDropdown.tsx b/src-web/components/RequestActionsDropdown.tsx index a595a843..5aef02c0 100644 --- a/src-web/components/RequestActionsDropdown.tsx +++ b/src-web/components/RequestActionsDropdown.tsx @@ -5,6 +5,7 @@ import { useDuplicateRequest } from '../hooks/useDuplicateRequest'; import { useRequest } from '../hooks/useRequest'; import { Dropdown } from './core/Dropdown'; import { Icon } from './core/Icon'; +import { InlineCode } from './core/InlineCode'; interface Props { requestId: string; @@ -30,9 +31,12 @@ export function RequestActionsDropdown({ requestId, children }: Props) { onSelect: async () => { const confirmed = await confirm({ title: 'Delete Request', - description: `Are you sure you want to delete "${request?.name}"?`, - confirmButtonColor: 'danger', - confirmButtonText: 'Delete', + variant: 'delete', + description: ( + <> + Are you sure you want to delete {request?.name}? + + ), }); if (confirmed) { deleteRequest.mutate(); diff --git a/src-web/components/WorkspaceDropdown.tsx b/src-web/components/WorkspaceActionsDropdown.tsx similarity index 86% rename from src-web/components/WorkspaceDropdown.tsx rename to src-web/components/WorkspaceActionsDropdown.tsx index caa7a852..5006a018 100644 --- a/src-web/components/WorkspaceDropdown.tsx +++ b/src-web/components/WorkspaceActionsDropdown.tsx @@ -10,12 +10,13 @@ import { Button } from './core/Button'; import type { DropdownItem } from './core/Dropdown'; import { Dropdown } from './core/Dropdown'; import { Icon } from './core/Icon'; +import { InlineCode } from './core/InlineCode'; type Props = { className?: string; }; -export const WorkspaceDropdown = memo(function WorkspaceDropdown({ className }: Props) { +export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ className }: Props) { const workspaces = useWorkspaces(); const activeWorkspace = useActiveWorkspace(); const activeWorkspaceId = activeWorkspace?.id ?? null; @@ -51,9 +52,12 @@ export const WorkspaceDropdown = memo(function WorkspaceDropdown({ className }: onSelect: async () => { const confirmed = await confirm({ title: 'Delete Workspace', - description: `Are you sure you want to delete "${activeWorkspace?.name}"?`, - confirmButtonColor: 'danger', - confirmButtonText: 'Delete', + variant: 'delete', + description: ( + <> + Are you sure you want to delete {activeWorkspace?.name}? + + ), }); if (confirmed) { deleteWorkspace.mutate(); diff --git a/src-web/components/WorkspaceHeader.tsx b/src-web/components/WorkspaceHeader.tsx index 23bc176c..6f9f6ca4 100644 --- a/src-web/components/WorkspaceHeader.tsx +++ b/src-web/components/WorkspaceHeader.tsx @@ -5,7 +5,7 @@ import { IconButton } from './core/IconButton'; import { HStack } from './core/Stacks'; import { RequestActionsDropdown } from './RequestActionsDropdown'; import { SidebarActions } from './SidebarActions'; -import { WorkspaceDropdown } from './WorkspaceDropdown'; +import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown'; interface Props { className?: string; @@ -21,13 +21,12 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop > - +
{activeRequest?.name}
- {activeRequest && ( {!hideX && ( @@ -63,13 +66,11 @@ export function Dialog({ className="ml-auto absolute right-1 top-1" /> )} - -

- {title} -

- {description &&

{description}

} -
{children}
-
+ + {title} + + {description &&

{description}

} +
{children}
diff --git a/src-web/components/core/Heading.tsx b/src-web/components/core/Heading.tsx index 9562fe83..cda9f282 100644 --- a/src-web/components/core/Heading.tsx +++ b/src-web/components/core/Heading.tsx @@ -1,12 +1,7 @@ import classnames from 'classnames'; -import type { ReactNode } from 'react'; +import type { HTMLAttributes } from 'react'; -type Props = { - className?: string; - children?: ReactNode; -}; - -export function Heading({ className, children, ...props }: Props) { +export function Heading({ className, children, ...props }: HTMLAttributes) { return (

{children} diff --git a/src-web/components/core/InlineCode.tsx b/src-web/components/core/InlineCode.tsx new file mode 100644 index 00000000..7471720b --- /dev/null +++ b/src-web/components/core/InlineCode.tsx @@ -0,0 +1,14 @@ +import classnames from 'classnames'; +import type { HTMLAttributes } from 'react'; + +export function InlineCode({ className, ...props }: HTMLAttributes) { + return ( + + ); +} diff --git a/src-web/hooks/Confirm.tsx b/src-web/hooks/Confirm.tsx index 573fe5cb..f70b08d9 100644 --- a/src-web/hooks/Confirm.tsx +++ b/src-web/hooks/Confirm.tsx @@ -2,20 +2,27 @@ import type { ButtonProps } from '../components/core/Button'; import { Button } from '../components/core/Button'; import { HStack } from '../components/core/Stacks'; -interface Props { +export interface ConfirmProps { hide: () => void; onResult: (result: boolean) => void; - confirmButtonColor?: ButtonProps['color']; - confirmButtonText?: string; + variant?: 'delete' | 'confirm'; } -export function Confirm({ - hide, - onResult, - confirmButtonColor = 'primary', - confirmButtonText = 'Confirm', -}: Props) { + +const colors: Record, ButtonProps['color']> = { + delete: 'danger', + confirm: 'primary', +}; + +const confirmButtonTexts: Record, string> = { + delete: 'Delete', + confirm: 'Confirm', +}; + +export function Confirm({ hide, onResult, variant = 'confirm' }: ConfirmProps) { const focusRef = (el: HTMLButtonElement | null) => { - el?.focus(); + setTimeout(() => { + el?.focus(); + }); }; const handleHide = () => { @@ -33,8 +40,8 @@ export function Confirm({ - ); diff --git a/src-web/hooks/useConfirm.ts b/src-web/hooks/useConfirm.ts new file mode 100644 index 00000000..791004f6 --- /dev/null +++ b/src-web/hooks/useConfirm.ts @@ -0,0 +1,26 @@ +import type { DialogProps } from '../components/core/Dialog'; +import { useDialog } from '../components/DialogContext'; +import type { ConfirmProps } from './Confirm'; +import { Confirm } from './Confirm'; + +export function useConfirm() { + const dialog = useDialog(); + return ({ + title, + description, + variant, + }: { + title: DialogProps['title']; + description?: DialogProps['description']; + variant: ConfirmProps['variant']; + }) => + new Promise((onResult: ConfirmProps['onResult']) => { + dialog.show({ + title, + description, + hideX: true, + size: 'sm', + render: ({ hide }) => Confirm({ hide, variant, onResult }), + }); + }); +} diff --git a/src-web/hooks/useConfirm.tsx b/src-web/hooks/useConfirm.tsx deleted file mode 100644 index 5ba0cf65..00000000 --- a/src-web/hooks/useConfirm.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { ButtonProps } from '../components/core/Button'; -import { useDialog } from '../components/DialogContext'; -import { Confirm } from './Confirm'; - -export function useConfirm() { - const dialog = useDialog(); - return ({ - title, - description, - confirmButtonColor, - confirmButtonText, - }: { - title: string; - description?: string; - confirmButtonColor?: ButtonProps['color']; - confirmButtonText?: string; - }) => { - return new Promise((resolve: (r: boolean) => void) => { - dialog.show({ - title, - description, - hideX: true, - render: ({ hide }) => ( - - ), - }); - }); - }; -} diff --git a/src-web/hooks/useCreateRequest.ts b/src-web/hooks/useCreateRequest.ts index 7a34e309..aec73975 100644 --- a/src-web/hooks/useCreateRequest.ts +++ b/src-web/hooks/useCreateRequest.ts @@ -1,26 +1,31 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; import type { HttpRequest } from '../lib/models'; -import { useActiveWorkspace } from './useActiveWorkspace'; -import { useRequests } from './useRequests'; +import { useActiveWorkspaceId } from './useActiveWorkspaceId'; +import { requestsQueryKey, useRequests } from './useRequests'; import { useRoutes } from './useRoutes'; export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) { - const workspace = useActiveWorkspace(); + const workspaceId = useActiveWorkspaceId(); const routes = useRoutes(); const requests = useRequests(); + const queryClient = useQueryClient(); - return useMutation>>({ + return useMutation>>({ mutationFn: (patch) => { - if (workspace === null) { + if (workspaceId === null) { throw new Error("Cannot create request when there's no active workspace"); } const sortPriority = maxSortPriority(requests) + 1000; - return invoke('create_request', { sortPriority, workspaceId: workspace.id, ...patch }); + return invoke('create_request', { sortPriority, workspaceId, ...patch }); }, - onSuccess: async (requestId) => { - if (navigateAfter && workspace !== null) { - routes.navigate('request', { workspaceId: workspace.id, requestId }); + onSuccess: async (request) => { + queryClient.setQueryData( + requestsQueryKey({ workspaceId: request.workspaceId }), + (requests) => [...(requests ?? []), request], + ); + if (navigateAfter) { + routes.navigate('request', { workspaceId: request.workspaceId, requestId: request.id }); } }, }); diff --git a/src-web/hooks/useCreateWorkspace.ts b/src-web/hooks/useCreateWorkspace.ts index a93c9634..72cc8d52 100644 --- a/src-web/hooks/useCreateWorkspace.ts +++ b/src-web/hooks/useCreateWorkspace.ts @@ -1,17 +1,23 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; import type { Workspace } from '../lib/models'; import { useRoutes } from './useRoutes'; +import { workspacesQueryKey } from './useWorkspaces'; export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) { const routes = useRoutes(); - return useMutation>({ + const queryClient = useQueryClient(); + return useMutation>({ mutationFn: (patch) => { return invoke('create_workspace', patch); }, - onSuccess: async (workspaceId) => { + onSuccess: async (workspace) => { + queryClient.setQueryData(workspacesQueryKey({}), (workspaces) => [ + ...(workspaces ?? []), + workspace, + ]); if (navigateAfter) { - routes.navigate('workspace', { workspaceId }); + routes.navigate('workspace', { workspaceId: workspace.id }); } }, }); diff --git a/src-web/hooks/useDeleteRequest.ts b/src-web/hooks/useDeleteRequest.ts index c679f2f6..25e93793 100644 --- a/src-web/hooks/useDeleteRequest.ts +++ b/src-web/hooks/useDeleteRequest.ts @@ -1,19 +1,25 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; -import { useActiveWorkspaceId } from './useActiveWorkspaceId'; +import type { HttpRequest } from '../lib/models'; +import { useActiveRequestId } from './useActiveRequestId'; import { requestsQueryKey } from './useRequests'; +import { responsesQueryKey } from './useResponses'; +import { useRoutes } from './useRoutes'; export function useDeleteRequest(id: string | null) { - const workspaceId = useActiveWorkspaceId(); const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async () => { - if (id === null) return; - await invoke('delete_request', { requestId: id }); - }, - onSuccess: async () => { - if (workspaceId === null || id === null) return; - await queryClient.invalidateQueries(requestsQueryKey({ workspaceId })); + const activeRequestId = useActiveRequestId(); + const routes = useRoutes(); + return useMutation({ + mutationFn: async () => invoke('delete_request', { requestId: id }), + onSuccess: async ({ workspaceId, id: requestId }) => { + queryClient.setQueryData(responsesQueryKey({ requestId }), []); // Responses were deleted + queryClient.setQueryData(requestsQueryKey({ workspaceId }), (requests) => + (requests ?? []).filter((r) => r.id !== requestId), + ); + if (activeRequestId === requestId) { + routes.navigate('workspace', { workspaceId }); + } }, }); } diff --git a/src-web/hooks/useDeleteResponse.ts b/src-web/hooks/useDeleteResponse.ts index e5947097..34ea2853 100644 --- a/src-web/hooks/useDeleteResponse.ts +++ b/src-web/hooks/useDeleteResponse.ts @@ -1,11 +1,18 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; +import type { HttpResponse } from '../lib/models'; +import { responsesQueryKey } from './useResponses'; export function useDeleteResponse(id: string | null) { - return useMutation({ + const queryClient = useQueryClient(); + return useMutation({ mutationFn: async () => { - if (id === null) return; - await invoke('delete_response', { id: id }); + return await invoke('delete_response', { id: id }); + }, + onSuccess: ({ requestId, id: responseId }) => { + queryClient.setQueryData(responsesQueryKey({ requestId }), (responses) => + (responses ?? []).filter((response) => response.id !== responseId), + ); }, }); } diff --git a/src-web/hooks/useDeleteResponses.ts b/src-web/hooks/useDeleteResponses.ts index 57a2c56d..1a7f9e3f 100644 --- a/src-web/hooks/useDeleteResponses.ts +++ b/src-web/hooks/useDeleteResponses.ts @@ -11,7 +11,7 @@ export function useDeleteResponses(requestId?: string) { }, onSuccess: async () => { if (!requestId) return; - await queryClient.invalidateQueries(responsesQueryKey({ requestId })); + queryClient.setQueryData(responsesQueryKey({ requestId }), []); }, }); } diff --git a/src-web/hooks/useDeleteWorkspace.ts b/src-web/hooks/useDeleteWorkspace.ts index 6d024d4b..cf53043b 100644 --- a/src-web/hooks/useDeleteWorkspace.ts +++ b/src-web/hooks/useDeleteWorkspace.ts @@ -1,6 +1,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; +import type { Workspace } from '../lib/models'; import { useActiveWorkspaceId } from './useActiveWorkspaceId'; +import { requestsQueryKey } from './useRequests'; import { useRoutes } from './useRoutes'; import { workspacesQueryKey } from './useWorkspaces'; @@ -8,17 +10,21 @@ export function useDeleteWorkspace(id: string | null) { const queryClient = useQueryClient(); const activeWorkspaceId = useActiveWorkspaceId(); const routes = useRoutes(); - return useMutation({ - mutationFn: async () => { - if (id === null) return; - await invoke('delete_workspace', { id }); + return useMutation({ + mutationFn: () => { + return invoke('delete_workspace', { id }); }, - onSuccess: async () => { - if (id === null) return; - await queryClient.invalidateQueries(workspacesQueryKey()); - if (id === activeWorkspaceId) { + onSuccess: async ({ id: workspaceId }) => { + queryClient.setQueryData(workspacesQueryKey({}), (workspaces) => + workspaces?.filter((workspace) => workspace.id !== workspaceId), + ); + if (workspaceId === activeWorkspaceId) { routes.navigate('workspaces'); } + + // Also clean up other things that may have been deleted + queryClient.setQueryData(requestsQueryKey({ workspaceId }), []); + await queryClient.invalidateQueries(requestsQueryKey({ workspaceId })); }, }); } diff --git a/src-web/hooks/useKeyValue.ts b/src-web/hooks/useKeyValue.ts index 52ddc674..7115071f 100644 --- a/src-web/hooks/useKeyValue.ts +++ b/src-web/hooks/useKeyValue.ts @@ -33,7 +33,7 @@ export function useKeyValue({ const mutate = useMutation({ mutationFn: (value) => setKeyValue({ namespace, key, value }), // k/v should be as fast as possible, so optimistically update the cache - onMutate: (value) => queryClient.setQueryData(keyValueQueryKey({ namespace, key }), value), + onMutate: (value) => queryClient.setQueryData(keyValueQueryKey({ namespace, key }), value), }); const set = useCallback( diff --git a/src-web/hooks/useSendRequest.ts b/src-web/hooks/useSendRequest.ts index 956eb753..45c3b0a9 100644 --- a/src-web/hooks/useSendRequest.ts +++ b/src-web/hooks/useSendRequest.ts @@ -1,11 +1,19 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; +import type { HttpResponse } from '../lib/models'; +import { responsesQueryKey } from './useResponses'; export function useSendRequest(id: string | null) { - return useMutation({ + const queryClient = useQueryClient(); + return useMutation({ mutationFn: async () => { - if (id === null) return; - await invoke('send_request', { requestId: id }); + return invoke('send_request', { requestId: id }); + }, + onSuccess: (response) => { + queryClient.setQueryData(responsesQueryKey(response), (responses) => [ + ...(responses ?? []), + response, + ]); }, }).mutate; } diff --git a/src-web/hooks/useTauriListeners.ts b/src-web/hooks/useTauriListeners.ts index 6d7d9b17..f4f2dd18 100644 --- a/src-web/hooks/useTauriListeners.ts +++ b/src-web/hooks/useTauriListeners.ts @@ -1,13 +1,14 @@ import { useQueryClient } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; import type { EventCallback } from '@tauri-apps/api/event'; +import { listen as tauriListen } from '@tauri-apps/api/event'; import { appWindow } from '@tauri-apps/api/window'; import { matchPath } from 'react-router-dom'; import { useEffectOnce } from 'react-use'; import { DEFAULT_FONT_SIZE } from '../lib/constants'; import { debounce } from '../lib/debounce'; -import { extractKeyValue, NAMESPACE_NO_SYNC } from '../lib/keyValueStore'; -import type { HttpRequest, HttpResponse, KeyValue, Model, Workspace } from '../lib/models'; +import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore'; +import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models'; import { modelsEq } from '../lib/models'; import { keyValueQueryKey } from './useKeyValue'; import { requestsQueryKey } from './useRequests'; @@ -30,7 +31,7 @@ export function useTauriListeners() { // eslint-disable-next-line @typescript-eslint/ban-types function listen(event: string, fn: EventCallback) { - appWindow.listen(event, fn).then((unsub) => { + tauriListen(event, fn).then((unsub) => { if (unmounted) unsub(); else unsubFns.push(unsub); }); @@ -43,13 +44,10 @@ export function useTauriListeners() { listen('toggle_sidebar', sidebarDisplay.toggle); listen('refresh', () => location.reload()); - listenDebounced('updated_key_value', ({ payload: keyValue }: { payload: KeyValue }) => { - if (keyValue.namespace !== NAMESPACE_NO_SYNC) { - queryClient.setQueryData(keyValueQueryKey(keyValue), extractKeyValue(keyValue)); - } - }); + listenDebounced('created_model', ({ payload, windowLabel }) => { + const cameFromSameWindow = windowLabel === appWindow.label; + if (cameFromSameWindow) return; - listenDebounced('created_model', ({ payload }: { payload: Model }) => { const queryKey = payload.model === 'http_request' ? requestsQueryKey(payload) @@ -71,11 +69,14 @@ export function useTauriListeners() { const skipSync = payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC; if (!skipSync) { - queryClient.setQueryData(queryKey, (values: Model[] = []) => [...values, payload]); + queryClient.setQueryData(queryKey, (values) => [...(values ?? []), payload]); } }); - listenDebounced('updated_model', ({ payload }: { payload: Model }) => { + listenDebounced('updated_model', ({ payload, windowLabel }) => { + const cameFromSameWindow = windowLabel === appWindow.label; + if (cameFromSameWindow) return; + const queryKey = payload.model === 'http_request' ? requestsQueryKey(payload) @@ -94,26 +95,30 @@ export function useTauriListeners() { const skipSync = payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC; + if (payload.model === 'http_request') { + wasUpdatedExternally(payload.id); + } + if (!skipSync) { - queryClient.setQueryData(queryKey, (values: Model[] = []) => - values.map((v) => (modelsEq(v, payload) ? payload : v)), + queryClient.setQueryData(queryKey, (values) => + values?.map((v) => (modelsEq(v, payload) ? payload : v)), ); } }); - listen('deleted_model', ({ payload: model }: { payload: Model }) => { + listen('deleted_model', ({ payload }) => { function removeById(model: T) { return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id); } - if (model.model === 'workspace') { - queryClient.setQueryData(workspacesQueryKey(), removeById(model)); - } else if (model.model === 'http_request') { - queryClient.setQueryData(requestsQueryKey(model), removeById(model)); - } else if (model.model === 'http_response') { - queryClient.setQueryData(responsesQueryKey(model), removeById(model)); - } else if (model.model === 'key_value') { - queryClient.setQueryData(keyValueQueryKey(model), undefined); + if (payload.model === 'workspace') { + queryClient.setQueryData(workspacesQueryKey(), removeById(payload)); + } else if (payload.model === 'http_request') { + queryClient.setQueryData(requestsQueryKey(payload), removeById(payload)); + } else if (payload.model === 'http_response') { + queryClient.setQueryData(responsesQueryKey(payload), removeById(payload)); + } else if (payload.model === 'key_value') { + queryClient.setQueryData(keyValueQueryKey(payload), undefined); } }); diff --git a/src-web/hooks/useUpdateAnyRequest.ts b/src-web/hooks/useUpdateAnyRequest.ts index ff63882d..ab9f80f9 100644 --- a/src-web/hooks/useUpdateAnyRequest.ts +++ b/src-web/hooks/useUpdateAnyRequest.ts @@ -19,8 +19,8 @@ export function useUpdateAnyRequest() { onMutate: async ({ id, update }) => { const request = await getRequest(id); if (request === null) return; - queryClient.setQueryData(requestsQueryKey(request), (requests: HttpRequest[] | undefined) => - requests?.map((r) => (r.id === request.id ? update(r) : r)), + queryClient.setQueryData(requestsQueryKey(request), (requests) => + (requests ?? []).map((r) => (r.id === request.id ? update(r) : r)), ); }, }); diff --git a/src-web/hooks/useUpdateRequest.ts b/src-web/hooks/useUpdateRequest.ts index 1f49a8ef..d6d8c38f 100644 --- a/src-web/hooks/useUpdateRequest.ts +++ b/src-web/hooks/useUpdateRequest.ts @@ -21,8 +21,8 @@ export function useUpdateRequest(id: string | null) { if (request === null) return; const newRequest = typeof v === 'function' ? v(request) : { ...request, ...v }; - queryClient.setQueryData(requestsQueryKey(request), (requests: HttpRequest[] | undefined) => - requests?.map((r) => (r.id === newRequest.id ? newRequest : r)), + queryClient.setQueryData(requestsQueryKey(request), (requests) => + (requests ?? []).map((r) => (r.id === newRequest.id ? newRequest : r)), ); }, }); diff --git a/src-web/lib/lastLocation.ts b/src-web/lib/lastLocation.ts index 99783fae..45719aee 100644 --- a/src-web/lib/lastLocation.ts +++ b/src-web/lib/lastLocation.ts @@ -1,11 +1,11 @@ -import { getKeyValue, setKeyValue } from './keyValueStore'; +import { getKeyValue, NAMESPACE_NO_SYNC, setKeyValue } from './keyValueStore'; export async function getLastLocation(): Promise { - return getKeyValue({ key: 'last_location', fallback: '/' }); + return getKeyValue({ namespace: NAMESPACE_NO_SYNC, key: 'last_location', fallback: '/' }); } export async function setLastLocation(pathname: string): Promise { - return setKeyValue({ key: 'last_location', value: pathname }); + return setKeyValue({ namespace: NAMESPACE_NO_SYNC, key: 'last_location', value: pathname }); } export async function syncLastLocation(): Promise {