From 168dfb9f6ba07afdf2e3973fdff2a5742528e207 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 21 Mar 2023 23:54:45 -0700 Subject: [PATCH] GraphQL autocomplete and duplicate request --- src-tauri/src/main.rs | 83 +++++++++++++------ src-tauri/src/models.rs | 41 ++++++++- src-web/components/App.tsx | 13 ++- src-web/components/GraphQLEditor.tsx | 33 ++++++-- src-web/components/HeaderEditor.tsx | 10 ++- src-web/components/RequestPane.tsx | 24 ++++-- .../components/RequestSettingsDropdown.tsx | 25 +++--- src-web/components/Sidebar.tsx | 29 ++----- src-web/components/UrlBar.tsx | 2 +- src-web/components/Workspace.tsx | 9 +- src-web/components/core/Dropdown.tsx | 1 + src-web/components/core/Editor/Editor.css | 25 +++++- src-web/components/core/Editor/Editor.tsx | 28 +++---- src-web/components/core/Editor/extensions.ts | 15 +++- .../core/Editor/genericCompletion.ts | 2 +- src-web/components/core/Icon.tsx | 6 +- src-web/components/core/PairEditor.tsx | 2 +- src-web/components/core/Stacks.tsx | 1 + src-web/hooks/useCreateRequest.ts | 8 +- src-web/hooks/useDeleteRequest.ts | 1 + src-web/hooks/useDuplicateRequest.ts | 26 ++++++ src-web/hooks/useRequests.ts | 4 +- src-web/hooks/useResponses.ts | 4 +- src-web/hooks/useRoutes.ts | 1 - src-web/hooks/useSendRequest.ts | 8 +- src-web/hooks/useUpdateRequest.ts | 6 +- src-web/hooks/useWorkspaces.ts | 4 +- src-web/lib/models.ts | 17 ---- src-web/lib/sendEphemeralRequest.ts | 6 ++ src-web/lib/store.ts | 3 +- tailwind.config.cjs | 19 +++-- 31 files changed, 299 insertions(+), 157 deletions(-) create mode 100644 src-web/hooks/useDuplicateRequest.ts create mode 100644 src-web/lib/sendEphemeralRequest.ts diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2198345e..9af47bff 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -58,25 +58,24 @@ async fn migrate_db( } #[tauri::command] -async fn send_request( +async fn send_ephemeral_request( + request: models::HttpRequest, app_handle: AppHandle, db_instance: State<'_, Mutex>>, - request_id: &str, -) -> Result { +) -> Result { let pool = &*db_instance.lock().await; + let response = models::HttpResponse::default(); + return actually_send_ephemeral_request(request, response, app_handle, pool).await; +} - let req = models::get_request(request_id, pool) - .await - .expect("Failed to get request"); - - let mut response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool) - .await - .expect("Failed to create response"); - app_handle.emit_all("updated_response", &response).unwrap(); - +async fn actually_send_ephemeral_request( + request: models::HttpRequest, + mut response: models::HttpResponse, + app_handle: AppHandle, + pool: &Pool, +) -> Result { let start = std::time::Instant::now(); - - let mut url_string = req.url.to_string(); + let mut url_string = request.url.to_string(); let mut variables = HashMap::new(); variables.insert("PROJECT_ID", "project_123"); @@ -108,7 +107,7 @@ async fn send_request( headers.insert(USER_AGENT, HeaderValue::from_static("yaak")); headers.insert(ACCEPT, HeaderValue::from_static("*/*")); - for h in req.headers.0 { + for h in request.headers.0 { if h.name.is_empty() && h.value.is_empty() { continue; } @@ -133,10 +132,10 @@ async fn send_request( } let m = - Method::from_bytes(req.method.to_uppercase().as_bytes()).expect("Failed to create method"); + Method::from_bytes(request.method.to_uppercase().as_bytes()).expect("Failed to create method"); let builder = client.request(m, url_string.to_string()).headers(headers); - let sendable_req_result = match (req.body, req.body_type) { + let sendable_req_result = match (request.body, request.body_type) { (Some(b), Some(_)) => builder.body(b).build(), _ => builder.build(), }; @@ -173,28 +172,49 @@ async fn send_request( response.url = v.url().to_string(); response.body = v.text().await.expect("Failed to get body"); response.elapsed = start.elapsed().as_millis() as i64; - response = models::update_response(response, pool) + response = models::update_response_if_id(response, pool) .await .expect("Failed to update response"); app_handle.emit_all("updated_response", &response).unwrap(); - Ok(response.id) + Ok(response) } Err(e) => response_err(response, e.to_string(), app_handle, pool).await, } } +#[tauri::command] +async fn send_request( + app_handle: AppHandle, + db_instance: State<'_, Mutex>>, + request_id: &str, +) -> Result<(), String> { + let pool = &*db_instance.lock().await; + + let req = models::get_request(request_id, pool) + .await + .expect("Failed to get request"); + + let response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool) + .await + .expect("Failed to create response"); + app_handle.emit_all("updated_response", &response).unwrap(); + + actually_send_ephemeral_request(req, response, app_handle, pool).await?; + Ok(()) +} + async fn response_err( mut response: models::HttpResponse, error: String, app_handle: AppHandle, pool: &Pool, -) -> Result { +) -> Result { response.error = Some(error.clone()); - response = models::update_response(response, pool) + response = models::update_response_if_id(response, pool) .await .expect("Failed to update response"); app_handle.emit_all("updated_response", &response).unwrap(); - Ok(response.id) + Ok(response) } #[tauri::command] @@ -268,6 +288,20 @@ async fn create_request( Ok(created_request.id) } +#[tauri::command] +async fn duplicate_request( + id: &str, + app_handle: AppHandle, + db_instance: State<'_, Mutex>>, +) -> Result { + let pool = &*db_instance.lock().await; + let request = models::duplicate_request(id, pool).await.expect("Failed to duplicate request"); + app_handle + .emit_all("updated_request", &request) + .unwrap(); + Ok(request.id) +} + #[tauri::command] async fn update_request( request: models::HttpRequest, @@ -458,7 +492,6 @@ fn main() { let p = dir.join("db.sqlite"); let p_string = p.to_string_lossy().replace(' ', "%20"); let url = format!("sqlite://{}?mode=rwc", p_string); - println!("DB PATH: {}", p_string); tauri::async_runtime::block_on(async move { let pool = SqlitePoolOptions::new() .connect(url.as_str()) @@ -501,7 +534,7 @@ fn main() { } else { event.window().open_devtools(); } - }, + } _ => {} }; }) @@ -525,6 +558,8 @@ fn main() { get_request, requests, send_request, + send_ephemeral_request, + duplicate_request, create_request, create_workspace, delete_workspace, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 4a08152a..7c8c6227 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -48,7 +48,7 @@ pub struct HttpResponseHeader { pub value: String, } -#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)] +#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct HttpResponse { pub id: String, @@ -180,6 +180,35 @@ pub async fn create_workspace( get_workspace(&id, pool).await } +pub async fn duplicate_request( + id: &str, + pool: &Pool, +) -> Result { + let existing = get_request(id, pool).await.expect("Failed to get request to duplicate"); + // TODO: Figure out how to make this better + let b2; + let body = match existing.body { + Some(b) => { + b2 = b; + Some(b2.as_str()) + } + None => None, + }; + upsert_request( + None, + existing.workspace_id.as_str(), + existing.name.as_str(), + existing.method.as_str(), + body, + existing.body_type, + existing.url.as_str(), + existing.headers.0, + existing.sort_priority, + pool, + ).await +} + + pub async fn upsert_request( id: Option<&str>, workspace_id: &str, @@ -350,6 +379,16 @@ pub async fn create_response( get_response(&id, pool).await } +pub async fn update_response_if_id( + response: HttpResponse, + pool: &Pool, +) -> Result { + if response.id == "" { + return Ok(response); + } + return update_response(response, pool).await; +} + pub async fn update_response( response: HttpResponse, pool: &Pool, diff --git a/src-web/components/App.tsx b/src-web/components/App.tsx index 34a5693b..def7f5d8 100644 --- a/src-web/components/App.tsx +++ b/src-web/components/App.tsx @@ -16,7 +16,6 @@ import { workspacesQueryKey } from '../hooks/useWorkspaces'; import { DEFAULT_FONT_SIZE } from '../lib/constants'; import { extractKeyValue } from '../lib/keyValueStore'; import type { HttpRequest, HttpResponse, KeyValue, Workspace } from '../lib/models'; -import { convertDates } from '../lib/models'; import { AppRouter } from './AppRouter'; const queryClient = new QueryClient({ @@ -52,13 +51,13 @@ await listen('updated_request', ({ payload: request }: { payload: HttpRequest }) for (const r of requests) { if (r.id === request.id) { found = true; - newRequests.push(convertDates(request)); + newRequests.push(request); } else { newRequests.push(r); } } if (!found) { - newRequests.push(convertDates(request)); + newRequests.push(request); } return newRequests; }, @@ -74,13 +73,13 @@ await listen('updated_response', ({ payload: response }: { payload: HttpResponse for (const r of responses) { if (r.id === response.id) { found = true; - newResponses.push(convertDates(response)); + newResponses.push(response); } else { newResponses.push(r); } } if (!found) { - newResponses.push(convertDates(response)); + newResponses.push(response); } return newResponses; }, @@ -94,13 +93,13 @@ await listen('updated_workspace', ({ payload: workspace }: { payload: Workspace for (const w of workspaces) { if (w.id === workspace.id) { found = true; - newWorkspaces.push(convertDates(workspace)); + newWorkspaces.push(workspace); } else { newWorkspaces.push(w); } } if (!found) { - newWorkspaces.push(convertDates(workspace)); + newWorkspaces.push(workspace); } return newWorkspaces; }); diff --git a/src-web/components/GraphQLEditor.tsx b/src-web/components/GraphQLEditor.tsx index ed8c574f..93d70585 100644 --- a/src-web/components/GraphQLEditor.tsx +++ b/src-web/components/GraphQLEditor.tsx @@ -1,11 +1,18 @@ +import type { Extension } from '@codemirror/state'; +import { graphql } from 'cm6-graphql'; import { formatSdl } from 'format-graphql'; -import { useMemo } from 'react'; +import { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities'; +import { useEffect, useMemo, useState } from 'react'; import { useUniqueKey } from '../hooks/useUniqueKey'; -import { Separator } from './core/Separator'; +import type { HttpRequest } from '../lib/models'; +import { sendEphemeralRequest } from '../lib/sendEphemeralRequest'; import type { EditorProps } from './core/Editor'; import { Editor } from './core/Editor'; +import { Separator } from './core/Separator'; -type Props = Pick; +type Props = Pick & { + baseRequest: HttpRequest; +}; interface GraphQLBody { query: string; @@ -13,7 +20,7 @@ interface GraphQLBody { operationName?: string; } -export function GraphQLEditor({ defaultValue, onChange, ...extraEditorProps }: Props) { +export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) { const queryKey = useUniqueKey(); const { query, variables } = useMemo(() => { if (!defaultValue) { @@ -46,12 +53,29 @@ export function GraphQLEditor({ defaultValue, onChange, ...extraEditorProps }: P } }; + const [graphqlExtension, setGraphqlExtension] = useState(); + + useEffect(() => { + const body = JSON.stringify({ + query: getIntrospectionQuery(), + operationName: 'IntrospectionQuery', + }); + const req: HttpRequest = { ...baseRequest, body, id: '' }; + sendEphemeralRequest(req).then((response) => { + console.log('RESPONSE', response.body); + const { data } = JSON.parse(response.body); + const schema = buildClientSchema(data); + setGraphqlExtension(graphql(schema, {})); + }); + }, [baseRequest.url]); + return (
- {/**/}

