diff --git a/src-tauri/capabilities/capabilities.json b/src-tauri/capabilities/capabilities.json index 0ed402a3..3c57cb36 100644 --- a/src-tauri/capabilities/capabilities.json +++ b/src-tauri/capabilities/capabilities.json @@ -10,6 +10,7 @@ "os:allow-os-type", "event:allow-emit", "clipboard-manager:allow-write-text", + "clipboard-manager:allow-read-text", "dialog:allow-open", "dialog:allow-save", "event:allow-listen", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 87d4844d..42234e30 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["os:allow-os-type","event:allow-emit","clipboard-manager:allow-write-text","dialog:allow-open","dialog:allow-save","event:allow-listen","event:allow-unlisten","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"protoc","sidecar":true}]},"window:allow-close","window:allow-is-fullscreen","window:allow-maximize","window:allow-minimize","window:allow-set-decorations","window:allow-set-title","window:allow-start-dragging","window:allow-unmaximize","clipboard-manager:default"]}} \ No newline at end of file +{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["os:allow-os-type","event:allow-emit","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","event:allow-listen","event:allow-unlisten","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"protoc","sidecar":true}]},"window:allow-close","window:allow-is-fullscreen","window:allow-maximize","window:allow-minimize","window:allow-set-decorations","window:allow-set-title","window:allow-start-dragging","window:allow-unmaximize","clipboard-manager:default"]}} \ No newline at end of file diff --git a/src-web/components/DefaultLayout.tsx b/src-web/components/DefaultLayout.tsx index 31e80c1a..581d6ece 100644 --- a/src-web/components/DefaultLayout.tsx +++ b/src-web/components/DefaultLayout.tsx @@ -1,12 +1,15 @@ import { Outlet } from 'react-router-dom'; import { DialogProvider } from './DialogContext'; import { GlobalHooks } from './GlobalHooks'; +import { ToastProvider } from './ToastContext'; export function DefaultLayout() { return ( - - + + + + ); } diff --git a/src-web/components/ImportDataDialog.tsx b/src-web/components/ImportDataDialog.tsx index 9de30e20..1a941df9 100644 --- a/src-web/components/ImportDataDialog.tsx +++ b/src-web/components/ImportDataDialog.tsx @@ -13,8 +13,8 @@ export function ImportDataDialog({ importData }: Props) {

Supported Formats:

diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index 0e858b19..71284de1 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -250,15 +250,13 @@ export const RequestPane = memo(function RequestPane({ if (!command.startsWith('curl ')) { return; } - if ( - await confirm({ - id: 'paste-curl', - title: 'Import from Curl?', - description: - 'Do you want to overwrite the current request with the Curl command?', - confirmText: 'Overwrite', - }) - ) { + const confirmed = await confirm({ + id: 'paste-curl', + title: 'Import from Curl?', + description: 'Do you want to overwrite the current request with the Curl command?', + confirmText: 'Overwrite', + }); + if (confirmed) { importCurl.mutate({ requestId: activeRequestId, command }); } }} diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index 5c25602e..6e4a8256 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -9,7 +9,6 @@ import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId'; import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useAppRoutes } from '../hooks/useAppRoutes'; -import { useCopyAsCurl } from '../hooks/useCopyAsCurl'; import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems'; import { useDeleteFolder } from '../hooks/useDeleteFolder'; import { useDeleteRequest } from '../hooks/useDeleteRequest'; @@ -41,6 +40,7 @@ import { InlineCode } from './core/InlineCode'; import { VStack } from './core/Stacks'; import { StatusTag } from './core/StatusTag'; import { DropMarker } from './DropMarker'; +import { useCopyAsCurl } from '../hooks/useCopyAsCurl'; interface Props { className?: string; @@ -608,7 +608,7 @@ const SidebarItem = forwardRef(function SidebarItem( const deleteRequest = useDeleteRequest(activeRequest ?? null); const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true }); const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true }); - const [isCopied, copyAsCurl] = useCopyAsCurl(itemId); + const [copyAsCurl] = useCopyAsCurl(itemId); const sendRequest = useSendRequest(itemId); const sendManyRequests = useSendManyRequests(); const latestHttpResponse = useLatestHttpResponse(itemId); @@ -739,12 +739,7 @@ const SidebarItem = forwardRef(function SidebarItem( { key: 'copyCurl', label: 'Copy as Curl', - leftSlot: ( - - ), + leftSlot: , onSelect: copyAsCurl, }, { type: 'separator' }, diff --git a/src-web/components/ToastContext.tsx b/src-web/components/ToastContext.tsx new file mode 100644 index 00000000..9a70e79c --- /dev/null +++ b/src-web/components/ToastContext.tsx @@ -0,0 +1,79 @@ +import React, { createContext, useContext, useMemo, useRef, useState } from 'react'; +import type { ToastProps } from './core/Toast'; +import { Toast } from './core/Toast'; +import { generateId } from '../lib/generateId'; +import { Portal } from './Portal'; +import { AnimatePresence } from 'framer-motion'; + +type ToastEntry = { + render: ({ hide }: { hide: () => void }) => React.ReactNode; + timeout?: number; +} & Omit; + +type PrivateToastEntry = ToastEntry & { + id: string; + timeout: number; +}; + +interface State { + toasts: PrivateToastEntry[]; + actions: Actions; +} + +interface Actions { + show: (d: ToastEntry) => void; + hide: (id: string) => void; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const ToastContext = createContext({} as State); + +export const ToastProvider = ({ children }: { children: React.ReactNode }) => { + const [toasts, setToasts] = useState([]); + const timeoutRef = useRef(); + const actions = useMemo( + () => ({ + show({ timeout = 4000, ...props }: ToastEntry) { + const id = generateId(); + timeoutRef.current = setTimeout(() => { + this.hide(id); + }, timeout); + setToasts((a) => [...a.filter((d) => d.id !== id), { id, timeout, ...props }]); + return id; + }, + hide: (id: string) => { + setToasts((a) => a.filter((d) => d.id !== id)); + }, + }), + [], + ); + + const state: State = { toasts, actions }; + + return ( + + {children} + +
+ + {toasts.map((props: PrivateToastEntry) => ( + + ))} + +
+
+
+ ); +}; + +function ToastInstance({ id, render, timeout, ...props }: PrivateToastEntry) { + const { actions } = useContext(ToastContext); + const children = render({ hide: () => actions.hide(id) }); + return ( + actions.hide(id)} {...props}> + {children} + + ); +} + +export const useToast = () => useContext(ToastContext).actions; diff --git a/src-web/components/Workspace.tsx b/src-web/components/Workspace.tsx index 7780bf84..6a150af2 100644 --- a/src-web/components/Workspace.tsx +++ b/src-web/components/Workspace.tsx @@ -33,6 +33,8 @@ import { ResizeHandle } from './ResizeHandle'; import { Sidebar } from './Sidebar'; import { SidebarActions } from './SidebarActions'; import { WorkspaceHeader } from './WorkspaceHeader'; +import { useClipboardText } from '../hooks/useClipboardText'; +import { Portal } from './Portal'; const side = { gridArea: 'side' }; const head = { gridArea: 'head' }; @@ -54,6 +56,7 @@ export default function Workspace() { const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>( null, ); + const isCurlInClipboard = !!useClipboardText()?.startsWith('curl '); const unsub = () => { if (moveState.current !== null) { @@ -124,83 +127,88 @@ export default function Workspace() { } return ( -
- {floating ? ( - setFloatingSidebarHidden(true)} - > - + + {isCurlInClipboard &&
Import
} +
+
+ {floating ? ( + setFloatingSidebarHidden(true)} > - - - - - - - ) : ( - <> -
- + + + + + + + + ) : ( + <> +
+ +
+ + + )} + + + + {activeWorkspace == null ? ( +
+ + The active workspace{' '} + {activeWorkspaceId} was not + found. Select a workspace from the header menu or report this bug to +
- - - )} - - - - {activeWorkspace == null ? ( -
- - The active workspace{' '} - {activeWorkspaceId} was not found. - Select a workspace from the header menu or report this bug to - -
- ) : activeRequest == null ? ( - - - - - - - } - /> - ) : activeRequest.model === 'grpc_request' ? ( - - ) : ( - - )} -
+ + + + + } + /> + ) : activeRequest.model === 'grpc_request' ? ( + + ) : ( + + )} +
+ ); } diff --git a/src-web/components/core/Button.tsx b/src-web/components/core/Button.tsx index c29eb303..7b2b6111 100644 --- a/src-web/components/core/Button.tsx +++ b/src-web/components/core/Button.tsx @@ -60,9 +60,7 @@ export const Button = forwardRef(function Button size === 'sm' && 'h-sm px-2.5 text-sm', size === 'xs' && 'h-xs px-2 text-sm', // Solids - variant === 'solid' && - color === 'custom' && - 'ring-blue-400 enabled:hocus:bg-highlightSecondary', + variant === 'solid' && color === 'custom' && 'ring-blue-400', variant === 'solid' && color === 'default' && 'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-800 ring-blue-400', diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index 49d10cac..c64516a3 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -17,7 +17,6 @@ const icons = { arrowUpFromDot: lucide.ArrowUpFromDotIcon, box: lucide.BoxIcon, cake: lucide.CakeIcon, - minus: lucide.MinusIcon, chat: lucide.MessageSquare, check: lucide.CheckIcon, chevronDown: lucide.ChevronDownIcon, @@ -25,6 +24,7 @@ const icons = { code: lucide.CodeIcon, cookie: lucide.CookieIcon, copy: lucide.CopyIcon, + copyCheck: lucide.CopyCheck, download: lucide.DownloadIcon, externalLink: lucide.ExternalLinkIcon, eye: lucide.EyeIcon, @@ -39,6 +39,7 @@ const icons = { leftPanelHidden: lucide.PanelLeftOpenIcon, leftPanelVisible: lucide.PanelLeftCloseIcon, magicWand: lucide.Wand2Icon, + minus: lucide.MinusIcon, moreVertical: lucide.MoreVerticalIcon, pencil: lucide.PencilIcon, plug: lucide.Plug, diff --git a/src-web/components/core/Toast.tsx b/src-web/components/core/Toast.tsx new file mode 100644 index 00000000..e5e12188 --- /dev/null +++ b/src-web/components/core/Toast.tsx @@ -0,0 +1,76 @@ +import classNames from 'classnames'; +import { motion } from 'framer-motion'; +import type { ReactNode } from 'react'; +import React, { useMemo } from 'react'; +import { useKey } from 'react-use'; +import { Heading } from './Heading'; +import { IconButton } from './IconButton'; + +export interface ToastProps { + children: ReactNode; + open: boolean; + onClose: () => void; + title?: ReactNode; + className?: string; + timeout: number; +} + +export function Toast({ children, className, open, onClose, title, timeout }: ToastProps) { + const titleId = useMemo(() => Math.random().toString(36).slice(2), []); + + useKey( + 'Escape', + () => { + if (!open) return; + onClose(); + }, + {}, + [open], + ); + + return ( + +
+ {title && ( + + {title} + + )} + +
{children}
+
+ + +
+ +
+
+ ); +} diff --git a/src-web/hooks/useClipboardText.ts b/src-web/hooks/useClipboardText.ts new file mode 100644 index 00000000..0a5f8926 --- /dev/null +++ b/src-web/hooks/useClipboardText.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { readText } from '@tauri-apps/plugin-clipboard-manager'; + +export function useClipboardText() { + return useQuery({ + queryKey: [], + queryFn: async () => { + const text = await readText(); + console.log('READ CLIPBOARD', text); + return text; + }, + }).data; +} diff --git a/src-web/hooks/useCopyAsCurl.ts b/src-web/hooks/useCopyAsCurl.tsx similarity index 57% rename from src-web/hooks/useCopyAsCurl.ts rename to src-web/hooks/useCopyAsCurl.tsx index 98fc2c03..fde355b6 100644 --- a/src-web/hooks/useCopyAsCurl.ts +++ b/src-web/hooks/useCopyAsCurl.tsx @@ -1,9 +1,12 @@ import { invoke } from '@tauri-apps/api/core'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; -import { useState } from 'react'; +import React, { useState } from 'react'; +import { useToast } from '../components/ToastContext'; +import { Icon } from '../components/core/Icon'; export function useCopyAsCurl(requestId: string) { const [checked, setChecked] = useState(false); + const toast = useToast(); return [ checked, async () => { @@ -11,6 +14,14 @@ export function useCopyAsCurl(requestId: string) { await writeText(cmd); setChecked(true); setTimeout(() => setChecked(false), 800); + toast.show({ + render: () => [ + <> + + Command copied to clipboard + , + ], + }); return cmd; }, ] as const; diff --git a/src-web/lib/analytics.ts b/src-web/lib/analytics.ts index 458e959d..a3863f24 100644 --- a/src-web/lib/analytics.ts +++ b/src-web/lib/analytics.ts @@ -14,6 +14,7 @@ export type TrackResource = | 'key_value' | 'setting' | 'sidebar' + | 'toast' | 'workspace'; export type TrackAction =