diff --git a/src-tauri/sqlx-data.json b/src-tauri/sqlx-data.json index bae3746b..1b0deeac 100644 --- a/src-tauri/sqlx-data.json +++ b/src-tauri/sqlx-data.json @@ -506,6 +506,16 @@ }, "query": "\n INSERT INTO key_values (namespace, key, value)\n VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n value = excluded.value\n " }, + "e0f41023d877d94b7609ce910a71bd89c4827a558654b8ae14d85e6ba86990cf": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 2 + } + }, + "query": "\n UPDATE workspaces SET (name, updated_at) =\n (?, CURRENT_TIMESTAMP) WHERE id = ?;\n " + }, "e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": { "describe": { "columns": [ diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4bb6cc22..2e095bd0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -362,6 +362,21 @@ async fn duplicate_request( emit_and_return(&window, "updated_model", request) } +#[tauri::command] +async fn update_workspace( + workspace: models::Workspace, + window: Window, + db_instance: State<'_, Mutex>>, +) -> Result { + let pool = &*db_instance.lock().await; + + let updated_workspace = models::update_workspace(workspace, pool) + .await + .expect("Failed to update request"); + + emit_and_return(&window, "updated_model", updated_workspace) +} + #[tauri::command] async fn update_request( request: models::HttpRequest, @@ -436,6 +451,17 @@ async fn get_request( .map_err(|e| e.to_string()) } +#[tauri::command] +async fn get_workspace( + id: &str, + db_instance: State<'_, Mutex>>, +) -> Result { + let pool = &*db_instance.lock().await; + models::get_workspace(id, pool) + .await + .map_err(|e| e.to_string()) +} + #[tauri::command] async fn responses( request_id: &str, @@ -546,8 +572,10 @@ fn main() { send_ephemeral_request, duplicate_request, create_request, + get_workspace, create_workspace, delete_workspace, + update_workspace, update_request, delete_request, responses, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 01653c26..692473e6 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -423,6 +423,24 @@ pub async fn update_response_if_id( return update_response(response, pool).await; } +pub async fn update_workspace( + workspace: Workspace, + pool: &Pool, +) -> Result { + sqlx::query!( + r#" + UPDATE workspaces SET (name, updated_at) = + (?, CURRENT_TIMESTAMP) WHERE id = ?; + "#, + workspace.name, + workspace.id, + ) + .execute(pool) + .await + .expect("Failed to update workspace"); + get_workspace(&workspace.id, pool).await +} + pub async fn update_response( response: HttpResponse, pool: &Pool, diff --git a/src-web/components/DialogContext.tsx b/src-web/components/DialogContext.tsx index 94152a69..5b0f1021 100644 --- a/src-web/components/DialogContext.tsx +++ b/src-web/components/DialogContext.tsx @@ -45,13 +45,20 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => { return ( {children} - {dialogs.map(({ id, render, ...props }) => ( - actions.hide(id)} {...props}> - {render({ hide: () => actions.hide(id) })} - + {dialogs.map((props: DialogEntry) => ( + ))} ); }; +function DialogInstance({ id, render, ...props }: DialogEntry) { + const { actions } = useContext(DialogContext); + return ( + actions.hide(id)} {...props}> + {render({ hide: () => actions.hide(id) })} + + ); +} + export const useDialog = () => useContext(DialogContext).actions; diff --git a/src-web/components/ResponsePane.tsx b/src-web/components/ResponsePane.tsx index f4cc6cb5..f4f0ce15 100644 --- a/src-web/components/ResponsePane.tsx +++ b/src-web/components/ResponsePane.tsx @@ -111,7 +111,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro {activeResponse && (
- + {activeResponse.elapsed > 0 && <> • {activeResponse.elapsed}ms} {activeResponse.body.length > 0 && ( <> • {(activeResponse.body.length / 1000).toFixed(1)} KB @@ -165,7 +165,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro {!activeResponse.body ? ( - No Response + Empty Body ) : viewMode === 'pretty' && contentType.includes('html') ? ( { @@ -32,31 +37,59 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN }, })); + const activeWorkspaceItems: DropdownItem[] = + workspaces.length <= 1 + ? [] + : [ + ...workspaceItems, + { + type: 'separator', + label: activeWorkspace?.name, + }, + ]; + return [ - ...workspaceItems, + ...activeWorkspaceItems, { - type: 'separator', - label: 'Actions', + label: 'Rename', + leftSlot: , + onSelect: async () => { + const name = await prompt({ + title: 'Rename Workspace', + description: ( + <> + Enter a new name for {activeWorkspace?.name} + + ), + name: 'name', + label: 'Name', + defaultValue: activeWorkspace?.name, + }); + updateWorkspace.mutate({ name }); + }, }, { - label: 'New Workspace', - leftSlot: , - onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }), - }, - { - label: 'Delete Workspace', + label: 'Delete', leftSlot: , onSelect: deleteWorkspace.mutate, + variant: 'danger', + }, + { type: 'separator' }, + { + label: 'Create Workspace', + leftSlot: , + onSelect: () => createWorkspace.mutate({ name: 'Workspace' }), }, ]; }, [ workspaces, + deleteWorkspace.mutate, activeWorkspaceId, routes, - createWorkspace, - confirm, + prompt, activeWorkspace?.name, - deleteWorkspace, + updateWorkspace, + createWorkspace, ]); return ( diff --git a/src-web/components/core/Button.tsx b/src-web/components/core/Button.tsx index 20a2e973..03e1f5a9 100644 --- a/src-web/components/core/Button.tsx +++ b/src-web/components/core/Button.tsx @@ -36,6 +36,7 @@ const _Button = forwardRef(function Button( children, forDropdown, color, + type = 'button', justify = 'center', size = 'md', ...props @@ -68,7 +69,7 @@ const _Button = forwardRef(function Button( ); } else { return ( -
diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 69a84efa..950d6ac2 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -2,7 +2,15 @@ import classnames from 'classnames'; import FocusTrap from 'focus-trap-react'; import { motion } from 'framer-motion'; import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react'; -import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { + Children, + cloneElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useKeyPressEvent } from 'react-use'; import { Portal } from '../Portal'; import { Separator } from './Separator'; @@ -17,6 +25,7 @@ export type DropdownItem = | { type?: 'default'; label: string; + variant?: 'danger'; disabled?: boolean; hidden?: boolean; leftSlot?: ReactNode; @@ -94,6 +103,17 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) { setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 }); }, []); + // Close menu on space bar + const handleMenuKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault(); + onClose(); + } + }, + [onClose], + ); + useKeyPressEvent('Escape', (e) => { e.preventDefault(); onClose(); @@ -181,6 +201,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index c03a82b8..de1b242e 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -23,6 +23,7 @@ import { MagnifyingGlassIcon, MoonIcon, PaperPlaneIcon, + Pencil2Icon, PlusCircledIcon, PlusIcon, QuestionMarkIcon, @@ -65,6 +66,7 @@ const icons = { magnifyingGlass: MagnifyingGlassIcon, moon: MoonIcon, paperPlane: PaperPlaneIcon, + pencil: Pencil2Icon, plus: PlusIcon, plusCircle: PlusCircledIcon, question: QuestionMarkIcon, diff --git a/src-web/components/core/StatusTag.tsx b/src-web/components/core/StatusTag.tsx index 393a4f0e..f1f54f71 100644 --- a/src-web/components/core/StatusTag.tsx +++ b/src-web/components/core/StatusTag.tsx @@ -2,11 +2,12 @@ import classnames from 'classnames'; import type { HttpResponse } from '../../lib/models'; interface Props { - response: Pick; + response: Pick; className?: string; + showReason?: boolean; } -export function StatusTag({ response, className }: Props) { +export function StatusTag({ response, className, showReason }: Props) { const { status, error } = response; const label = error ? 'ERR' : status; return ( @@ -22,7 +23,7 @@ export function StatusTag({ response, className }: Props) { status >= 500 && 'text-red-600', )} > - {label} + {label} {showReason && response.statusReason && response.statusReason} ); } diff --git a/src-web/components/core/Tabs/Tabs.tsx b/src-web/components/core/Tabs/Tabs.tsx index 06bb79d7..8512b0a0 100644 --- a/src-web/components/core/Tabs/Tabs.tsx +++ b/src-web/components/core/Tabs/Tabs.tsx @@ -73,17 +73,17 @@ export function Tabs({ aria-label={label} className={classnames( tabListClassName, - 'flex items-center overflow-x-auto hide-scrollbars mt-1 mb-2', + 'flex items-center overflow-x-auto overflow-y-visible hide-scrollbars mt-1 mb-2', // Give space for button focus states within overflow boundary. - 'px-2 -mx-2', + '-mx-5 pl-3 py-1', )} > - + {tabs.map((t) => { const isActive = t.value === value; const btnClassName = classnames( isActive ? '' : 'text-gray-600 hover:text-gray-800', - '!px-0 mr-4 ml-[1px]', + '!px-2 ml-[1px]', ); if ('options' in t) { diff --git a/src-web/hooks/Confirm.tsx b/src-web/hooks/Confirm.tsx index f70b08d9..c23f78ba 100644 --- a/src-web/hooks/Confirm.tsx +++ b/src-web/hooks/Confirm.tsx @@ -3,7 +3,7 @@ import { Button } from '../components/core/Button'; import { HStack } from '../components/core/Stacks'; export interface ConfirmProps { - hide: () => void; + onHide: () => void; onResult: (result: boolean) => void; variant?: 'delete' | 'confirm'; } @@ -18,7 +18,7 @@ const confirmButtonTexts: Record, string> = confirm: 'Confirm', }; -export function Confirm({ hide, onResult, variant = 'confirm' }: ConfirmProps) { +export function Confirm({ onHide, onResult, variant = 'confirm' }: ConfirmProps) { const focusRef = (el: HTMLButtonElement | null) => { setTimeout(() => { el?.focus(); @@ -27,16 +27,16 @@ export function Confirm({ hide, onResult, variant = 'confirm' }: ConfirmProps) { const handleHide = () => { onResult(false); - hide(); + onHide(); }; const handleSuccess = () => { onResult(true); - hide(); + onHide(); }; return ( - + diff --git a/src-web/hooks/Prompt.tsx b/src-web/hooks/Prompt.tsx new file mode 100644 index 00000000..7b79f750 --- /dev/null +++ b/src-web/hooks/Prompt.tsx @@ -0,0 +1,48 @@ +import type { FormEvent } from 'react'; +import { useCallback, useState } from 'react'; +import { Button } from '../components/core/Button'; +import type { InputProps } from '../components/core/Input'; +import { Input } from '../components/core/Input'; +import { HStack, VStack } from '../components/core/Stacks'; + +export interface PromptProps { + onHide: () => void; + onResult: (value: string) => void; + label: InputProps['label']; + name: InputProps['name']; + defaultValue: InputProps['defaultValue']; +} + +export function Prompt({ onHide, label, name, defaultValue, onResult }: PromptProps) { + const [value, setValue] = useState(defaultValue ?? ''); + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + onHide(); + onResult(value); + }, + [onHide, onResult, value], + ); + + return ( +
+ + + + + + + +
+ ); +} diff --git a/src-web/hooks/useConfirm.ts b/src-web/hooks/useConfirm.ts index 791004f6..d2325786 100644 --- a/src-web/hooks/useConfirm.ts +++ b/src-web/hooks/useConfirm.ts @@ -20,7 +20,7 @@ export function useConfirm() { description, hideX: true, size: 'sm', - render: ({ hide }) => Confirm({ hide, variant, onResult }), + render: ({ hide }) => Confirm({ onHide: hide, variant, onResult }), }); }); } diff --git a/src-web/hooks/useDeleteRequest.tsx b/src-web/hooks/useDeleteRequest.tsx index 05b6aae0..eaab314e 100644 --- a/src-web/hooks/useDeleteRequest.tsx +++ b/src-web/hooks/useDeleteRequest.tsx @@ -19,7 +19,7 @@ export function useDeleteRequest(id: string | null) { variant: 'delete', description: ( <> - Are you sure you want to delete {request?.name}? + Permanently delete {request?.name}? ), }); diff --git a/src-web/hooks/useDeleteWorkspace.tsx b/src-web/hooks/useDeleteWorkspace.tsx index 819af232..10d2b32c 100644 --- a/src-web/hooks/useDeleteWorkspace.tsx +++ b/src-web/hooks/useDeleteWorkspace.tsx @@ -21,7 +21,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) { variant: 'delete', description: ( <> - Are you sure you want to delete {workspace?.name}? + Permanently delete {workspace?.name}? ), }); diff --git a/src-web/hooks/usePrompt.ts b/src-web/hooks/usePrompt.ts new file mode 100644 index 00000000..89b72707 --- /dev/null +++ b/src-web/hooks/usePrompt.ts @@ -0,0 +1,30 @@ +import type { DialogProps } from '../components/core/Dialog'; +import { useDialog } from '../components/DialogContext'; +import type { PromptProps } from './Prompt'; +import { Prompt } from './Prompt'; + +export function usePrompt() { + const dialog = useDialog(); + return ({ + title, + description, + name, + label, + defaultValue, + }: { + title: DialogProps['title']; + description?: DialogProps['description']; + name: PromptProps['name']; + label: PromptProps['label']; + defaultValue: PromptProps['defaultValue']; + }) => + new Promise((onResult: PromptProps['onResult']) => { + dialog.show({ + title, + description, + hideX: true, + size: 'sm', + render: ({ hide }) => Prompt({ onHide: hide, onResult, name, label, defaultValue }), + }); + }); +} diff --git a/src-web/hooks/useUpdateWorkspace.ts b/src-web/hooks/useUpdateWorkspace.ts new file mode 100644 index 00000000..3b71ee00 --- /dev/null +++ b/src-web/hooks/useUpdateWorkspace.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { invoke } from '@tauri-apps/api'; +import type { Workspace } from '../lib/models'; +import { getWorkspace } from '../lib/store'; +import { workspacesQueryKey } from './useWorkspaces'; + +export function useUpdateWorkspace(id: string | null) { + const queryClient = useQueryClient(); + return useMutation | ((w: Workspace) => Workspace)>({ + mutationFn: async (v) => { + const workspace = await getWorkspace(id); + if (workspace == null) { + throw new Error("Can't update a null workspace"); + } + + const newWorkspace = typeof v === 'function' ? v(workspace) : { ...workspace, ...v }; + await invoke('update_workspace', { workspace: newWorkspace }); + }, + onMutate: async (v) => { + const workspace = await getWorkspace(id); + if (workspace === null) return; + + const newWorkspace = typeof v === 'function' ? v(workspace) : { ...workspace, ...v }; + queryClient.setQueryData(workspacesQueryKey(workspace), (workspaces) => + (workspaces ?? []).map((w) => (w.id === newWorkspace.id ? newWorkspace : w)), + ); + }, + }); +} diff --git a/src-web/lib/store.ts b/src-web/lib/store.ts index 753b21bb..998142d7 100644 --- a/src-web/lib/store.ts +++ b/src-web/lib/store.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api'; -import type { HttpRequest } from './models'; +import type { HttpRequest, Workspace } from './models'; export async function getRequest(id: string | null): Promise { if (id === null) return null; @@ -9,3 +9,12 @@ export async function getRequest(id: string | null): Promise } return request; } + +export async function getWorkspace(id: string | null): Promise { + if (id === null) return null; + const workspace: Workspace = (await invoke('get_workspace', { id })) ?? null; + if (workspace == null) { + return null; + } + return workspace; +}