From b0d8908724d0dd4c6097c010605aedfee3cdcc87 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 1 Apr 2023 21:39:46 -0700 Subject: [PATCH] Refactor debounce and tauri event listeners --- src-tauri/src/main.rs | 6 +- src-web/components/RequestPane.tsx | 13 +++ src-web/components/ResponsePane.tsx | 2 +- src-web/components/UrlBar.tsx | 10 +- src-web/components/Workspace.tsx | 5 +- src-web/components/core/Input.tsx | 47 ++++---- src-web/hooks/useDebouncedSetState.ts | 12 ++ src-web/hooks/useDebouncedValue.ts | 15 +-- src-web/hooks/useTauriEvent.ts | 22 ++++ src-web/hooks/useTauriListeners.ts | 157 +++++++++----------------- src-web/lib/debounce.ts | 2 +- 11 files changed, 154 insertions(+), 137 deletions(-) create mode 100644 src-web/hooks/useDebouncedSetState.ts create mode 100644 src-web/hooks/useTauriEvent.ts diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4cdf87e0..61224ae8 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -605,6 +605,9 @@ fn create_window(handle: &AppHandle) -> Window { CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar") .accelerator("CmdOrCtrl+b"), ) + .add_item( + CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"), + ) .add_item(CustomMenuItem::new("new_window".to_string(), "New Window")); if is_dev() { test_menu = test_menu @@ -647,8 +650,9 @@ fn create_window(handle: &AppHandle) -> Window { "zoom_in" => win2.emit("zoom", 1).unwrap(), "zoom_out" => win2.emit("zoom", -1).unwrap(), "toggle_sidebar" => win2.emit("toggle_sidebar", true).unwrap(), - "refresh" => win2.emit("refresh", true).unwrap(), + "focus_url" => win2.emit("focus_url", true).unwrap(), "send_request" => win2.emit("send_request", true).unwrap(), + "refresh" => win2.eval("location.reload()").unwrap(), "new_window" => _ = create_window(&handle2), "toggle_devtools" => { if win2.is_devtools_open() { diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index 74a676a5..eb5b9c52 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -1,9 +1,12 @@ +import { invoke } from '@tauri-apps/api'; +import { appWindow } from '@tauri-apps/api/window'; import classnames from 'classnames'; import type { CSSProperties } from 'react'; import { memo, useCallback, useMemo, useState } from 'react'; import { createGlobalState } from 'react-use'; import { useActiveRequest } from '../hooks/useActiveRequest'; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; +import { useTauriEvent } from '../hooks/useTauriEvent'; import { useUpdateRequest } from '../hooks/useUpdateRequest'; import { tryFormatJson } from '../lib/formatters'; import type { HttpHeader, HttpRequest } from '../lib/models'; @@ -128,6 +131,16 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN [updateRequest], ); + useTauriEvent( + 'send_request', + async ({ windowLabel }) => { + if (windowLabel !== appWindow.label) return; + console.log('SEND REQUEST', activeRequest?.url); + await invoke('send_request', { requestId: activeRequestId }); + }, + [activeRequestId], + ); + return (
& { }; export const UrlBar = memo(function UrlBar({ id: requestId, url, method, className }: Props) { + const inputRef = useRef(null); const sendRequest = useSendRequest(requestId); const updateRequest = useUpdateRequest(requestId); const handleMethodChange = useCallback( @@ -36,9 +39,14 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa [sendRequest], ); + useTauriEvent('focus_url', () => { + inputRef.current?.focus(); + }); + return (
(false); @@ -36,6 +37,8 @@ export default function Workspace() { null, ); + useTauriEvent('toggle_sidebar', toggle); + // float/un-float sidebar on window resize useEffect(() => { const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH; diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx index ee91814a..ba79d27b 100644 --- a/src-web/components/core/Input.tsx +++ b/src-web/components/core/Input.tsx @@ -1,6 +1,7 @@ import classnames from 'classnames'; +import type { EditorView } from 'codemirror'; import type { HTMLAttributes, ReactNode } from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { forwardRef, useCallback, useMemo, useState } from 'react'; import type { EditorProps } from './Editor'; import { Editor } from './Editor'; import { IconButton } from './IconButton'; @@ -27,25 +28,28 @@ export type InputProps = Omit, 'onChange' | 'on require?: boolean; }; -export function Input({ - label, - type = 'text', - hideLabel, - className, - containerClassName, - labelClassName, - onChange, - placeholder, - size = 'md', - name, - leftSlot, - rightSlot, - defaultValue, - validate, - require, - forceUpdateKey, - ...props -}: InputProps) { +export const Input = forwardRef(function Input( + { + label, + type = 'text', + hideLabel, + className, + containerClassName, + labelClassName, + onChange, + placeholder, + size = 'md', + name, + leftSlot, + rightSlot, + defaultValue, + validate, + require, + forceUpdateKey, + ...props + }: InputProps, + ref, +) { const [obscured, setObscured] = useState(type === 'password'); const [currentValue, setCurrentValue] = useState(defaultValue ?? ''); const id = `input-${name}`; @@ -96,6 +100,7 @@ export function Input({ > {leftSlot} ); -} +}); function validateRequire(v: string) { return v.length > 0; diff --git a/src-web/hooks/useDebouncedSetState.ts b/src-web/hooks/useDebouncedSetState.ts new file mode 100644 index 00000000..89e436f5 --- /dev/null +++ b/src-web/hooks/useDebouncedSetState.ts @@ -0,0 +1,12 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { useMemo, useState } from 'react'; +import { debounce } from '../lib/debounce'; + +export function useDebouncedSetState( + defaultValue: T, + delay?: number, +): [T, Dispatch>] { + const [state, setState] = useState(defaultValue); + const debouncedSetState = useMemo(() => debounce(setState, delay), [delay]); + return [state, debouncedSetState]; +} diff --git a/src-web/hooks/useDebouncedValue.ts b/src-web/hooks/useDebouncedValue.ts index ed26c278..83aa1ace 100644 --- a/src-web/hooks/useDebouncedValue.ts +++ b/src-web/hooks/useDebouncedValue.ts @@ -1,13 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; - -export function useDebouncedValue(value: T, delay = 1000) { - const [state, setState] = useState(value); - const timeout = useRef(); - - useEffect(() => { - clearTimeout(timeout.current ?? 0); - timeout.current = setTimeout(() => setState(value), delay); - }, [value, delay]); +import { useEffect } from 'react'; +import { useDebouncedSetState } from './useDebouncedSetState'; +export function useDebouncedValue(value: T, delay?: number) { + const [state, setState] = useDebouncedSetState(value, delay); + useEffect(() => setState(value), [setState, value]); return state; } diff --git a/src-web/hooks/useTauriEvent.ts b/src-web/hooks/useTauriEvent.ts new file mode 100644 index 00000000..386064b1 --- /dev/null +++ b/src-web/hooks/useTauriEvent.ts @@ -0,0 +1,22 @@ +import type { EventCallback } from '@tauri-apps/api/event'; +import { listen as tauriListen } from '@tauri-apps/api/event'; +import type { DependencyList } from 'react'; +import { useEffect } from 'react'; + +export function useTauriEvent(event: string, fn: EventCallback, deps: DependencyList = []) { + useEffect(() => { + let unMounted = false; + let unsubFn: (() => void) | undefined = undefined; + + tauriListen(event, fn).then((unsub) => { + if (unMounted) unsub(); + else unsubFn = unsub; + }); + + return () => { + unMounted = true; + unsubFn?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [event, fn, ...deps]); +} diff --git a/src-web/hooks/useTauriListeners.ts b/src-web/hooks/useTauriListeners.ts index 4c01a4b2..6870ad32 100644 --- a/src-web/hooks/useTauriListeners.ts +++ b/src-web/hooks/useTauriListeners.ts @@ -1,10 +1,5 @@ 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 { useEffect } from 'react'; -import { matchPath } from 'react-router-dom'; import { DEFAULT_FONT_SIZE } from '../lib/constants'; import { debounce } from '../lib/debounce'; import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore'; @@ -14,65 +9,44 @@ import { keyValueQueryKey } from './useKeyValue'; import { requestsQueryKey } from './useRequests'; import { useRequestUpdateKey } from './useRequestUpdateKey'; import { responsesQueryKey } from './useResponses'; -import { routePaths } from './useRoutes'; -import { useSidebarHidden } from './useSidebarHidden'; +import { useTauriEvent } from './useTauriEvent'; import { workspacesQueryKey } from './useWorkspaces'; -const unsubFns: (() => void)[] = []; -export const UPDATE_DEBOUNCE_MILLIS = 100; - export function useTauriListeners() { - const { toggle } = useSidebarHidden(); const queryClient = useQueryClient(); const { wasUpdatedExternally } = useRequestUpdateKey(null); - useEffect(() => { - let unmounted = false; + useTauriEvent('created_model', ({ payload, windowLabel }) => { + if (windowLabel === appWindow.label && payload.model !== 'http_response') return; - // eslint-disable-next-line @typescript-eslint/ban-types - function listen(event: string, fn: EventCallback) { - tauriListen(event, fn).then((unsub) => { - if (unmounted) unsub(); - else unsubFns.push(unsub); - }); + const queryKey = + payload.model === 'http_request' + ? requestsQueryKey(payload) + : payload.model === 'http_response' + ? responsesQueryKey(payload) + : payload.model === 'workspace' + ? workspacesQueryKey(payload) + : payload.model === 'key_value' + ? keyValueQueryKey(payload) + : null; + + if (queryKey === null) { + if (payload.model) { + console.log('Unrecognized created model:', payload); + } + return; } - function listenDebounced(event: string, fn: EventCallback) { - listen(event, debounce(fn, UPDATE_DEBOUNCE_MILLIS)); + const skipSync = payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC; + + if (!skipSync) { + queryClient.setQueryData(queryKey, (values) => [...(values ?? []), payload]); } + }); - listen('toggle_sidebar', toggle); - listen('refresh', () => location.reload()); - - listenDebounced('created_model', ({ payload, windowLabel }) => { - if (windowLabel === appWindow.label && payload.model !== 'http_response') return; - - const queryKey = - payload.model === 'http_request' - ? requestsQueryKey(payload) - : payload.model === 'http_response' - ? responsesQueryKey(payload) - : payload.model === 'workspace' - ? workspacesQueryKey(payload) - : payload.model === 'key_value' - ? keyValueQueryKey(payload) - : null; - - if (queryKey === null) { - if (payload.model) { - console.log('Unrecognized created model:', payload); - } - return; - } - - const skipSync = payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC; - - if (!skipSync) { - queryClient.setQueryData(queryKey, (values) => [...(values ?? []), payload]); - } - }); - - listenDebounced('updated_model', ({ payload, windowLabel }) => { + useTauriEvent( + 'updated_model', + debounce(({ payload, windowLabel }) => { if (windowLabel === appWindow.label && payload.model !== 'http_response') return; const queryKey = @@ -104,57 +78,38 @@ export function useTauriListeners() { values?.map((v) => (modelsEq(v, payload) ? payload : v)), ); } - }); + }, 500), + ); - listen('deleted_model', ({ payload }) => { - function removeById(model: T) { - return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id); - } + useTauriEvent('deleted_model', ({ payload }) => { + function removeById(model: T) { + return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id); + } - 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); - } - }); + 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); + } + }); - // TODO: Just call this from the backend instead of this way - listen('send_request', async ({ windowLabel }) => { - if (windowLabel !== appWindow.label) return; + useTauriEvent('zoom', ({ payload: zoomDelta, windowLabel }) => { + if (windowLabel !== appWindow.label) return; + const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize); - const params = matchPath(routePaths.request(), window.location.pathname); - const requestId = params?.params.requestId; - if (typeof requestId !== 'string') { - return; - } - await invoke('send_request', { requestId }); - }); + let newFontSize; + if (zoomDelta === 0) { + newFontSize = DEFAULT_FONT_SIZE; + } else if (zoomDelta > 0) { + newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5); + } else if (zoomDelta < 0) { + newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4); + } - listen('zoom', ({ payload: zoomDelta, windowLabel }) => { - if (windowLabel !== appWindow.label) return; - const fontSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize); - - let newFontSize; - if (zoomDelta === 0) { - newFontSize = DEFAULT_FONT_SIZE; - } else if (zoomDelta > 0) { - newFontSize = Math.min(fontSize * 1.1, DEFAULT_FONT_SIZE * 5); - } else if (zoomDelta < 0) { - newFontSize = Math.max(fontSize * 0.9, DEFAULT_FONT_SIZE * 0.4); - } - - document.documentElement.style.fontSize = `${newFontSize}px`; - }); - - return () => { - unmounted = true; - for (const unsub of unsubFns) { - unsub(); - } - }; - }, [queryClient, toggle, wasUpdatedExternally]); + document.documentElement.style.fontSize = `${newFontSize}px`; + }); } diff --git a/src-web/lib/debounce.ts b/src-web/lib/debounce.ts index 5252734b..59c00615 100644 --- a/src-web/lib/debounce.ts +++ b/src-web/lib/debounce.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function debounce(fn: (...args: any[]) => void, delay: number) { +export function debounce(fn: (...args: any[]) => void, delay = 500) { let timer: ReturnType; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = function (...args: any[]) {