Better multi-window updates

This commit is contained in:
Gregory Schier
2023-03-29 21:53:20 -07:00
parent 47481b711e
commit fb7424714a
16 changed files with 180 additions and 136 deletions

22
package-lock.json generated
View File

@@ -39,7 +39,6 @@
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0", "react-helmet-async": "^1.3.0",
"react-hook-form": "^7.43.8",
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"uuid": "^9.0.0" "uuid": "^9.0.0"
@@ -6335,21 +6334,6 @@
"react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -12123,12 +12107,6 @@
"shallowequal": "^1.1.0" "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": { "react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -15,7 +15,9 @@ use std::fs::create_dir_all;
use base64::Engine; use base64::Engine;
use http::header::{HeaderName, ACCEPT, USER_AGENT}; use http::header::{HeaderName, ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderValue, Method}; use http::{HeaderMap, HeaderValue, Method};
use objc::runtime::ivar_getOffset;
use reqwest::redirect::Policy; use reqwest::redirect::Policy;
use serde::Serialize;
use sqlx::migrate::Migrator; use sqlx::migrate::Migrator;
use sqlx::sqlite::SqlitePoolOptions; use sqlx::sqlite::SqlitePoolOptions;
use sqlx::types::Json; use sqlx::types::Json;
@@ -27,8 +29,6 @@ use tokio::sync::Mutex;
use window_ext::WindowExt; use window_ext::WindowExt;
use crate::models::generate_id;
mod models; mod models;
mod runtime; mod runtime;
mod window_ext; mod window_ext;
@@ -101,6 +101,8 @@ async fn actually_send_ephemeral_request(
url_string = format!("http://{}", url_string); url_string = format!("http://{}", url_string);
} }
println!("Sending request to {}", url_string);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.redirect(Policy::none()) .redirect(Policy::none())
.build() .build()
@@ -114,7 +116,7 @@ async fn actually_send_ephemeral_request(
if h.name.is_empty() && h.value.is_empty() { if h.name.is_empty() && h.value.is_empty() {
continue; continue;
} }
if h.enabled == false { if !h.enabled {
continue; continue;
} }
let header_name = match HeaderName::from_bytes(h.name.as_bytes()) { 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) response = models::update_response_if_id(response, window.label(), pool)
.await .await
.expect("Failed to update response"); .expect("Failed to update response");
window emit_all_others(&window, "updated_response", &response);
.app_handle()
.emit_all("updated_response", &response)
.unwrap();
Ok(response) Ok(response)
} }
Err(e) => response_err(response, e.to_string(), window, pool).await, 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) models::create_response(&req.id, 0, "", 0, None, "", vec![], window.label(), pool)
.await .await
.expect("Failed to create response"); .expect("Failed to create response");
window emit_all_others(&window, "updated_response", &response);
.app_handle()
.emit_all("updated_response", &response)
.unwrap();
actually_send_ephemeral_request(req, response, window, pool).await?; actually_send_ephemeral_request(req, response, window, pool).await?;
Ok(()) Ok(())
@@ -259,10 +255,7 @@ async fn response_err(
response = models::update_response_if_id(response, window.label(), pool) response = models::update_response_if_id(response, window.label(), pool)
.await .await
.expect("Failed to update response"); .expect("Failed to update response");
window emit_all_others(&window, "updated_response", &response);
.app_handle()
.emit_all("updated_response", &response)
.unwrap();
Ok(response) Ok(response)
} }
@@ -282,7 +275,7 @@ async fn set_key_value(
namespace: &str, namespace: &str,
key: &str, key: &str,
value: &str, value: &str,
app_handle: AppHandle<Wry>, window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> { ) -> Result<(), String> {
let pool = &*db_instance.lock().await; let pool = &*db_instance.lock().await;
@@ -290,9 +283,7 @@ async fn set_key_value(
.await .await
.expect("Failed to create key value"); .expect("Failed to create key value");
app_handle emit_all_others(&window, "updated_key_value", &created_key_value);
.emit_all("updated_key_value", &created_key_value)
.unwrap();
Ok(()) Ok(())
} }
@@ -308,10 +299,7 @@ async fn create_workspace(
.await .await
.expect("Failed to create workspace"); .expect("Failed to create workspace");
window emit_all_others(&window, "updated_workspace", &created_workspace);
.app_handle()
.emit_all("updated_workspace", &created_workspace)
.unwrap();
Ok(created_workspace.id) Ok(created_workspace.id)
} }
@@ -344,10 +332,7 @@ async fn create_request(
.await .await
.expect("Failed to create request"); .expect("Failed to create request");
window emit_all_others(&window, "updated_request", &created_request);
.app_handle()
.emit_all("updated_request", &created_request)
.unwrap();
Ok(created_request.id) Ok(created_request.id)
} }
@@ -362,10 +347,7 @@ async fn duplicate_request(
let request = models::duplicate_request(id, window.label(), pool) let request = models::duplicate_request(id, window.label(), pool)
.await .await
.expect("Failed to duplicate request"); .expect("Failed to duplicate request");
window emit_all_others(&window, "updated_request", &request);
.app_handle()
.emit_all("updated_request", &request)
.unwrap();
Ok(request.id) Ok(request.id)
} }
@@ -406,17 +388,14 @@ async fn update_request(
.await .await
.expect("Failed to update request"); .expect("Failed to update request");
window emit_all_others(&window, "updated_request", updated_request);
.app_handle()
.emit_all("updated_request", updated_request)
.unwrap();
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
async fn delete_request( async fn delete_request(
app_handle: AppHandle<Wry>, window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str, request_id: &str,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -424,7 +403,7 @@ async fn delete_request(
let req = models::delete_request(request_id, pool) let req = models::delete_request(request_id, pool)
.await .await
.expect("Failed to delete request"); .expect("Failed to delete request");
app_handle.emit_all("deleted_model", req).unwrap(); emit_all_others(&window, "deleted_model", req);
Ok(()) Ok(())
} }
@@ -464,14 +443,14 @@ async fn responses(
#[tauri::command] #[tauri::command]
async fn delete_response( async fn delete_response(
id: &str, id: &str,
app_handle: AppHandle<Wry>, window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> { ) -> Result<(), String> {
let pool = &*db_instance.lock().await; let pool = &*db_instance.lock().await;
let response = models::delete_response(id, pool) let response = models::delete_response(id, pool)
.await .await
.expect("Failed to delete response"); .expect("Failed to delete response");
app_handle.emit_all("deleted_model", response).unwrap(); emit_all_others(&window, "deleted_model", response);
Ok(()) Ok(())
} }
@@ -512,7 +491,7 @@ async fn workspaces(
#[tauri::command] #[tauri::command]
async fn delete_workspace( async fn delete_workspace(
app_handle: AppHandle<Wry>, window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
id: &str, id: &str,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -520,7 +499,7 @@ async fn delete_workspace(
let workspace = models::delete_workspace(id, pool) let workspace = models::delete_workspace(id, pool)
.await .await
.expect("Failed to delete workspace"); .expect("Failed to delete workspace");
app_handle.emit_all("deleted_model", workspace).unwrap(); emit_all_others(&window, "deleted_model", workspace);
Ok(()) Ok(())
} }
@@ -715,7 +694,7 @@ async fn get_or_create_client_id(pool: &Pool<Sqlite>) -> String {
match models::get_key_value("global", "client_id", pool).await { match models::get_key_value("global", "client_id", pool).await {
Some(kv) => kv.value, Some(kv) => kv.value,
None => { None => {
let id = &generate_id("yaak"); let id = &models::generate_id("yaak");
models::set_key_value("global", "client_id", id, pool) models::set_key_value("global", "client_id", id, pool)
.await .await
.expect("Failed to set client id") .expect("Failed to set client id")
@@ -723,3 +702,14 @@ async fn get_or_create_client_id(pool: &Pool<Sqlite>) -> String {
} }
} }
} }
/// Emit an event to all windows except the current one
fn emit_all_others<S: Serialize + Clone>(current_window: &Window<Wry>, 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();
}
}

View File

@@ -51,33 +51,6 @@ await listen(
}, UPDATE_DEBOUNCE_MILLIS), }, 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 }) => { await listen('updated_response', ({ payload: response }: { payload: HttpResponse }) => {
queryClient.setQueryData( queryClient.setQueryData(
responsesQueryKey(response.requestId), responsesQueryKey(response.requestId),

View File

@@ -1,6 +1,5 @@
import type { Extension } from '@codemirror/state'; import type { Extension } from '@codemirror/state';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useUniqueKey } from '../hooks/useUniqueKey';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import { sendEphemeralRequest } from '../lib/sendEphemeralRequest'; import { sendEphemeralRequest } from '../lib/sendEphemeralRequest';
import type { EditorProps } from './core/Editor'; import type { EditorProps } from './core/Editor';
@@ -13,7 +12,10 @@ import {
} from './core/Editor'; } from './core/Editor';
import { Separator } from './core/Separator'; import { Separator } from './core/Separator';
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'defaultValue' | 'className'> & { type Props = Pick<
EditorProps,
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'forceUpdateKey'
> & {
baseRequest: HttpRequest; baseRequest: HttpRequest;
}; };
@@ -24,7 +26,6 @@ interface GraphQLBody {
} }
export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) { export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) {
const queryKey = useUniqueKey();
const { query, variables } = useMemo<GraphQLBody>(() => { const { query, variables } = useMemo<GraphQLBody>(() => {
if (!defaultValue) { if (!defaultValue) {
return { query: '', variables: {} }; return { query: '', variables: {} };
@@ -79,7 +80,6 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
return ( return (
<div className="pb-2 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]"> <div className="pb-2 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor <Editor
key={queryKey.key}
heightMode="auto" heightMode="auto"
defaultValue={query ?? ''} defaultValue={query ?? ''}
languageExtension={graphqlExtension} languageExtension={graphqlExtension}

View File

@@ -9,15 +9,17 @@ import type { PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor'; import { PairEditor } from './core/PairEditor';
type Props = { type Props = {
forceUpdateKey: string;
headers: HttpRequest['headers']; headers: HttpRequest['headers'];
onChange: (headers: HttpRequest['headers']) => void; onChange: (headers: HttpRequest['headers']) => void;
}; };
export function HeaderEditor({ headers, onChange }: Props) { export function HeaderEditor({ headers, onChange, forceUpdateKey }: Props) {
return ( return (
<PairEditor <PairEditor
pairs={headers} pairs={headers}
onChange={onChange} onChange={onChange}
forceUpdateKey={forceUpdateKey}
nameValidate={validateHttpHeader} nameValidate={validateHttpHeader}
nameAutocomplete={nameAutocomplete} nameAutocomplete={nameAutocomplete}
valueAutocomplete={valueAutocomplete} valueAutocomplete={valueAutocomplete}

View File

@@ -2,10 +2,18 @@ import type { HttpRequest } from '../lib/models';
import { PairEditor } from './core/PairEditor'; import { PairEditor } from './core/PairEditor';
type Props = { type Props = {
forceUpdateKey: string;
parameters: { name: string; value: string }[]; parameters: { name: string; value: string }[];
onChange: (headers: HttpRequest['headers']) => void; onChange: (headers: HttpRequest['headers']) => void;
}; };
export function ParametersEditor({ parameters, onChange }: Props) { export function ParametersEditor({ parameters, forceUpdateKey, onChange }: Props) {
return <PairEditor pairs={parameters} onChange={onChange} namePlaceholder="name" />; return (
<PairEditor
forceUpdateKey={forceUpdateKey}
pairs={parameters}
onChange={onChange}
namePlaceholder="name"
/>
);
} }

View File

@@ -3,8 +3,8 @@ import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from '../hooks/useActiveRequest';
import { useKeyValue } from '../hooks/useKeyValue'; import { useKeyValue } from '../hooks/useKeyValue';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useUpdateRequest } from '../hooks/useUpdateRequest'; import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { useWindowFocus } from '../hooks/useWindowFocus';
import { tryFormatJson } from '../lib/formatters'; import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest } from '../lib/models'; import type { HttpHeader, HttpRequest } from '../lib/models';
import { import {
@@ -125,13 +125,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
[], [],
); );
const visible = useWindowFocus(); const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest?.id ?? null);
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]);
return ( return (
<div <div
@@ -140,12 +134,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
> >
{activeRequest && ( {activeRequest && (
<> <>
<UrlBar <UrlBar id={activeRequest.id} url={activeRequest.url} method={activeRequest.method} />
key={multiWindowKey}
id={activeRequest.id}
url={activeRequest.url}
method={activeRequest.method}
/>
<Tabs <Tabs
value={activeTab.value} value={activeTab.value}
onChangeValue={activeTab.set} onChangeValue={activeTab.set}
@@ -156,13 +145,13 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
<TabContent value="auth"> <TabContent value="auth">
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? ( {activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
<BasicAuth <BasicAuth
key={multiWindowKey} key={forceUpdateKey}
requestId={activeRequest.id} requestId={activeRequest.id}
authentication={activeRequest.authentication} authentication={activeRequest.authentication}
/> />
) : activeRequest.authenticationType === AUTH_TYPE_BEARER ? ( ) : activeRequest.authenticationType === AUTH_TYPE_BEARER ? (
<BearerAuth <BearerAuth
key={multiWindowKey} key={forceUpdateKey}
requestId={activeRequest.id} requestId={activeRequest.id}
authentication={activeRequest.authentication} authentication={activeRequest.authentication}
/> />
@@ -174,18 +163,22 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
</TabContent> </TabContent>
<TabContent value="headers"> <TabContent value="headers">
<HeaderEditor <HeaderEditor
key={`${forceUpdateHeaderEditorKey}::${multiWindowKey}`} forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers} headers={activeRequest.headers}
onChange={handleHeadersChange} onChange={handleHeadersChange}
/> />
</TabContent> </TabContent>
<TabContent value="params"> <TabContent value="params">
<ParametersEditor key={multiWindowKey} parameters={[]} onChange={() => null} /> <ParametersEditor
forceUpdateKey={forceUpdateKey}
parameters={[]}
onChange={() => null}
/>
</TabContent> </TabContent>
<TabContent value="body" className="pl-3 mt-1"> <TabContent value="body" className="pl-3 mt-1">
{activeRequest.bodyType === BODY_TYPE_JSON ? ( {activeRequest.bodyType === BODY_TYPE_JSON ? (
<Editor <Editor
key={multiWindowKey} forceUpdateKey={forceUpdateKey}
useTemplating useTemplating
placeholder="..." placeholder="..."
className="!bg-gray-50" className="!bg-gray-50"
@@ -197,7 +190,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
/> />
) : activeRequest.bodyType === BODY_TYPE_XML ? ( ) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor <Editor
key={multiWindowKey} forceUpdateKey={forceUpdateKey}
useTemplating useTemplating
placeholder="..." placeholder="..."
className="!bg-gray-50" className="!bg-gray-50"
@@ -208,7 +201,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
/> />
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? ( ) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<GraphQLEditor <GraphQLEditor
key={multiWindowKey} forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest} baseRequest={activeRequest}
className="!bg-gray-50" className="!bg-gray-50"
defaultValue={activeRequest?.body ?? ''} defaultValue={activeRequest?.body ?? ''}

View File

@@ -122,7 +122,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
) : viewMode === 'pretty' && contentType.includes('json') ? ( ) : viewMode === 'pretty' && contentType.includes('json') ? (
<Editor <Editor
readOnly readOnly
key={`${contentType}:${activeResponse.updatedAt}:pretty`} forceUpdateKey={activeResponse.updatedAt}
className="bg-gray-50 dark:!bg-gray-100" className="bg-gray-50 dark:!bg-gray-100"
defaultValue={tryFormatJson(activeResponse?.body)} defaultValue={tryFormatJson(activeResponse?.body)}
contentType={contentType} contentType={contentType}
@@ -130,7 +130,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
) : activeResponse?.body ? ( ) : activeResponse?.body ? (
<Editor <Editor
readOnly readOnly
key={`${contentType}:${activeResponse.updatedAt}`} forceUpdateKey={activeResponse.updatedAt}
className="bg-gray-50 dark:!bg-gray-100" className="bg-gray-50 dark:!bg-gray-100"
defaultValue={activeResponse?.body} defaultValue={activeResponse?.body}
contentType={contentType} contentType={contentType}

View File

@@ -2,6 +2,7 @@ import classnames from 'classnames';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading'; import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest'; import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest'; import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
@@ -19,6 +20,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
const handleMethodChange = useCallback((method: string) => updateRequest.mutate({ method }), []); const handleMethodChange = useCallback((method: string) => updateRequest.mutate({ method }), []);
const handleUrlChange = useCallback((url: string) => updateRequest.mutate({ url }), []); const handleUrlChange = useCallback((url: string) => updateRequest.mutate({ url }), []);
const loading = useIsResponseLoading(requestId); const loading = useIsResponseLoading(requestId);
const { updateKey } = useRequestUpdateKey(requestId);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (e: FormEvent) => { async (e: FormEvent) => {
@@ -32,13 +34,13 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
<form onSubmit={handleSubmit} className={classnames('url-bar', className)}> <form onSubmit={handleSubmit} className={classnames('url-bar', className)}>
<Input <Input
size="sm" size="sm"
key={requestId}
hideLabel hideLabel
useTemplating useTemplating
contentType="url" contentType="url"
className="px-0" className="px-0"
name="url" name="url"
label="Enter URL" label="Enter URL"
forceUpdateKey={updateKey}
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50" containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
onChange={handleUrlChange} onChange={handleUrlChange}
defaultValue={url} defaultValue={url}

View File

@@ -1,6 +1,7 @@
import { defaultKeymap } from '@codemirror/commands'; import { defaultKeymap } from '@codemirror/commands';
import type { Extension } from '@codemirror/state'; import type { Extension } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state'; import { Compartment, EditorState, Transaction } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view'; import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames'; import classnames from 'classnames';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
@@ -19,6 +20,7 @@ export { formatSdl } from 'format-graphql';
export interface EditorProps { export interface EditorProps {
id?: string; id?: string;
forceUpdateKey?: string;
readOnly?: boolean; readOnly?: boolean;
type?: 'text' | 'password'; type?: 'text' | 'password';
className?: string; className?: string;
@@ -42,6 +44,7 @@ export function Editor({
type = 'text', type = 'text',
heightMode, heightMode,
contentType, contentType,
forceUpdateKey,
autoFocus, autoFocus,
placeholder, placeholder,
useTemplating, useTemplating,
@@ -87,6 +90,15 @@ export function Editor({
view.dispatch({ effects: languageCompartment.reconfigure(ext) }); view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete]); }, [contentType, autocomplete]);
useEffect(() => {
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 // Initialize the editor when ref mounts
useEffect(() => { useEffect(() => {
if (wrapperRef.current === null || cm.current !== null) return; if (wrapperRef.current === null || cm.current !== null) return;
@@ -218,13 +230,27 @@ function getExtensions({
// Handle onChange // Handle onChange
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (onChange && update.docChanged) { if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) {
onChange.current?.(update.state.doc.toString()); 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 = ({ const syncGutterBg = ({
parent, parent,
className = '', className = '',

View File

@@ -7,7 +7,7 @@ import { IconButton } from './IconButton';
import { HStack, VStack } from './Stacks'; import { HStack, VStack } from './Stacks';
export type InputProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'onFocus'> & export type InputProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'onFocus'> &
Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'> & { Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete' | 'forceUpdateKey'> & {
name: string; name: string;
type?: 'text' | 'password'; type?: 'text' | 'password';
label: string; label: string;

View File

@@ -14,6 +14,7 @@ import { Input } from './Input';
export type PairEditorProps = { export type PairEditorProps = {
pairs: Pair[]; pairs: Pair[];
onChange: (pairs: Pair[]) => void; onChange: (pairs: Pair[]) => void;
forceUpdateKey?: string;
className?: string; className?: string;
namePlaceholder?: string; namePlaceholder?: string;
valuePlaceholder?: string; valuePlaceholder?: string;
@@ -36,6 +37,7 @@ type PairContainer = {
export const PairEditor = memo(function PairEditor({ export const PairEditor = memo(function PairEditor({
pairs: originalPairs, pairs: originalPairs,
forceUpdateKey,
nameAutocomplete, nameAutocomplete,
valueAutocomplete, valueAutocomplete,
namePlaceholder, namePlaceholder,
@@ -53,6 +55,15 @@ export const PairEditor = memo(function PairEditor({
return [...pairs, newPairContainer()]; 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( const setPairsAndSave = useCallback(
(fn: (pairs: PairContainer[]) => PairContainer[]) => { (fn: (pairs: PairContainer[]) => PairContainer[]) => {
setPairs((oldPairs) => { setPairs((oldPairs) => {
@@ -139,6 +150,7 @@ export const PairEditor = memo(function PairEditor({
pairContainer={p} pairContainer={p}
className="py-1" className="py-1"
isLast={isLast} isLast={isLast}
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete} nameAutocomplete={nameAutocomplete}
valueAutocomplete={valueAutocomplete} valueAutocomplete={valueAutocomplete}
namePlaceholder={namePlaceholder} namePlaceholder={namePlaceholder}
@@ -179,6 +191,7 @@ type FormRowProps = {
| 'valuePlaceholder' | 'valuePlaceholder'
| 'nameValidate' | 'nameValidate'
| 'valueValidate' | 'valueValidate'
| 'forceUpdateKey'
>; >;
const FormRow = memo(function FormRow({ const FormRow = memo(function FormRow({
@@ -190,6 +203,7 @@ const FormRow = memo(function FormRow({
onMove, onMove,
onEnd, onEnd,
isLast, isLast,
forceUpdateKey,
nameAutocomplete, nameAutocomplete,
valueAutocomplete, valueAutocomplete,
namePlaceholder, namePlaceholder,
@@ -287,6 +301,7 @@ const FormRow = memo(function FormRow({
require={!isLast && !!pairContainer.pair.enabled && !!pairContainer.pair.value} require={!isLast && !!pairContainer.pair.enabled && !!pairContainer.pair.value}
validate={nameValidate} validate={nameValidate}
useTemplating useTemplating
forceUpdateKey={forceUpdateKey}
containerClassName={classnames(isLast && 'border-dashed')} containerClassName={classnames(isLast && 'border-dashed')}
defaultValue={pairContainer.pair.name} defaultValue={pairContainer.pair.name}
label="Name" label="Name"
@@ -301,6 +316,7 @@ const FormRow = memo(function FormRow({
size="sm" size="sm"
containerClassName={classnames(isLast && 'border-dashed')} containerClassName={classnames(isLast && 'border-dashed')}
validate={valueValidate} validate={valueValidate}
forceUpdateKey={forceUpdateKey}
defaultValue={pairContainer.pair.value} defaultValue={pairContainer.pair.value}
label="Value" label="Value"
name="value" name="value"

View File

@@ -0,0 +1,14 @@
import { createGlobalState } from 'react-use';
import { generateId } from '../lib/generateId';
const useGlobalState = createGlobalState<Record<string, string>>({});
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() }));
},
};
}

View File

@@ -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 { 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'; import { useSidebarDisplay } from './useSidebarDisplay';
const unsubFns: (() => void)[] = []; const unsubFns: (() => void)[] = [];
const UPDATE_DEBOUNCE_MILLIS = 500;
export function useTauriListeners() { export function useTauriListeners() {
const sidebarDisplay = useSidebarDisplay(); const sidebarDisplay = useSidebarDisplay();
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);
useEffect(() => { useEffect(() => {
let unmounted = false; let unmounted = false;
listen('toggle_sidebar', async () => { appWindow
sidebarDisplay.toggle(); .listen('toggle_sidebar', async () => {
}).then((fn) => { sidebarDisplay.toggle();
if (unmounted) { })
fn(); .then((unsub) => {
} else { if (unmounted) unsub();
unsubFns.push(fn); 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 () => { return () => {
unmounted = true; unmounted = true;

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
export function useUniqueKey(len = 10): { key: string; regenerate: () => void } { export function useUniqueKey(len = 10): { key: string; regenerate: () => void } {
const [key, setKey] = useState<string>(() => generate(len)); const [key, setKey] = useState<string>(() => generate(len));
return { key, regenerate: () => setKey(generate(len)) }; return { key, wasUpdatedExternally: () => setKey(generate(len)) };
} }
const CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';

View File

@@ -0,0 +1,3 @@
export function generateId(): string {
return Math.random().toString(36).slice(2);
}