From b721396340baaf87743510227aa6fc2d324e936e Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 29 Mar 2023 21:53:20 -0700 Subject: [PATCH] Better multi-window updates --- package-lock.json | 22 ------- src-tauri/src/main.rs | 74 ++++++++++------------- src-web/components/App.tsx | 27 --------- src-web/components/GraphQLEditor.tsx | 8 +-- src-web/components/HeaderEditor.tsx | 4 +- src-web/components/ParameterEditor.tsx | 12 +++- src-web/components/RequestPane.tsx | 35 +++++------ src-web/components/ResponsePane.tsx | 4 +- src-web/components/UrlBar.tsx | 4 +- src-web/components/core/Editor/Editor.tsx | 30 ++++++++- src-web/components/core/Input.tsx | 2 +- src-web/components/core/PairEditor.tsx | 16 +++++ src-web/hooks/useRequestUpdateKey.ts | 14 +++++ src-web/hooks/useTauriListeners.ts | 59 +++++++++++++++--- src-web/hooks/useUniqueKey.ts | 2 +- src-web/lib/generateId.ts | 3 + 16 files changed, 180 insertions(+), 136 deletions(-) create mode 100644 src-web/hooks/useRequestUpdateKey.ts create mode 100644 src-web/lib/generateId.ts diff --git a/package-lock.json b/package-lock.json index 9407c4be..53fe27b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,6 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", "react-helmet-async": "^1.3.0", - "react-hook-form": "^7.43.8", "react-router-dom": "^6.8.1", "react-use": "^17.4.0", "uuid": "^9.0.0" @@ -6335,21 +6334,6 @@ "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/react-hook-form": { - "version": "7.43.8", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.43.8.tgz", - "integrity": "sha512-BQm+Ge5KjTk1EchDBRhdP8Pkb7MArO2jFF+UWYr3rtvh6197khi22uloLqlWeuY02ItlCzPunPsFt1/q9wQKnw==", - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-hook-form" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18" - } - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -12123,12 +12107,6 @@ "shallowequal": "^1.1.0" } }, - "react-hook-form": { - "version": "7.43.8", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.43.8.tgz", - "integrity": "sha512-BQm+Ge5KjTk1EchDBRhdP8Pkb7MArO2jFF+UWYr3rtvh6197khi22uloLqlWeuY02ItlCzPunPsFt1/q9wQKnw==", - "requires": {} - }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b0a5c680..7e6d6de0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -15,7 +15,9 @@ use std::fs::create_dir_all; use base64::Engine; use http::header::{HeaderName, ACCEPT, USER_AGENT}; use http::{HeaderMap, HeaderValue, Method}; +use objc::runtime::ivar_getOffset; use reqwest::redirect::Policy; +use serde::Serialize; use sqlx::migrate::Migrator; use sqlx::sqlite::SqlitePoolOptions; use sqlx::types::Json; @@ -27,8 +29,6 @@ use tokio::sync::Mutex; use window_ext::WindowExt; -use crate::models::generate_id; - mod models; mod runtime; mod window_ext; @@ -101,6 +101,8 @@ async fn actually_send_ephemeral_request( url_string = format!("http://{}", url_string); } + println!("Sending request to {}", url_string); + let client = reqwest::Client::builder() .redirect(Policy::none()) .build() @@ -114,7 +116,7 @@ async fn actually_send_ephemeral_request( if h.name.is_empty() && h.value.is_empty() { continue; } - if h.enabled == false { + if !h.enabled { continue; } let header_name = match HeaderName::from_bytes(h.name.as_bytes()) { @@ -214,10 +216,7 @@ async fn actually_send_ephemeral_request( response = models::update_response_if_id(response, window.label(), pool) .await .expect("Failed to update response"); - window - .app_handle() - .emit_all("updated_response", &response) - .unwrap(); + emit_all_others(&window, "updated_response", &response); Ok(response) } Err(e) => response_err(response, e.to_string(), window, pool).await, @@ -240,10 +239,7 @@ async fn send_request( models::create_response(&req.id, 0, "", 0, None, "", vec![], window.label(), pool) .await .expect("Failed to create response"); - window - .app_handle() - .emit_all("updated_response", &response) - .unwrap(); + emit_all_others(&window, "updated_response", &response); actually_send_ephemeral_request(req, response, window, pool).await?; Ok(()) @@ -259,10 +255,7 @@ async fn response_err( response = models::update_response_if_id(response, window.label(), pool) .await .expect("Failed to update response"); - window - .app_handle() - .emit_all("updated_response", &response) - .unwrap(); + emit_all_others(&window, "updated_response", &response); Ok(response) } @@ -282,7 +275,7 @@ async fn set_key_value( namespace: &str, key: &str, value: &str, - app_handle: AppHandle, + window: Window, db_instance: State<'_, Mutex>>, ) -> Result<(), String> { let pool = &*db_instance.lock().await; @@ -290,9 +283,7 @@ async fn set_key_value( .await .expect("Failed to create key value"); - app_handle - .emit_all("updated_key_value", &created_key_value) - .unwrap(); + emit_all_others(&window, "updated_key_value", &created_key_value); Ok(()) } @@ -308,10 +299,7 @@ async fn create_workspace( .await .expect("Failed to create workspace"); - window - .app_handle() - .emit_all("updated_workspace", &created_workspace) - .unwrap(); + emit_all_others(&window, "updated_workspace", &created_workspace); Ok(created_workspace.id) } @@ -344,10 +332,7 @@ async fn create_request( .await .expect("Failed to create request"); - window - .app_handle() - .emit_all("updated_request", &created_request) - .unwrap(); + emit_all_others(&window, "updated_request", &created_request); Ok(created_request.id) } @@ -362,10 +347,7 @@ async fn duplicate_request( let request = models::duplicate_request(id, window.label(), pool) .await .expect("Failed to duplicate request"); - window - .app_handle() - .emit_all("updated_request", &request) - .unwrap(); + emit_all_others(&window, "updated_request", &request); Ok(request.id) } @@ -406,17 +388,14 @@ async fn update_request( .await .expect("Failed to update request"); - window - .app_handle() - .emit_all("updated_request", updated_request) - .unwrap(); + emit_all_others(&window, "updated_request", updated_request); Ok(()) } #[tauri::command] async fn delete_request( - app_handle: AppHandle, + window: Window, db_instance: State<'_, Mutex>>, request_id: &str, ) -> Result<(), String> { @@ -424,7 +403,7 @@ async fn delete_request( let req = models::delete_request(request_id, pool) .await .expect("Failed to delete request"); - app_handle.emit_all("deleted_model", req).unwrap(); + emit_all_others(&window, "deleted_model", req); Ok(()) } @@ -464,14 +443,14 @@ async fn responses( #[tauri::command] async fn delete_response( id: &str, - app_handle: AppHandle, + window: Window, db_instance: State<'_, Mutex>>, ) -> Result<(), String> { let pool = &*db_instance.lock().await; let response = models::delete_response(id, pool) .await .expect("Failed to delete response"); - app_handle.emit_all("deleted_model", response).unwrap(); + emit_all_others(&window, "deleted_model", response); Ok(()) } @@ -512,7 +491,7 @@ async fn workspaces( #[tauri::command] async fn delete_workspace( - app_handle: AppHandle, + window: Window, db_instance: State<'_, Mutex>>, id: &str, ) -> Result<(), String> { @@ -520,7 +499,7 @@ async fn delete_workspace( let workspace = models::delete_workspace(id, pool) .await .expect("Failed to delete workspace"); - app_handle.emit_all("deleted_model", workspace).unwrap(); + emit_all_others(&window, "deleted_model", workspace); Ok(()) } @@ -715,7 +694,7 @@ async fn get_or_create_client_id(pool: &Pool) -> String { match models::get_key_value("global", "client_id", pool).await { Some(kv) => kv.value, None => { - let id = &generate_id("yaak"); + let id = &models::generate_id("yaak"); models::set_key_value("global", "client_id", id, pool) .await .expect("Failed to set client id") @@ -723,3 +702,14 @@ async fn get_or_create_client_id(pool: &Pool) -> String { } } } + +/// 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(); + } +} diff --git a/src-web/components/App.tsx b/src-web/components/App.tsx index 2fe208c4..eb20739d 100644 --- a/src-web/components/App.tsx +++ b/src-web/components/App.tsx @@ -51,33 +51,6 @@ await listen( }, UPDATE_DEBOUNCE_MILLIS), ); -await listen( - 'updated_request', - debounce(({ payload: request }: { payload: HttpRequest }) => { - if (request.updatedBy === appWindow.label) return; - - queryClient.setQueryData( - requestsQueryKey(request.workspaceId), - (requests: HttpRequest[] = []) => { - const newRequests = []; - let found = false; - for (const r of requests) { - if (r.id === request.id) { - found = true; - newRequests.push(request); - } else { - newRequests.push(r); - } - } - if (!found) { - newRequests.push(request); - } - return newRequests; - }, - ); - }, UPDATE_DEBOUNCE_MILLIS), -); - await listen('updated_response', ({ payload: response }: { payload: HttpResponse }) => { queryClient.setQueryData( responsesQueryKey(response.requestId), diff --git a/src-web/components/GraphQLEditor.tsx b/src-web/components/GraphQLEditor.tsx index d0f10bb9..355d66f9 100644 --- a/src-web/components/GraphQLEditor.tsx +++ b/src-web/components/GraphQLEditor.tsx @@ -1,6 +1,5 @@ import type { Extension } from '@codemirror/state'; import { useEffect, useMemo, useState } from 'react'; -import { useUniqueKey } from '../hooks/useUniqueKey'; import type { HttpRequest } from '../lib/models'; import { sendEphemeralRequest } from '../lib/sendEphemeralRequest'; import type { EditorProps } from './core/Editor'; @@ -13,7 +12,10 @@ import { } from './core/Editor'; import { Separator } from './core/Separator'; -type Props = Pick & { +type Props = Pick< + EditorProps, + 'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey' +> & { baseRequest: HttpRequest; }; @@ -24,7 +26,6 @@ interface GraphQLBody { } export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) { - const queryKey = useUniqueKey(); const { query, variables } = useMemo(() => { if (!defaultValue) { return { query: '', variables: {} }; @@ -79,7 +80,6 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi return (
void; }; -export function HeaderEditor({ headers, onChange }: Props) { +export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) { return ( void; }; -export function ParametersEditor({ parameters, onChange }: Props) { - return ; +export function ParametersEditor({ parameters, forceUpdateKey, onChange }: Props) { + return ( + + ); } diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index fb782c6b..2d44012d 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -3,8 +3,8 @@ import type { CSSProperties } from 'react'; import { memo, useCallback, useMemo, useState } from 'react'; import { useActiveRequest } from '../hooks/useActiveRequest'; import { useKeyValue } from '../hooks/useKeyValue'; +import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { useUpdateRequest } from '../hooks/useUpdateRequest'; -import { useWindowFocus } from '../hooks/useWindowFocus'; import { tryFormatJson } from '../lib/formatters'; import type { HttpHeader, HttpRequest } from '../lib/models'; import { @@ -125,13 +125,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN [], ); - const visible = useWindowFocus(); - const multiWindowKey = useMemo(() => { - // If the window has focus, don't ever force an update - if (visible) return undefined; - // If the window is not focused, force an update if the request has been updated - return activeRequest?.updatedAt; - }, [visible, activeRequest?.updatedAt]); + const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest?.id ?? null); return (
{activeRequest && ( <> - + {activeRequest.authenticationType === AUTH_TYPE_BASIC ? ( ) : activeRequest.authenticationType === AUTH_TYPE_BEARER ? ( @@ -174,18 +163,22 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN - null} /> + null} + /> {activeRequest.bodyType === BODY_TYPE_JSON ? ( ) : activeRequest.bodyType === BODY_TYPE_XML ? ( ) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? ( updateRequest.mutate({ method }), []); const handleUrlChange = useCallback((url: string) => updateRequest.mutate({ url }), []); const loading = useIsResponseLoading(requestId); + const { updateKey } = useRequestUpdateKey(requestId); const handleSubmit = useCallback( async (e: FormEvent) => { @@ -32,13 +34,13 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
{ + if (cm.current === null) return; + const { view, languageCompartment } = cm.current; + const newDoc = defaultValue; + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: newDoc ?? '' } }); + const ext = getLanguageExtension({ contentType, useTemplating, autocomplete }); + view.dispatch({ effects: languageCompartment.reconfigure(ext) }); + }, [forceUpdateKey]); + // Initialize the editor when ref mounts useEffect(() => { if (wrapperRef.current === null || cm.current !== null) return; @@ -218,13 +230,27 @@ function getExtensions({ // Handle onChange EditorView.updateListener.of((update) => { - if (onChange && update.docChanged) { + if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) { onChange.current?.(update.state.doc.toString()); } }), ]; } +function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) { + // Make sure document has changed, ensuring user events like selections don't count. + if (viewUpdate.docChanged) { + // Check transactions for any that are direct user input, not changes from Y.js or another extension. + for (const transaction of viewUpdate.transactions) { + // Not using Transaction.isUserEvent because that only checks for a specific User event type ( "input", "delete", etc.). Checking the annotation directly allows for any type of user event. + const userEventType = transaction.annotation(Transaction.userEvent); + if (userEventType) return userEventType; + } + } + + return false; +} + const syncGutterBg = ({ parent, className = '', diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx index 2193767a..b7c29588 100644 --- a/src-web/components/core/Input.tsx +++ b/src-web/components/core/Input.tsx @@ -7,7 +7,7 @@ import { IconButton } from './IconButton'; import { HStack, VStack } from './Stacks'; export type InputProps = Omit, 'onChange' | 'onFocus'> & - Pick & { + Pick & { name: string; type?: 'text' | 'password'; label: string; diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index e582b99f..8e879e94 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -14,6 +14,7 @@ import { Input } from './Input'; export type PairEditorProps = { pairs: Pair[]; onChange: (pairs: Pair[]) => void; + forceUpdateKey?: string; className?: string; namePlaceholder?: string; valuePlaceholder?: string; @@ -36,6 +37,7 @@ type PairContainer = { export const PairEditor = memo(function PairEditor({ pairs: originalPairs, + forceUpdateKey, nameAutocomplete, valueAutocomplete, namePlaceholder, @@ -53,6 +55,15 @@ export const PairEditor = memo(function PairEditor({ return [...pairs, newPairContainer()]; }); + useEffect(() => { + // Remove empty headers on initial render + // TODO: Make this not refresh the entire editor when forceUpdateKey changes, using some + // sort of diff method or deterministic IDs based on array index and update key + const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === '')); + const pairs = nonEmpty.map((pair) => newPairContainer(pair)); + setPairs([...pairs, newPairContainer()]); + }, [forceUpdateKey]); + const setPairsAndSave = useCallback( (fn: (pairs: PairContainer[]) => PairContainer[]) => { setPairs((oldPairs) => { @@ -139,6 +150,7 @@ export const PairEditor = memo(function PairEditor({ pairContainer={p} className="py-1" isLast={isLast} + forceUpdateKey={forceUpdateKey} nameAutocomplete={nameAutocomplete} valueAutocomplete={valueAutocomplete} namePlaceholder={namePlaceholder} @@ -179,6 +191,7 @@ type FormRowProps = { | 'valuePlaceholder' | 'nameValidate' | 'valueValidate' + | 'forceUpdateKey' >; const FormRow = memo(function FormRow({ @@ -190,6 +203,7 @@ const FormRow = memo(function FormRow({ onMove, onEnd, isLast, + forceUpdateKey, nameAutocomplete, valueAutocomplete, namePlaceholder, @@ -287,6 +301,7 @@ const FormRow = memo(function FormRow({ require={!isLast && !!pairContainer.pair.enabled && !!pairContainer.pair.value} validate={nameValidate} useTemplating + forceUpdateKey={forceUpdateKey} containerClassName={classnames(isLast && 'border-dashed')} defaultValue={pairContainer.pair.name} label="Name" @@ -301,6 +316,7 @@ const FormRow = memo(function FormRow({ size="sm" containerClassName={classnames(isLast && 'border-dashed')} validate={valueValidate} + forceUpdateKey={forceUpdateKey} defaultValue={pairContainer.pair.value} label="Value" name="value" diff --git a/src-web/hooks/useRequestUpdateKey.ts b/src-web/hooks/useRequestUpdateKey.ts new file mode 100644 index 00000000..0d8b89fa --- /dev/null +++ b/src-web/hooks/useRequestUpdateKey.ts @@ -0,0 +1,14 @@ +import { createGlobalState } from 'react-use'; +import { generateId } from '../lib/generateId'; + +const useGlobalState = createGlobalState>({}); + +export function useRequestUpdateKey(requestId: string | null) { + const [keys, setKeys] = useGlobalState(); + return { + updateKey: `${requestId}::${keys[requestId ?? 'n/a']}`, + wasUpdatedExternally: (changedRequestId: string) => { + setKeys((m) => ({ ...m, [changedRequestId]: generateId() })); + }, + }; +} diff --git a/src-web/hooks/useTauriListeners.ts b/src-web/hooks/useTauriListeners.ts index 807761ec..6ae7888c 100644 --- a/src-web/hooks/useTauriListeners.ts +++ b/src-web/hooks/useTauriListeners.ts @@ -1,23 +1,62 @@ -import { listen } from '@tauri-apps/api/event'; +import { useQueryClient } from '@tanstack/react-query'; +import { appWindow } from '@tauri-apps/api/window'; import { useEffect } from 'react'; +import { debounce } from '../lib/debounce'; +import type { HttpRequest } from '../lib/models'; +import { requestsQueryKey } from './useRequests'; +import { useRequestUpdateKey } from './useRequestUpdateKey'; import { useSidebarDisplay } from './useSidebarDisplay'; const unsubFns: (() => void)[] = []; +const UPDATE_DEBOUNCE_MILLIS = 500; export function useTauriListeners() { const sidebarDisplay = useSidebarDisplay(); + const queryClient = useQueryClient(); + const { wasUpdatedExternally } = useRequestUpdateKey(null); + useEffect(() => { let unmounted = false; - listen('toggle_sidebar', async () => { - sidebarDisplay.toggle(); - }).then((fn) => { - if (unmounted) { - fn(); - } else { - unsubFns.push(fn); - } - }); + appWindow + .listen('toggle_sidebar', async () => { + sidebarDisplay.toggle(); + }) + .then((unsub) => { + if (unmounted) unsub(); + else unsubFns.push(unsub); + }); + + appWindow + .listen( + 'updated_request', + debounce(({ payload: request }: { payload: HttpRequest }) => { + queryClient.setQueryData( + requestsQueryKey(request.workspaceId), + (requests: HttpRequest[] = []) => { + const newRequests = []; + let found = false; + for (const r of requests) { + if (r.id === request.id) { + found = true; + newRequests.push(request); + } else { + newRequests.push(r); + } + } + if (!found) { + newRequests.push(request); + } + setTimeout(() => wasUpdatedExternally(request.id), 50); + return newRequests; + }, + ); + }, UPDATE_DEBOUNCE_MILLIS), + ) + .then((unsub) => { + if (unmounted) unsub(); + else unsubFns.push(unsub); + }); return () => { unmounted = true; diff --git a/src-web/hooks/useUniqueKey.ts b/src-web/hooks/useUniqueKey.ts index baf683c1..92731a75 100644 --- a/src-web/hooks/useUniqueKey.ts +++ b/src-web/hooks/useUniqueKey.ts @@ -2,7 +2,7 @@ import { useState } from 'react'; export function useUniqueKey(len = 10): { key: string; regenerate: () => void } { const [key, setKey] = useState(() => generate(len)); - return { key, regenerate: () => setKey(generate(len)) }; + return { key, wasUpdatedExternally: () => setKey(generate(len)) }; } const CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; diff --git a/src-web/lib/generateId.ts b/src-web/lib/generateId.ts new file mode 100644 index 00000000..d0c629e8 --- /dev/null +++ b/src-web/lib/generateId.ts @@ -0,0 +1,3 @@ +export function generateId(): string { + return Math.random().toString(36).slice(2); +}