Variables

= { const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => { const name = headerName.toLowerCase().trim(); const options: GenericCompletionConfig['options'] = - headerOptionsMap[name]?.map((o, i) => ({ + headerOptionsMap[name]?.map((o) => ({ label: o, type: 'constant', - boost: 99 - i, // Max boost is 99 + boost: 1, // Put above other completions })) ?? []; return { minMatch: MIN_MATCH, options }; }; const nameAutocomplete: PairEditorProps['nameAutocomplete'] = { minMatch: MIN_MATCH, - options: headerNames.map((t, i) => ({ label: t, type: 'constant', boost: 99 - i })), + options: headerNames.map((t) => ({ + label: t, + type: 'constant', + boost: 1, // Put above other completions + })), }; const validateHttpHeader = (v: string) => { diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index 85f3d052..d7bf9d16 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -1,5 +1,5 @@ import classnames from 'classnames'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useActiveRequest } from '../hooks/useActiveRequest'; import { useKeyValue } from '../hooks/useKeyValue'; import { useUpdateRequest } from '../hooks/useUpdateRequest'; @@ -23,6 +23,7 @@ export function RequestPane({ fullHeight, className }: Props) { const activeRequest = useActiveRequest(); const activeRequestId = activeRequest?.id ?? null; const updateRequest = useUpdateRequest(activeRequestId); + const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState(0); const activeTab = useKeyValue({ key: ['active_request_body_tab'], defaultValue: 'body', @@ -34,12 +35,24 @@ export function RequestPane({ fullHeight, className }: Props) { value: 'body', label: activeRequest?.bodyType ?? 'No Body', options: { - onChange: (bodyType: HttpRequest['bodyType']) => { + onChange: async (bodyType: HttpRequest['bodyType']) => { const patch: Partial = { bodyType }; if (bodyType == HttpRequestBodyType.GraphQL) { patch.method = 'POST'; + patch.headers = [ + ...(activeRequest?.headers.filter((h) => h.name.toLowerCase() !== 'content-type') ?? + []), + { + name: 'Content-Type', + value: 'application/json', + enabled: true, + }, + ]; + setTimeout(() => { + setForceUpdateHeaderEditorKey((u) => u + 1); + }, 100); } - updateRequest.mutate(patch); + await updateRequest.mutate(patch); }, value: activeRequest?.bodyType ?? null, items: [ @@ -54,7 +67,7 @@ export function RequestPane({ fullHeight, className }: Props) { { value: 'headers', label: 'Headers' }, { value: 'auth', label: 'Auth' }, ], - [activeRequest?.bodyType ?? 'n/a'], + [activeRequest?.bodyType, activeRequest?.headers], ); const handleBodyChange = useCallback((body: string) => updateRequest.mutate({ body }), []); @@ -88,7 +101,7 @@ export function RequestPane({ fullHeight, className }: Props) { @@ -123,6 +136,7 @@ export function RequestPane({ fullHeight, className }: Props) { ) : activeRequest.bodyType === HttpRequestBodyType.GraphQL ? ( >; } -export function RequestSettingsDropdown({ className }: Props) { - const activeRequestId = useActiveRequestId(); - const deleteRequest = useDeleteRequest(activeRequestId ?? null); +export function RequestSettingsDropdown({ requestId, children }: Props) { + const deleteRequest = useDeleteRequest(requestId ?? null); + const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true }); + return ( null, - leftSlot: , + label: 'Duplicate', + onSelect: duplicateRequest.mutate, + leftSlot: , }, - '-----', { - label: 'Delete Request', + label: 'Delete', onSelect: deleteRequest.mutate, leftSlot: , }, ]} > - + {children} ); } diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index e0be37f5..428dc293 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -5,19 +5,17 @@ import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; import { useActiveRequest } from '../hooks/useActiveRequest'; import { useCreateRequest } from '../hooks/useCreateRequest'; -import { useDeleteRequest } from '../hooks/useDeleteRequest'; import { useRequests } from '../hooks/useRequests'; import { useSidebarWidth } from '../hooks/useSidebarWidth'; import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest'; import { useUpdateRequest } from '../hooks/useUpdateRequest'; import type { HttpRequest } from '../lib/models'; import { Button } from './core/Button'; -import { Dropdown } from './core/Dropdown'; -import { Icon } from './core/Icon'; import { IconButton } from './core/IconButton'; import { HStack, VStack } from './core/Stacks'; import { WindowDragRegion } from './core/WindowDragRegion'; import { DropMarker } from './DropMarker'; +import { RequestSettingsDropdown } from './RequestSettingsDropdown'; import { ToggleThemeButton } from './ToggleThemeButton'; interface Props { @@ -204,7 +202,6 @@ const _SidebarItem = forwardRef(function SidebarItem( { className, requestName, requestId, workspaceId, active, sidebarWidth }: SidebarItemProps, ref: ForwardedRef, ) { - const deleteRequest = useDeleteRequest(requestId); const updateRequest = useUpdateRequest(requestId); const [editing, setEditing] = useState(false); @@ -244,17 +241,6 @@ const _SidebarItem = forwardRef(function SidebarItem( [active], ); - const actionItems = useMemo( - () => [ - { - label: 'Delete Request', - onSelect: deleteRequest.mutate, - leftSlot: , - }, - ], - [], - ); - return (
  • )} - + - +
  • ); diff --git a/src-web/components/UrlBar.tsx b/src-web/components/UrlBar.tsx index 3b227afb..5dc18603 100644 --- a/src-web/components/UrlBar.tsx +++ b/src-web/components/UrlBar.tsx @@ -39,7 +39,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa className="px-0" name="url" label="Enter URL" - containerClassName="shadow shadow-gray-100 dark:shadow-gray-0" + containerClassName="shadow shadow-gray-100 dark:shadow-gray-50" onChange={handleUrlChange} defaultValue={url} placeholder="https://example.com" diff --git a/src-web/components/Workspace.tsx b/src-web/components/Workspace.tsx index ac8f928a..da3e300c 100644 --- a/src-web/components/Workspace.tsx +++ b/src-web/components/Workspace.tsx @@ -50,7 +50,14 @@ export default function Workspace() {
    - + + +
    {containerStyles && ( ul { - @apply p-1 max-h-[20rem]; + @apply p-1 max-h-[40vh]; } & > ul > li { @@ -177,5 +185,18 @@ .cm-completionIcon { @apply text-sm flex items-center pb-0.5; } + + + .cm-completionLabel { + } + + .cm-completionDetail { + @apply ml-auto; + } } } + +/* Add default icon. Needs low priority so it can be overwritten */ +.cm-completionIcon::after { + content: '𝑥'; +} diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 3d73a126..1b961943 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -1,11 +1,11 @@ import { defaultKeymap } from '@codemirror/commands'; +import type { Extension } from '@codemirror/state'; import { Compartment, EditorState } from '@codemirror/state'; import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view'; import classnames from 'classnames'; import { EditorView } from 'codemirror'; import type { MutableRefObject } from 'react'; import { useEffect, useMemo, useRef } from 'react'; -import { useUnmount } from 'react-use'; import { IconButton } from '../IconButton'; import './Editor.css'; import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions'; @@ -18,6 +18,7 @@ export interface _EditorProps { className?: string; heightMode?: 'auto' | 'full'; contentType?: string; + languageExtension?: Extension; autoFocus?: boolean; defaultValue?: string; placeholder?: string; @@ -38,6 +39,7 @@ export function _Editor({ placeholder, useTemplating, defaultValue, + languageExtension, onChange, onFocus, className, @@ -48,12 +50,6 @@ export function _Editor({ const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); const wrapperRef = useRef(null); - // Unmount the editor - useUnmount(() => { - cm.current?.view.destroy(); - cm.current = null; - }); - // Use ref so we can update the onChange handler without re-initializing the editor const handleChange = useRef<_EditorProps['onChange']>(onChange); useEffect(() => { @@ -87,9 +83,11 @@ export function _Editor({ // Initialize the editor when ref mounts useEffect(() => { if (wrapperRef.current === null || cm.current !== null) return; + let view: EditorView; try { const languageCompartment = new Compartment(); - const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete }); + const langExt = + languageExtension ?? getLanguageExtension({ contentType, useTemplating, autocomplete }); const state = EditorState.create({ doc: `${defaultValue ?? ''}`, extensions: [ @@ -106,18 +104,18 @@ export function _Editor({ }), ], }); - const view = new EditorView({ state, parent: wrapperRef.current }); + view = new EditorView({ state, parent: wrapperRef.current }); cm.current = { view, languageCompartment }; + syncGutterBg({ parent: wrapperRef.current, className }); if (autoFocus) view.focus(); } catch (e) { console.log('Failed to initialize Codemirror', e); } - }, [wrapperRef.current]); - - useEffect(() => { - if (wrapperRef.current === null) return; - syncGutterBg({ parent: wrapperRef.current, className }); - }, [className]); + return () => { + view.destroy(); + cm.current = null; + }; + }, [wrapperRef.current, languageExtension]); const cmContainer = useMemo( () => ( diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index dc77c959..ef6338aa 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -32,7 +32,8 @@ import { rectangularSelection, } from '@codemirror/view'; import { tags as t } from '@lezer/highlight'; -import { graphqlLanguageSupport } from 'cm6-graphql'; +import { graphql, graphqlLanguageSupport } from 'cm6-graphql'; +import { render } from 'react-dom'; import type { EditorProps } from './index'; import { text } from './text/extension'; import { twig } from './twig/extension'; @@ -97,6 +98,9 @@ export function getLanguageExtension({ useTemplating = false, autocomplete, }: Pick) { + if (contentType === 'application/graphql') { + return graphql(); + } const justContentType = contentType?.split(';')[0] ?? contentType ?? ''; const base = syntaxExtensions[justContentType] ?? text(); if (!useTemplating) { @@ -115,7 +119,14 @@ export const baseExtensions = [ // TODO: Figure out how to debounce showing of autocomplete in a good way // debouncedAutocompletionDisplay({ millis: 1000 }), // autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }), - autocompletion({ closeOnBlur: true, interactionDelay: 200 }), + autocompletion({ + // closeOnBlur: false, + interactionDelay: 200, + compareCompletions: (a, b) => { + // Don't sort completions at all, only on boost + return (a.boost ?? 0) - (b.boost ?? 0); + }, + }), syntaxHighlighting(myHighlightStyle), EditorState.allowMultipleSelections.of(true), ]; diff --git a/src-web/components/core/Editor/genericCompletion.ts b/src-web/components/core/Editor/genericCompletion.ts index a181c6a0..af6366da 100644 --- a/src-web/components/core/Editor/genericCompletion.ts +++ b/src-web/components/core/Editor/genericCompletion.ts @@ -24,6 +24,6 @@ export function genericCompletion({ options, minMatch = 1 }: GenericCompletionCo if (!matchedMinimumLength && !context.explicit) return null; const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text); - return { from: toMatch.from, options: optionsWithoutExactMatches }; + return { from: toMatch.from, options: optionsWithoutExactMatches, info: 'hello' }; }; } diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index e8cafb63..e6f88521 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -7,6 +7,7 @@ import { ClockIcon, CodeIcon, ColorWheelIcon, + CopyIcon, Cross2Icon, DividerHorizontalIcon, DotsHorizontalIcon, @@ -40,8 +41,11 @@ const icons = { check: CheckIcon, checkbox: CheckboxIcon, clock: ClockIcon, + chevronDown: ChevronDownIcon, code: CodeIcon, colorWheel: ColorWheelIcon, + copy: CopyIcon, + dividerH: DividerHorizontalIcon, dotsH: DotsHorizontalIcon, dotsV: DotsVerticalIcon, drag: DragHandleDots2Icon, @@ -50,12 +54,10 @@ const icons = { home: HomeIcon, listBullet: ListBulletIcon, magicWand: MagicWandIcon, - chevronDown: ChevronDownIcon, magnifyingGlass: MagnifyingGlassIcon, moon: MoonIcon, paperPlane: PaperPlaneIcon, plus: PlusIcon, - dividerH: DividerHorizontalIcon, plusCircle: PlusCircledIcon, question: QuestionMarkIcon, rows: RowsIcon, diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index 4ef51212..2483d2b2 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -127,7 +127,7 @@ export const PairEditor = memo(function PairEditor({ '@container', 'pb-2 grid', // NOTE: Add padding to top so overflow doesn't hide drop marker - 'pt-1', + 'pt-1 -my-1', )} > {pairs.map((p, i) => { diff --git a/src-web/components/core/Stacks.tsx b/src-web/components/core/Stacks.tsx index f75ee8c0..76747585 100644 --- a/src-web/components/core/Stacks.tsx +++ b/src-web/components/core/Stacks.tsx @@ -4,6 +4,7 @@ import { forwardRef } from 'react'; const gapClasses = { 0: 'gap-0', + 0.5: 'gap-0.5', 1: 'gap-1', 2: 'gap-2', 3: 'gap-3', diff --git a/src-web/hooks/useCreateRequest.ts b/src-web/hooks/useCreateRequest.ts index 6450e7a3..066bd2b8 100644 --- a/src-web/hooks/useCreateRequest.ts +++ b/src-web/hooks/useCreateRequest.ts @@ -1,12 +1,12 @@ import { useMutation } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; -import { useNavigate } from 'react-router-dom'; import type { HttpRequest } from '../lib/models'; import { useActiveWorkspace } from './useActiveWorkspace'; +import { useRoutes } from './useRoutes'; export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) { const workspace = useActiveWorkspace(); - const navigate = useNavigate(); + const routes = useRoutes(); return useMutation>({ mutationFn: (patch) => { @@ -16,8 +16,8 @@ export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) return invoke('create_request', { ...patch, workspaceId: workspace.id }); }, onSuccess: async (requestId) => { - if (navigateAfter) { - navigate(`/workspaces/${workspace?.id}/requests/${requestId}`); + if (navigateAfter && workspace !== null) { + routes.navigate('request', { workspaceId: workspace.id, requestId }); } }, }); diff --git a/src-web/hooks/useDeleteRequest.ts b/src-web/hooks/useDeleteRequest.ts index 941c5f6b..c8c7f379 100644 --- a/src-web/hooks/useDeleteRequest.ts +++ b/src-web/hooks/useDeleteRequest.ts @@ -8,6 +8,7 @@ export function useDeleteRequest(id: string | null) { const queryClient = useQueryClient(); return useMutation({ mutationFn: async () => { + console.log('DELETE REQUEST2', id, workspaceId); if (id === null) return; await invoke('delete_request', { requestId: id }); }, diff --git a/src-web/hooks/useDuplicateRequest.ts b/src-web/hooks/useDuplicateRequest.ts new file mode 100644 index 00000000..5eec515c --- /dev/null +++ b/src-web/hooks/useDuplicateRequest.ts @@ -0,0 +1,26 @@ +import { useMutation } from '@tanstack/react-query'; +import { invoke } from '@tauri-apps/api'; +import { useActiveWorkspaceId } from './useActiveWorkspaceId'; +import { useRoutes } from './useRoutes'; + +export function useDuplicateRequest({ + id, + navigateAfter, +}: { + id: string | null; + navigateAfter: boolean; +}) { + const workspaceId = useActiveWorkspaceId(); + const routes = useRoutes(); + return useMutation({ + mutationFn: async () => { + if (id === null) throw new Error("Can't duplicate a null request"); + return invoke('duplicate_request', { id }); + }, + onSuccess: async (newId: string) => { + if (navigateAfter && workspaceId !== null) { + routes.navigate('request', { workspaceId, requestId: newId }); + } + }, + }); +} diff --git a/src-web/hooks/useRequests.ts b/src-web/hooks/useRequests.ts index 4e09a324..70914296 100644 --- a/src-web/hooks/useRequests.ts +++ b/src-web/hooks/useRequests.ts @@ -1,7 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; import type { HttpRequest } from '../lib/models'; -import { convertDates } from '../lib/models'; import { useActiveWorkspaceId } from './useActiveWorkspaceId'; export function requestsQueryKey(workspaceId: string) { @@ -16,8 +15,7 @@ export function useRequests() { queryKey: requestsQueryKey(workspaceId ?? 'n/a'), queryFn: async () => { if (workspaceId == null) return []; - const requests = (await invoke('requests', { workspaceId })) as HttpRequest[]; - return requests.map(convertDates); + return (await invoke('requests', { workspaceId })) as HttpRequest[]; }, }).data ?? [] ); diff --git a/src-web/hooks/useResponses.ts b/src-web/hooks/useResponses.ts index 024a1877..3fa61934 100644 --- a/src-web/hooks/useResponses.ts +++ b/src-web/hooks/useResponses.ts @@ -1,7 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; import type { HttpResponse } from '../lib/models'; -import { convertDates } from '../lib/models'; export function responsesQueryKey(requestId: string) { return ['http_responses', { requestId }]; @@ -14,10 +13,9 @@ export function useResponses(requestId: string | null) { initialData: [], queryKey: responsesQueryKey(requestId ?? 'n/a'), queryFn: async () => { - const responses = (await invoke('responses', { + return (await invoke('responses', { requestId, })) as HttpResponse[]; - return responses.map(convertDates); }, }).data ?? [] ); diff --git a/src-web/hooks/useRoutes.ts b/src-web/hooks/useRoutes.ts index 563d595c..c982c05b 100644 --- a/src-web/hooks/useRoutes.ts +++ b/src-web/hooks/useRoutes.ts @@ -36,7 +36,6 @@ export function useRoutes() { // outside caller perspective. // eslint-disable-next-line @typescript-eslint/no-explicit-any const resolvedPath = routePaths[path](...(params as any)); - console.log('NAVIGATE TO', resolvedPath, 'WITH PARAMS', params, 'AND PATH', path); navigate(resolvedPath); }, paths: routePaths, diff --git a/src-web/hooks/useSendRequest.ts b/src-web/hooks/useSendRequest.ts index 0ef798ad..956eb753 100644 --- a/src-web/hooks/useSendRequest.ts +++ b/src-web/hooks/useSendRequest.ts @@ -1,17 +1,11 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; -import { responsesQueryKey } from './useResponses'; export function useSendRequest(id: string | null) { - const queryClient = useQueryClient(); return useMutation({ mutationFn: async () => { if (id === null) return; await invoke('send_request', { requestId: id }); }, - onSuccess: async () => { - if (id === null) return; - await queryClient.invalidateQueries(responsesQueryKey(id)); - }, }).mutate; } diff --git a/src-web/hooks/useUpdateRequest.ts b/src-web/hooks/useUpdateRequest.ts index ef75e8fe..1fb80bb8 100644 --- a/src-web/hooks/useUpdateRequest.ts +++ b/src-web/hooks/useUpdateRequest.ts @@ -14,11 +14,7 @@ export function useUpdateRequest(id: string | null) { const updatedRequest = { ...request, ...patch }; await invoke('update_request', { - request: { - ...updatedRequest, - createdAt: updatedRequest.createdAt.toISOString().replace('Z', ''), - updatedAt: updatedRequest.updatedAt.toISOString().replace('Z', ''), - }, + request: updatedRequest, }); }, }); diff --git a/src-web/hooks/useWorkspaces.ts b/src-web/hooks/useWorkspaces.ts index 5bc8374d..f1053479 100644 --- a/src-web/hooks/useWorkspaces.ts +++ b/src-web/hooks/useWorkspaces.ts @@ -1,6 +1,5 @@ import { invoke } from '@tauri-apps/api'; import type { Workspace } from '../lib/models'; -import { convertDates } from '../lib/models'; import { useQuery } from '@tanstack/react-query'; export function workspacesQueryKey() { @@ -10,8 +9,7 @@ export function workspacesQueryKey() { export function useWorkspaces() { return ( useQuery(workspacesQueryKey(), async () => { - const workspaces = (await invoke('workspaces')) as Workspace[]; - return workspaces.map(convertDates); + return (await invoke('workspaces')) as Workspace[]; }).data ?? [] ); } diff --git a/src-web/lib/models.ts b/src-web/lib/models.ts index e75505ce..5d36f983 100644 --- a/src-web/lib/models.ts +++ b/src-web/lib/models.ts @@ -53,20 +53,3 @@ export interface HttpResponse extends BaseModel { readonly url: string; readonly headers: HttpHeader[]; } - -export function convertDates>(m: T): T { - return { - ...m, - createdAt: convertDate(m.createdAt), - updatedAt: convertDate(m.updatedAt), - }; -} - -function convertDate(d: string | Date): Date { - if (typeof d !== 'string') { - return d; - } - const date = new Date(d); - const userTimezoneOffset = date.getTimezoneOffset() * 60000; - return new Date(date.getTime() - userTimezoneOffset); -} diff --git a/src-web/lib/sendEphemeralRequest.ts b/src-web/lib/sendEphemeralRequest.ts new file mode 100644 index 00000000..555fb55f --- /dev/null +++ b/src-web/lib/sendEphemeralRequest.ts @@ -0,0 +1,6 @@ +import { invoke } from '@tauri-apps/api'; +import type { HttpRequest, HttpResponse } from './models'; + +export function sendEphemeralRequest(request: HttpRequest): Promise { + return invoke('send_ephemeral_request', { request }); +} diff --git a/src-web/lib/store.ts b/src-web/lib/store.ts index f344e928..753b21bb 100644 --- a/src-web/lib/store.ts +++ b/src-web/lib/store.ts @@ -1,5 +1,4 @@ import { invoke } from '@tauri-apps/api'; -import { convertDates } from './models'; import type { HttpRequest } from './models'; export async function getRequest(id: string | null): Promise { @@ -8,5 +7,5 @@ export async function getRequest(id: string | null): Promise if (request == null) { return null; } - return convertDates(request); + return request; } diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 5223b71d..935ef399 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -1,9 +1,3 @@ -const height = { - "xs": "1.5rem", - "sm": "2rem", - "md": "2.5rem" -}; - /** @type {import("tailwindcss").Config} */ module.exports = { darkMode: ["class", "[data-appearance=\"dark\"]"], @@ -16,8 +10,17 @@ module.exports = { opacity: { "disabled": "0.3" }, - height, - lineHeight: height + height: { + "xs": "1.5rem", + "sm": "2.00rem", + "md": "2.5rem" + }, + lineHeight: { + // HACK: Minus 2 to account for borders inside inputs + "xs": "calc(1.5rem - 2px)", + "sm": "calc(2.0rem - 2px)", + "md": "calc(2.5rem - 2px)" + } }, fontFamily: { "mono": ["JetBrains Mono", "Menlo", "monospace"],