diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4b8f80c6..2198345e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -313,14 +313,13 @@ async fn delete_request( app_handle: AppHandle, db_instance: State<'_, Mutex>>, request_id: &str, -) -> Result { +) -> Result<(), String> { let pool = &*db_instance.lock().await; let req = models::delete_request(request_id, pool) .await .expect("Failed to delete request"); - app_handle.emit_all("deleted_request", request_id).unwrap(); - - Ok(req) + app_handle.emit_all("deleted_model", req).unwrap(); + Ok(()) } #[tauri::command] @@ -357,12 +356,15 @@ async fn responses( #[tauri::command] async fn delete_response( id: &str, + app_handle: AppHandle, db_instance: State<'_, Mutex>>, ) -> Result<(), String> { let pool = &*db_instance.lock().await; - models::delete_response(id, pool) + let response = models::delete_response(id, pool) .await - .map_err(|e| e.to_string()) + .expect("Failed to delete response"); + app_handle.emit_all("deleted_model", response).unwrap(); + Ok(()) } #[tauri::command] @@ -394,6 +396,20 @@ async fn workspaces( } } +#[tauri::command] +async fn delete_workspace( + app_handle: AppHandle, + db_instance: State<'_, Mutex>>, + id: &str, +) -> Result<(), String> { + let pool = &*db_instance.lock().await; + let workspace = models::delete_workspace(id, pool) + .await + .expect("Failed to delete workspace"); + app_handle.emit_all("deleted_model", workspace).unwrap(); + Ok(()) +} + #[tauri::command] fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) @@ -511,6 +527,7 @@ fn main() { send_request, create_request, create_workspace, + delete_workspace, update_request, delete_request, responses, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 147dde8d..5be0266f 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -142,6 +142,22 @@ pub async fn get_workspace(id: &str, pool: &Pool) -> Result) -> Result { + let workspace = get_workspace(id, pool) + .await + .expect("Failed to get request to delete"); + let _ = sqlx::query!( + r#" + DELETE FROM http_requests + WHERE id = ? + "#, + id, + ) + .execute(pool) + .await; + Ok(workspace) +} + pub async fn create_workspace( name: &str, description: &str, @@ -395,7 +411,11 @@ pub async fn find_responses( .await } -pub async fn delete_response(id: &str, pool: &Pool) -> Result<(), sqlx::Error> { +pub async fn delete_response(id: &str, pool: &Pool) -> Result { + let resp = get_response(id, pool) + .await + .expect("Failed to get response to delete"); + let _ = sqlx::query!( r#" DELETE FROM http_responses @@ -406,7 +426,7 @@ pub async fn delete_response(id: &str, pool: &Pool) -> Result<(), sqlx:: .execute(pool) .await; - Ok(()) + Ok(resp) } pub async fn delete_all_responses( diff --git a/src-web/components/App.tsx b/src-web/components/App.tsx index 0aacfde8..f1e7ec4b 100644 --- a/src-web/components/App.tsx +++ b/src-web/components/App.tsx @@ -10,12 +10,13 @@ import { matchPath } from 'react-router-dom'; import { keyValueQueryKey } from '../hooks/useKeyValue'; import { requestsQueryKey } from '../hooks/useRequests'; import { responsesQueryKey } from '../hooks/useResponses'; +import { routePaths } from '../hooks/useRoutes'; 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, WORKSPACE_REQUEST_PATH } from './AppRouter'; +import { AppRouter } from './AppRouter'; const queryClient = new QueryClient(); @@ -45,12 +46,6 @@ await listen('updated_request', ({ payload: request }: { payload: HttpRequest }) ); }); -await listen('deleted_request', ({ payload: request }: { payload: HttpRequest }) => { - queryClient.setQueryData(requestsQueryKey(request.workspaceId), (requests: HttpRequest[] = []) => - requests.filter((r) => r.id !== request.id), - ); -}); - await listen('updated_response', ({ payload: response }: { payload: HttpResponse }) => { queryClient.setQueryData( responsesQueryKey(response.requestId), @@ -92,8 +87,27 @@ await listen('updated_workspace', ({ payload: workspace }: { payload: Workspace }); }); +await listen( + 'deleted_model', + ({ payload: model }: { payload: Workspace | HttpRequest | HttpResponse | KeyValue }) => { + function removeById(model: T) { + return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id); + } + + if (model.model === 'workspace') { + queryClient.setQueryData(workspacesQueryKey(), removeById(model)); + } else if (model.model === 'http_request') { + queryClient.setQueryData(requestsQueryKey(model.workspaceId), removeById(model)); + } else if (model.model === 'http_response') { + queryClient.setQueryData(responsesQueryKey(model.requestId), removeById(model)); + } else if (model.model === 'key_value') { + queryClient.setQueryData(keyValueQueryKey(model), undefined); + } + }, +); + await listen('send_request', async () => { - const params = matchPath(WORKSPACE_REQUEST_PATH, window.location.pathname); + const params = matchPath(routePaths.request(), window.location.pathname); const requestId = params?.params.requestId; if (typeof requestId !== 'string') { return; diff --git a/src-web/components/AppRouter.tsx b/src-web/components/AppRouter.tsx index d3b4f28e..54cd48be 100644 --- a/src-web/components/AppRouter.tsx +++ b/src-web/components/AppRouter.tsx @@ -1,13 +1,11 @@ import { lazy, Suspense } from 'react'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'; +import { routePaths } from '../hooks/useRoutes'; const Workspaces = lazy(() => import('./Workspaces')); const Workspace = lazy(() => import('./Workspace')); const RouteError = lazy(() => import('./RouteError')); -export const WORKSPACE_PATH = '/workspaces/:workspaceId'; -export const WORKSPACE_REQUEST_PATH = '/workspaces/:workspaceId/requests/:requestId'; - const router = createBrowserRouter([ { path: '/', @@ -15,14 +13,21 @@ const router = createBrowserRouter([ children: [ { path: '/', + element: , + }, + { + path: routePaths.workspaces(), element: , }, { - path: WORKSPACE_PATH, + path: routePaths.workspace({ workspaceId: ':workspaceId' }), element: , }, { - path: WORKSPACE_REQUEST_PATH, + path: routePaths.request({ + workspaceId: ':workspaceId', + requestId: ':requestId', + }), element: , }, ], diff --git a/src-web/components/ResponsePane.tsx b/src-web/components/ResponsePane.tsx index 90c6a289..b6e95cd4 100644 --- a/src-web/components/ResponsePane.tsx +++ b/src-web/components/ResponsePane.tsx @@ -2,7 +2,7 @@ import classnames from 'classnames'; import { memo, useEffect, useMemo, useState } from 'react'; import { useActiveRequestId } from '../hooks/useActiveRequestId'; import { useDeleteResponses } from '../hooks/useDeleteResponses'; -import { useDeleteResponse } from '../hooks/useResponseDelete'; +import { useDeleteResponse } from '../hooks/useDeleteResponse'; import { useResponses } from '../hooks/useResponses'; import { useResponseViewMode } from '../hooks/useResponseViewMode'; import { tryFormatJson } from '../lib/formatters'; @@ -21,18 +21,18 @@ interface Props { } export const ResponsePane = memo(function ResponsePane({ className }: Props) { - const [activeResponseId, setActiveResponseId] = useState(null); + const [pinnedResponseId, setPinnedResponseId] = useState(null); const activeRequestId = useActiveRequestId(); const responses = useResponses(activeRequestId); - const activeResponse: HttpResponse | null = activeResponseId - ? responses.find((r) => r.id === activeResponseId) ?? null + const activeResponse: HttpResponse | null = pinnedResponseId + ? responses.find((r) => r.id === pinnedResponseId) ?? null : responses[responses.length - 1] ?? null; const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId); - const deleteResponse = useDeleteResponse(activeResponse); + const deleteResponse = useDeleteResponse(activeResponse?.id ?? null); const deleteAllResponses = useDeleteResponses(activeResponse?.requestId); useEffect(() => { - setActiveResponseId(null); + setPinnedResponseId(null); }, [responses.length]); const contentType = useMemo( @@ -92,7 +92,7 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) { ...responses.slice(0, 10).map((r) => ({ label: r.status + ' - ' + r.elapsed + ' ms', leftSlot: activeResponse?.id === r.id ? : <>, - onSelect: () => setActiveResponseId(r.id), + onSelect: () => setPinnedResponseId(r.id), })), ]} > diff --git a/src-web/components/UrlBar.tsx b/src-web/components/UrlBar.tsx index 40ae9153..efebe79f 100644 --- a/src-web/components/UrlBar.tsx +++ b/src-web/components/UrlBar.tsx @@ -29,7 +29,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa ); return ( -
+ { const workspaceItems = workspaces.map((w) => ({ @@ -27,7 +29,7 @@ export const WorkspaceDropdown = memo(function WorkspaceDropdown({ className }: leftSlot: activeWorkspaceId === w.id ? : , onSelect: () => { if (w.id === activeWorkspaceId) return; - navigate(`/workspaces/${w.id}`); + routes.navigate('workspace', { workspaceId: w.id }); }, })); @@ -36,10 +38,14 @@ export const WorkspaceDropdown = memo(function WorkspaceDropdown({ className }: '-----', { label: 'New Workspace', - value: 'new', leftSlot: , onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }), }, + { + label: 'Delete Workspace', + leftSlot: , + onSelect: () => deleteWorkspace.mutate(), + }, ]; }, [workspaces, activeWorkspaceId]); diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index ece60edd..e9eb908e 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -20,10 +20,9 @@ export type DropdownItem = export interface DropdownProps { children: ReactElement>; items: DropdownItem[]; - ignoreClick?: boolean; } -export function Dropdown({ children, items, ignoreClick }: DropdownProps) { +export function Dropdown({ children, items }: DropdownProps) { const [open, setOpen] = useState(false); const ref = useRef(null); const child = useMemo(() => { @@ -36,7 +35,6 @@ export function Dropdown({ children, items, ignoreClick }: DropdownProps) { onClick: existingChild.props?.onClick ?? ((e: MouseEvent) => { - console.log('CLICK INSIDE'); e.preventDefault(); e.stopPropagation(); setOpen((o) => !o); diff --git a/src-web/components/core/Editor/Editor.css b/src-web/components/core/Editor/Editor.css index 9fae015d..9aaa37f7 100644 --- a/src-web/components/core/Editor/Editor.css +++ b/src-web/components/core/Editor/Editor.css @@ -24,6 +24,11 @@ @apply text-placeholder; } + .cm-scroller { + /* Inherit line-height from outside */ + line-height: inherit; + } + /* Don't show selection on blurred input */ .cm-selectionBackground { @apply bg-transparent; @@ -54,17 +59,15 @@ &.cm-singleline { .cm-editor { - @apply h-full w-full; + @apply w-full h-auto; } .cm-scroller { - @apply font-mono flex text-[0.8rem]; - align-items: center !important; - overflow: hidden !important; + @apply font-mono text-[0.8rem] overflow-hidden; } .cm-line { - @apply px-0; + @apply px-2 overflow-hidden; } } @@ -96,11 +99,12 @@ } .cm-editor .cm-gutterElement { + @apply flex items-center; transition: color var(--transition-duration); } .cm-editor .fold-gutter-icon { - @apply pt-[0.3em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded; + @apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded; } .cm-editor .fold-gutter-icon::after { @@ -109,7 +113,7 @@ } .cm-editor .fold-gutter-icon[data-open] { - @apply pt-[0.4em] pl-[0.3em]; + @apply pt-[0.38em] pl-[0.3em]; } .cm-editor .fold-gutter-icon[data-open]::after { diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx index 9d7ec541..0515f428 100644 --- a/src-web/components/core/Input.tsx +++ b/src-web/components/core/Input.tsx @@ -46,7 +46,7 @@ export function Input({ const id = `input-${name}`; const inputClassName = classnames( className, - '!bg-transparent pl-3 pr-2 min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder', + '!bg-transparent min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder', !!leftSlot && '!pl-0.5', !!rightSlot && '!pr-0.5', ); @@ -81,8 +81,8 @@ export function Input({ 'relative w-full rounded-md text-gray-900', 'border border-gray-200 focus-within:border-focus', !isValid && '!border-invalid', - size === 'md' && 'h-md', - size === 'sm' && 'h-sm', + size === 'md' && 'h-md leading-md', + size === 'sm' && 'h-sm leading-sm', )} > {leftSlot} diff --git a/src-web/hooks/useActiveRequestId.ts b/src-web/hooks/useActiveRequestId.ts index 3f417064..0ba25a20 100644 --- a/src-web/hooks/useActiveRequestId.ts +++ b/src-web/hooks/useActiveRequestId.ts @@ -1,6 +1,7 @@ import { useParams } from 'react-router-dom'; +import type { RouteParamsRequest } from './useRoutes'; export function useActiveRequestId(): string | null { - const { requestId } = useParams<{ requestId?: string }>(); + const { requestId } = useParams(); return requestId ?? null; } diff --git a/src-web/hooks/useActiveWorkspaceId.ts b/src-web/hooks/useActiveWorkspaceId.ts index 1df8ef41..b010fa76 100644 --- a/src-web/hooks/useActiveWorkspaceId.ts +++ b/src-web/hooks/useActiveWorkspaceId.ts @@ -1,6 +1,7 @@ import { useParams } from 'react-router-dom'; +import type { RouteParamsWorkspace } from './useRoutes'; export function useActiveWorkspaceId(): string | null { - const { workspaceId } = useParams<{ workspaceId?: string }>(); + const { workspaceId } = useParams(); return workspaceId ?? null; } diff --git a/src-web/hooks/useCreateWorkspace.ts b/src-web/hooks/useCreateWorkspace.ts index 1f1e9119..a93c9634 100644 --- a/src-web/hooks/useCreateWorkspace.ts +++ b/src-web/hooks/useCreateWorkspace.ts @@ -1,17 +1,17 @@ import { useMutation } from '@tanstack/react-query'; import { invoke } from '@tauri-apps/api'; -import { useNavigate } from 'react-router-dom'; import type { Workspace } from '../lib/models'; +import { useRoutes } from './useRoutes'; export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) { - const navigate = useNavigate(); + const routes = useRoutes(); return useMutation>({ mutationFn: (patch) => { return invoke('create_workspace', patch); }, onSuccess: async (workspaceId) => { if (navigateAfter) { - navigate(`/workspaces/${workspaceId}`); + routes.navigate('workspace', { workspaceId }); } }, }); diff --git a/src-web/hooks/useDeleteResponse.ts b/src-web/hooks/useDeleteResponse.ts new file mode 100644 index 00000000..e5947097 --- /dev/null +++ b/src-web/hooks/useDeleteResponse.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; +import { invoke } from '@tauri-apps/api'; + +export function useDeleteResponse(id: string | null) { + return useMutation({ + mutationFn: async () => { + if (id === null) return; + await invoke('delete_response', { id: id }); + }, + }); +} diff --git a/src-web/hooks/useDeleteResponses.ts b/src-web/hooks/useDeleteResponses.ts index 9c9134eb..ec98c4d6 100644 --- a/src-web/hooks/useDeleteResponses.ts +++ b/src-web/hooks/useDeleteResponses.ts @@ -9,9 +9,9 @@ export function useDeleteResponses(requestId?: string) { if (!requestId) return; await invoke('delete_all_responses', { requestId }); }, - onSuccess: () => { + onSuccess: async () => { if (!requestId) return; - queryClient.setQueryData(responsesQueryKey(requestId), []); + await queryClient.invalidateQueries(responsesQueryKey(requestId)); }, }); } diff --git a/src-web/hooks/useDeleteWorkspace.ts b/src-web/hooks/useDeleteWorkspace.ts new file mode 100644 index 00000000..950042b6 --- /dev/null +++ b/src-web/hooks/useDeleteWorkspace.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { invoke } from '@tauri-apps/api'; +import { useNavigate } from 'react-router-dom'; +import { useActiveWorkspaceId } from './useActiveWorkspaceId'; +import { useRoutes } from './useRoutes'; +import { workspacesQueryKey } from './useWorkspaces'; + +export function useDeleteWorkspace(id: string | null) { + const queryClient = useQueryClient(); + const activeWorkspaceId = useActiveWorkspaceId(); + const routes = useRoutes(); + return useMutation({ + mutationFn: async () => { + if (id === null) return; + await invoke('delete_workspace', { id }); + }, + onSuccess: async () => { + if (id === null) return; + await queryClient.invalidateQueries(workspacesQueryKey()); + if (id === activeWorkspaceId) { + routes.navigate('workspace', { workspaceId: id }); + } + }, + }); +} diff --git a/src-web/hooks/useResponseDelete.ts b/src-web/hooks/useResponseDelete.ts deleted file mode 100644 index 495bd112..00000000 --- a/src-web/hooks/useResponseDelete.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { invoke } from '@tauri-apps/api'; -import type { HttpResponse } from '../lib/models'; -import { responsesQueryKey } from './useResponses'; - -export function useDeleteResponse(response: HttpResponse | null) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async () => { - if (response === null) return; - await invoke('delete_response', { id: response.id }); - }, - onSuccess: () => { - if (response === null) return; - queryClient.setQueryData( - responsesQueryKey(response.requestId), - (responses: HttpResponse[] = []) => responses.filter((r) => r.id !== response.id), - ); - }, - }); -} diff --git a/src-web/hooks/useRoutes.ts b/src-web/hooks/useRoutes.ts new file mode 100644 index 00000000..ce158e72 --- /dev/null +++ b/src-web/hooks/useRoutes.ts @@ -0,0 +1,39 @@ +export type RouteParamsWorkspace = { + workspaceId: string; +}; + +export type RouteParamsRequest = RouteParamsWorkspace & { + requestId: string; +}; + +export const routePaths = { + workspaces() { + return '/workspaces'; + }, + workspace({ workspaceId } = { workspaceId: ':workspaceId' } as RouteParamsWorkspace) { + return `/workspaces/${workspaceId}`; + }, + request( + { workspaceId, requestId } = { + workspaceId: ':workspaceId', + requestId: ':requestId', + } as RouteParamsRequest, + ) { + return `${this.workspace({ workspaceId })}/requests/${requestId}`; + }, +}; + +export function useRoutes() { + return { + navigate( + path: T, + params: Parameters<(typeof routePaths)[T]>[0], + ) { + // Not sure how to make TS work here, but it's good from the + // outside caller perspective. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + routePaths[path](params as any); + }, + paths: routePaths, + }; +} diff --git a/src-web/lib/models.ts b/src-web/lib/models.ts index f86ee707..756d9a12 100644 --- a/src-web/lib/models.ts +++ b/src-web/lib/models.ts @@ -1,6 +1,5 @@ export interface BaseModel { readonly id: string; - readonly workspaceId: string; readonly createdAt: Date; readonly updatedAt: Date; } @@ -18,6 +17,7 @@ export interface HttpHeader { } export interface HttpRequest extends BaseModel { + readonly workspaceId: string; readonly model: 'http_request'; sortPriority: number; name: string; @@ -36,6 +36,7 @@ export interface KeyValue extends Omit { } export interface HttpResponse extends BaseModel { + readonly workspaceId: string; readonly model: 'http_response'; readonly requestId: string; readonly body: string; diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 6453a953..3a6df945 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -13,7 +13,11 @@ module.exports = { height: { 'sm': '2rem', 'md': '2.5rem', - } + }, + lineHeight: { + 'sm': '2rem', + 'md': '2.5rem', + }, }, fontFamily: { "mono": ["JetBrains Mono", "Menlo", "monospace"],