diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fb1b9dc0..674452c6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -44,11 +44,7 @@ use crate::render::{render_grpc_request, render_http_request, render_json_value, use crate::template_callback::PluginTemplateCallback; use crate::updates::{UpdateMode, YaakUpdater}; use crate::window_menu::app_menu; -use yaak_models::models::{ - CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcConnectionState, - GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, KeyValue, - ModelType, Plugin, Settings, Workspace, -}; +use yaak_models::models::{CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, KeyValue, KeyValueIden, ModelType, Plugin, Settings, Workspace}; use yaak_models::queries::{ cancel_pending_grpc_connections, cancel_pending_responses, create_default_http_response, delete_all_grpc_connections, delete_all_grpc_connections_for_workspace, @@ -61,10 +57,10 @@ use yaak_models::queries::{ get_key_value_raw, get_or_create_settings, get_plugin, get_workspace, list_cookie_jars, list_environments, list_folders, list_grpc_connections_for_workspace, list_grpc_events, list_grpc_requests, list_http_requests, list_http_responses_for_request, - list_http_responses_for_workspace, list_plugins, list_workspaces, set_key_value_raw, - update_response_if_id, update_settings, upsert_cookie_jar, upsert_environment, upsert_folder, - upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, - upsert_plugin, upsert_workspace, + list_http_responses_for_workspace, list_key_values_raw, list_plugins, list_workspaces, + set_key_value_raw, update_response_if_id, update_settings, upsert_cookie_jar, + upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, + upsert_grpc_request, upsert_http_request, upsert_plugin, upsert_workspace, }; use yaak_plugin_runtime::events::{ BootResponse, CallHttpRequestActionRequest, FilterResponse, FindHttpResponsesResponse, @@ -1513,6 +1509,11 @@ async fn cmd_list_cookie_jars( } } +#[tauri::command] +async fn cmd_list_key_values(w: WebviewWindow) -> Result, String> { + list_key_values_raw(&w).await.map_err(|e| e.to_string()) +} + #[tauri::command] async fn cmd_get_environment(id: &str, w: WebviewWindow) -> Result { get_environment(&w, id).await.map_err(|e| e.to_string()) @@ -1797,6 +1798,7 @@ pub fn run() { cmd_list_grpc_connections, cmd_list_grpc_events, cmd_list_grpc_requests, + cmd_list_key_values, cmd_list_http_requests, cmd_list_http_responses, cmd_list_plugins, diff --git a/src-tauri/yaak_models/src/queries.rs b/src-tauri/yaak_models/src/queries.rs index 52af6b9a..72e76526 100644 --- a/src-tauri/yaak_models/src/queries.rs +++ b/src-tauri/yaak_models/src/queries.rs @@ -126,6 +126,18 @@ pub async fn set_key_value_raw( (emit_upserted_model(w, kv), existing.is_none()) } +pub async fn list_key_values_raw(mgr: &impl Manager) -> Result> { + let dbm = &*mgr.state::(); + let db = dbm.0.lock().await.get().unwrap(); + let (sql, params) = Query::select() + .from(KeyValueIden::Table) + .column(Asterisk) + .build_rusqlite(SqliteQueryBuilder); + let mut stmt = db.prepare(sql.as_str())?; + let items = stmt.query_map(&*params.as_params(), |row| row.try_into())?; + Ok(items.map(|v| v.unwrap()).collect()) +} + pub async fn get_key_value_raw( mgr: &impl Manager, namespace: &str, @@ -767,7 +779,7 @@ pub async fn list_environments( ..Default::default() }, ) - .await?; + .await?; environments.push(base_environment); } @@ -857,7 +869,7 @@ pub async fn update_settings( None => None, Some(p) => Some(serde_json::to_string(&p)?), }) - .into(), + .into(), ), ]) .returning_all() @@ -1182,7 +1194,7 @@ pub async fn duplicate_folder( ..src_folder.clone() }, ) - .await?; + .await?; for m in http_requests { upsert_http_request( @@ -1194,7 +1206,7 @@ pub async fn duplicate_folder( ..m }, ) - .await?; + .await?; } for m in grpc_requests { upsert_grpc_request( @@ -1206,7 +1218,7 @@ pub async fn duplicate_folder( ..m }, ) - .await?; + .await?; } for m in folders { // Recurse down @@ -1217,7 +1229,7 @@ pub async fn duplicate_folder( ..m }, )) - .await?; + .await?; } Ok(()) } @@ -1376,7 +1388,7 @@ pub async fn create_default_http_response( None, None, ) - .await + .await } #[allow(clippy::too_many_arguments)] diff --git a/src-web/components/CommandPalette.tsx b/src-web/components/CommandPalette.tsx index 0c45ab72..8e102ea6 100644 --- a/src-web/components/CommandPalette.tsx +++ b/src-web/components/CommandPalette.tsx @@ -64,7 +64,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) { const recentWorkspaces = useRecentWorkspaces(); const requests = useRequests(); const activeRequest = useActiveRequest(); - const recentRequests = useRecentRequests(); + const [recentRequests] = useRecentRequests(); const openWorkspace = useOpenWorkspace(); const createWorkspace = useCreateWorkspace(); const createHttpRequest = useCreateHttpRequest(); @@ -78,6 +78,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) { const [, setSidebarHidden] = useSidebarHidden(); const openSettings = useOpenSettings(); const navigate = useNavigate(); + const { baseEnvironment } = useEnvironments(); const workspaceCommands = useMemo(() => { const commands: CommandPaletteItem[] = [ @@ -131,7 +132,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) { { key: 'environment.create', label: 'Create Environment', - onSelect: createEnvironment.mutate, + onSelect: () => createEnvironment.mutate(baseEnvironment), }, { key: 'sidebar.toggle', @@ -180,7 +181,8 @@ export function CommandPalette({ onClose }: { onClose: () => void }) { activeCookieJar?.id, activeEnvironment, activeRequest, - createEnvironment.mutate, + baseEnvironment, + createEnvironment, createGrpcRequest, createHttpRequest, createWorkspace.mutate, @@ -375,6 +377,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) { const handleKeyDown = useCallback( (e: KeyboardEvent) => { const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key); + console.log("ENDER", e.key); if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) { const next = filteredAllItems[index + 1] ?? filteredAllItems[0]; diff --git a/src-web/components/GlobalHooks.tsx b/src-web/components/GlobalHooks.tsx index 8fbd29f7..075d76ce 100644 --- a/src-web/components/GlobalHooks.tsx +++ b/src-web/components/GlobalHooks.tsx @@ -1,9 +1,10 @@ import { emit } from '@tauri-apps/api/event'; import type { PromptTextRequest, PromptTextResponse } from '@yaakapp-internal/plugin'; -import { useEnsureActiveCookieJar } from '../hooks/useActiveCookieJar'; +import { useEnsureActiveCookieJar, useSubscribeActiveCookieJar } from '../hooks/useActiveCookieJar'; +import { useSubscribeActiveEnvironmentId } from '../hooks/useActiveEnvironment'; import { useActiveRequest } from '../hooks/useActiveRequest'; -import {useSubscribeActiveRequestId} from "../hooks/useActiveRequestId"; -import {useSubscribeActiveWorkspaceId} from "../hooks/useActiveWorkspace"; +import { useSubscribeActiveRequestId } from '../hooks/useActiveRequestId'; +import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace'; import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast'; import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest'; import { useDuplicateHttpRequest } from '../hooks/useDuplicateHttpRequest'; @@ -14,13 +15,14 @@ import { useNotificationToast } from '../hooks/useNotificationToast'; import { usePrompt } from '../hooks/usePrompt'; import { useRecentCookieJars } from '../hooks/useRecentCookieJars'; import { useRecentEnvironments } from '../hooks/useRecentEnvironments'; -import { useRecentRequests } from '../hooks/useRecentRequests'; +import { useSubscribeRecentRequests } from '../hooks/useRecentRequests'; import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces'; import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting'; import { useSyncModelStores } from '../hooks/useSyncModelStores'; import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels'; -import {useSyncWorkspaceRequestTitle} from "../hooks/useSyncWorkspaceRequestTitle"; +import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle'; import { useSyncZoomSetting } from '../hooks/useSyncZoomSetting'; +import { useSubscribeTemplateFunctions } from '../hooks/useTemplateFunctions'; import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette'; export function GlobalHooks() { @@ -36,8 +38,11 @@ export function GlobalHooks() { useRecentWorkspaces(); useRecentEnvironments(); useRecentCookieJars(); - useRecentRequests(); + useSubscribeRecentRequests(); useSyncWorkspaceChildModels(); + useSubscribeTemplateFunctions(); + useSubscribeActiveEnvironmentId(); + useSubscribeActiveCookieJar(); // Other useful things useNotificationToast(); diff --git a/src-web/components/RecentRequestsDropdown.tsx b/src-web/components/RecentRequestsDropdown.tsx index b06881a4..981d45fb 100644 --- a/src-web/components/RecentRequestsDropdown.tsx +++ b/src-web/components/RecentRequestsDropdown.tsx @@ -18,7 +18,7 @@ export function RecentRequestsDropdown({ className }: Pick(null); const activeRequest = useActiveRequest(); const activeWorkspace = useActiveWorkspace(); - const allRecentRequestIds = useRecentRequests(); + const [allRecentRequestIds] = useRecentRequests(); const recentRequestIds = useMemo(() => allRecentRequestIds.slice(1), [allRecentRequestIds]); const requests = useRequests(); const navigate = useNavigate(); diff --git a/src-web/components/RedirectToLatestWorkspace.tsx b/src-web/components/RedirectToLatestWorkspace.tsx index b9326d7d..ecd852b3 100644 --- a/src-web/components/RedirectToLatestWorkspace.tsx +++ b/src-web/components/RedirectToLatestWorkspace.tsx @@ -22,18 +22,19 @@ export function RedirectToLatestWorkspace() { const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? null; const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? null; const requestId = (await getRecentRequests(workspaceId))[0] ?? null; + const search = { cookie_jar_id: cookieJarId, environment_id: environmentId }; if (workspaceId != null && requestId != null) { await navigate({ to: '/workspaces/$workspaceId/requests/$requestId', params: { workspaceId, requestId }, - search: { cookieJarId, environmentId }, + search, }); } else { await navigate({ to: '/workspaces/$workspaceId', params: { workspaceId }, - search: { cookieJarId, environmentId }, + search, }); } })(); diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index 69b65eea..733ce8bf 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -140,8 +140,28 @@ export const RequestPane = memo(function RequestPane({ value: activeRequest.bodyType, items: [ { type: 'separator', label: 'Form Data' }, - { label: 'Url Encoded', value: BODY_TYPE_FORM_URLENCODED }, - { label: 'Multi-Part', value: BODY_TYPE_FORM_MULTIPART }, + { + label: ( + <> + Url Encoded + + + ), + value: BODY_TYPE_FORM_URLENCODED, + }, + { + label: ( + <> + Url Encoded + + + ), + value: BODY_TYPE_FORM_MULTIPART, + }, { type: 'separator', label: 'Text Content' }, { label: 'GraphQL', value: BODY_TYPE_GRAPHQL }, { label: 'JSON', value: BODY_TYPE_JSON }, @@ -252,6 +272,7 @@ export const RequestPane = memo(function RequestPane({ [ activeRequest.authentication, activeRequest.authenticationType, + activeRequest.body, activeRequest.bodyType, activeRequest.description, activeRequest.headers, diff --git a/src-web/components/RouteError.tsx b/src-web/components/RouteError.tsx index 9568a2e8..7ad05aff 100644 --- a/src-web/components/RouteError.tsx +++ b/src-web/components/RouteError.tsx @@ -1,27 +1,33 @@ -import { useNavigate } from '@tanstack/react-router'; -import { useRouteError } from 'react-router-dom'; import { Button } from './core/Button'; import { FormattedError } from './core/FormattedError'; import { Heading } from './core/Heading'; import { VStack } from './core/Stacks'; -export default function RouteError() { - const navigate = useNavigate(); - const error = useRouteError(); +export default function RouteError({ error }: { error: unknown; reset: () => void }) { console.log('Error', error); const stringified = JSON.stringify(error); // eslint-disable-next-line @typescript-eslint/no-explicit-any const message = (error as any).message ?? stringified; + const stack = + typeof error === 'object' && error != null && 'stack' in error ? String(error.stack) : null; return (
- + Route Error 🔥 - {message} + + {message} + {stack && ( +
+ Stack Trace +
{stack}
+
+ )} +
- {children} + {collapsed ? null : children} ); }); diff --git a/src-web/components/SidebarItems.tsx b/src-web/components/SidebarItems.tsx index 615d7921..0186fb34 100644 --- a/src-web/components/SidebarItems.tsx +++ b/src-web/components/SidebarItems.tsx @@ -18,7 +18,6 @@ export interface SidebarItemsProps { handleEnd: (id: string) => void; handleDragStart: (id: string) => void; onSelect: (requestId: string) => void; - isCollapsed: (id: string) => boolean; httpResponses: HttpResponse[]; grpcConnections: GrpcConnection[]; } @@ -29,7 +28,6 @@ export const SidebarItems = memo(function SidebarItems({ draggingId, onSelect, treeParentMap, - isCollapsed, hoveredTree, hoveredIndex, handleEnd, @@ -71,11 +69,9 @@ export const SidebarItems = memo(function SidebarItems({ onEnd={handleEnd} onSelect={onSelect} onDragStart={handleDragStart} - isCollapsed={isCollapsed} child={child} > {child.item.model === 'folder' && - !isCollapsed(child.item.id) && draggingId !== child.item.id && ( diff --git a/src-web/components/core/PlainInput.tsx b/src-web/components/core/PlainInput.tsx index c1eb34e1..46a0dea2 100644 --- a/src-web/components/core/PlainInput.tsx +++ b/src-web/components/core/PlainInput.tsx @@ -27,16 +27,13 @@ export function PlainInput({ onChange, onFocus, onPaste, - placeholder, require, rightSlot, size = 'md', type = 'text', validate, autoSelect, - step, - autoFocus, - readOnly, + ...props }: PlainInputProps) { const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]); const [currentValue, setCurrentValue] = useState(defaultValue ?? ''); @@ -130,7 +127,6 @@ export function PlainInput({ id={id} type={type === 'password' && !obscured ? 'text' : type} defaultValue={defaultValue} - placeholder={placeholder} autoComplete="off" autoCapitalize="off" autoCorrect="off" @@ -139,9 +135,7 @@ export function PlainInput({ className={classNames(commonClassName, 'h-auto')} onFocus={handleFocus} onBlur={handleBlur} - autoFocus={autoFocus} - step={step} - readOnly={readOnly} + {...props} /> {type === 'password' && ( diff --git a/src-web/components/core/RadioDropdown.tsx b/src-web/components/core/RadioDropdown.tsx index eb5e4252..e7d7d241 100644 --- a/src-web/components/core/RadioDropdown.tsx +++ b/src-web/components/core/RadioDropdown.tsx @@ -7,7 +7,7 @@ import { Icon } from './Icon'; export type RadioDropdownItem = | { type?: 'default'; - label: string; + label: ReactNode; shortLabel?: string; value: T; rightSlot?: ReactNode; diff --git a/src-web/hooks/useActiveCookieJar.ts b/src-web/hooks/useActiveCookieJar.ts index f6ee9d2a..901bded5 100644 --- a/src-web/hooks/useActiveCookieJar.ts +++ b/src-web/hooks/useActiveCookieJar.ts @@ -1,18 +1,58 @@ import { useNavigate, useSearch } from '@tanstack/react-router'; -import { useCallback, useEffect, useMemo } from 'react'; -import { useCookieJars } from './useCookieJars'; +import type { CookieJar } from '@yaakapp-internal/models'; +import { atom, useAtomValue } from 'jotai/index'; +import { useCallback, useEffect } from 'react'; +import { jotaiStore } from '../lib/jotai'; +import { cookieJarsAtom, useCookieJars } from './useCookieJars'; export const QUERY_COOKIE_JAR_ID = 'cookie_jar_id'; +export const activeCookieJarIdAtom = atom(); + +export const activeCookieJarAtom = atom((get) => { + const activeId = get(activeCookieJarIdAtom); + return get(cookieJarsAtom)?.find((e) => e.id === activeId) ?? null; +}); + export function useActiveCookieJar() { - const [activeCookieJarId, setActiveCookieJarId] = useActiveCookieJarId(); - const cookieJars = useCookieJars(); + const navigate = useNavigate({ from: '/workspaces/$workspaceId' }); + const setId = useCallback( + (id: string) => + navigate({ + search: (prev) => ({ ...prev, cookie_jar_id: id }), + }), + [navigate], + ); + const cookieJar = useAtomValue(activeCookieJarAtom); + return [cookieJar, setId] as const; +} - const activeCookieJar = useMemo(() => { - return cookieJars?.find((cookieJar) => cookieJar.id === activeCookieJarId) ?? null; - }, [activeCookieJarId, cookieJars]); +function useActiveCookieJarId() { + // NOTE: This query param is accessed from Rust side, so do not change + const { cookie_jar_id: id } = useSearch({ strict: false }); + const navigate = useNavigate({ from: '/workspaces/$workspaceId' }); - return [activeCookieJar ?? null, setActiveCookieJarId] as const; + const setId = useCallback( + (id: string) => + navigate({ + search: (prev) => ({ ...prev, cookie_jar_id: id }), + }), + [navigate], + ); + + return [id, setId] as const; +} + +export function useSubscribeActiveCookieJar() { + const { cookie_jar_id } = useSearch({ strict: false }); + useEffect( + () => jotaiStore.set(activeCookieJarIdAtom, cookie_jar_id ?? undefined), + [cookie_jar_id], + ); +} + +export function getActiveCookieJar() { + return jotaiStore.get(activeCookieJarAtom); } export function useEnsureActiveCookieJar() { @@ -37,19 +77,3 @@ export function useEnsureActiveCookieJar() { setActiveCookieJarId(firstJar.id).catch(console.error); }, [activeCookieJarId, cookieJars, setActiveCookieJarId]); } - -function useActiveCookieJarId() { - // NOTE: This query param is accessed from Rust side, so do not change - const { cookie_jar_id: id } = useSearch({ strict: false }); - const navigate = useNavigate({ from: '/workspaces/$workspaceId' }); - - const setId = useCallback( - (id: string) => - navigate({ - search: (prev) => ({ ...prev, cookie_jar_id: id }), - }), - [navigate], - ); - - return [id, setId] as const; -} diff --git a/src-web/hooks/useActiveEnvironment.ts b/src-web/hooks/useActiveEnvironment.ts index d6957a1c..660a6046 100644 --- a/src-web/hooks/useActiveEnvironment.ts +++ b/src-web/hooks/useActiveEnvironment.ts @@ -1,28 +1,41 @@ import { useNavigate, useSearch } from '@tanstack/react-router'; -import { useCallback } from 'react'; -import { useEnvironments } from './useEnvironments'; - -export function useActiveEnvironment() { - const [id, setId] = useActiveEnvironmentId(); - const { subEnvironments } = useEnvironments(); - const environment = subEnvironments.find((w) => w.id === id) ?? null; - return [environment, setId] as const; -} +import type { Environment } from '@yaakapp-internal/models'; +import { useAtomValue } from 'jotai'; +import { atom } from 'jotai/index'; +import { useCallback, useEffect } from 'react'; +import { jotaiStore } from '../lib/jotai'; +import { environmentsAtom } from './useEnvironments'; export const QUERY_ENVIRONMENT_ID = 'environment_id'; -function useActiveEnvironmentId() { - // NOTE: This query param is accessed from Rust side, so do not change - const { environment_id: id} = useSearch({ strict: false }); - const navigate = useNavigate({ from: '/workspaces/$workspaceId' }); +export const activeEnvironmentIdAtom = atom(); +export const activeEnvironmentAtom = atom((get) => { + const activeEnvironmentId = get(activeEnvironmentIdAtom); + return get(environmentsAtom).find((e) => e.id === activeEnvironmentId) ?? null; +}); + +export function useActiveEnvironment() { + const navigate = useNavigate({ from: '/workspaces/$workspaceId' }); const setId = useCallback( - (environmentId: string | null) => + (id: string | null) => navigate({ - search: (prev) => ({ ...prev, environment_id: environmentId ?? undefined }), + search: (prev) => ({ ...prev, environment_id: id }), }), [navigate], ); - - return [id, setId] as const; + const environment = useAtomValue(activeEnvironmentAtom); + return [environment, setId] as const; +} + +export function getActiveEnvironment() { + return jotaiStore.get(activeEnvironmentAtom); +} + +export function useSubscribeActiveEnvironmentId() { + const { environment_id } = useSearch({ strict: false }); + useEffect( + () => jotaiStore.set(activeEnvironmentIdAtom, environment_id ?? undefined), + [environment_id], + ); } diff --git a/src-web/hooks/useActiveRequestId.ts b/src-web/hooks/useActiveRequestId.ts index 7438406d..b5ca41f7 100644 --- a/src-web/hooks/useActiveRequestId.ts +++ b/src-web/hooks/useActiveRequestId.ts @@ -1,7 +1,7 @@ import { useParams } from '@tanstack/react-router'; import { atom, useAtomValue } from 'jotai'; import { useEffect } from 'react'; -import {jotaiStore} from "../lib/jotai"; +import { jotaiStore } from '../lib/jotai'; export const activeRequestIdAtom = atom(); @@ -11,7 +11,5 @@ export function useActiveRequestId(): string | null { export function useSubscribeActiveRequestId() { const { requestId } = useParams({ strict: false }); - useEffect(() => { - jotaiStore.set(activeRequestIdAtom, requestId); - }, [requestId]); + useEffect(() => jotaiStore.set(activeRequestIdAtom, requestId), [requestId]); } diff --git a/src-web/hooks/useActiveWorkspace.ts b/src-web/hooks/useActiveWorkspace.ts index d61bab08..9783e157 100644 --- a/src-web/hooks/useActiveWorkspace.ts +++ b/src-web/hooks/useActiveWorkspace.ts @@ -18,7 +18,7 @@ function useActiveWorkspaceId(): string | null { } export function getActiveWorkspaceId() { - return jotaiStore.get(activeWorkspaceIdAtom); + return jotaiStore.get(activeWorkspaceIdAtom) ?? null; } export function useSubscribeActiveWorkspaceId() { diff --git a/src-web/hooks/useCreateEnvironment.ts b/src-web/hooks/useCreateEnvironment.ts index cd2bae52..64a9454d 100644 --- a/src-web/hooks/useCreateEnvironment.ts +++ b/src-web/hooks/useCreateEnvironment.ts @@ -15,9 +15,14 @@ export function useCreateEnvironment() { const workspace = useActiveWorkspace(); const setEnvironments = useSetAtom(environmentsAtom); - return useFastMutation({ + return useFastMutation({ + toastyError: true, mutationKey: ['create_environment'], mutationFn: async (baseEnvironment) => { + if (baseEnvironment == null) { + throw new Error('No base environment passed'); + } + const name = await prompt({ id: 'new-environment', title: 'New Environment', diff --git a/src-web/hooks/useCreateFolder.ts b/src-web/hooks/useCreateFolder.ts index 89014513..0330e33e 100644 --- a/src-web/hooks/useCreateFolder.ts +++ b/src-web/hooks/useCreateFolder.ts @@ -1,15 +1,14 @@ -import { useFastMutation } from './useFastMutation'; import type { Folder } from '@yaakapp-internal/models'; import { useSetAtom } from 'jotai'; import { trackEvent } from '../lib/analytics'; import { invokeCmd } from '../lib/tauri'; -import { useActiveWorkspace } from './useActiveWorkspace'; +import { getActiveWorkspaceId } from './useActiveWorkspace'; +import { useFastMutation } from './useFastMutation'; import { foldersAtom } from './useFolders'; import { usePrompt } from './usePrompt'; import { updateModelList } from './useSyncModelStores'; export function useCreateFolder() { - const workspace = useActiveWorkspace(); const prompt = usePrompt(); const setFolders = useSetAtom(foldersAtom); @@ -20,8 +19,8 @@ export function useCreateFolder() { >({ mutationKey: ['create_folder'], mutationFn: async (patch) => { - console.log("FOLDER", workspace); - if (workspace === null) { + const workspaceId = getActiveWorkspaceId(); + if (workspaceId == null) { throw new Error("Cannot create folder when there's no active workspace"); } @@ -40,7 +39,7 @@ export function useCreateFolder() { } patch.sortPriority = patch.sortPriority || -Date.now(); - return await invokeCmd('cmd_create_folder', { workspaceId: workspace.id, ...patch }); + return await invokeCmd('cmd_create_folder', { workspaceId, ...patch }); }, onSuccess: (folder) => { if (folder == null) return; diff --git a/src-web/hooks/useEnvironments.ts b/src-web/hooks/useEnvironments.ts index 15d4ca5e..afcb7eb2 100644 --- a/src-web/hooks/useEnvironments.ts +++ b/src-web/hooks/useEnvironments.ts @@ -6,7 +6,7 @@ export const environmentsAtom = atom([]); export function useEnvironments() { const allEnvironments = useAtomValue(environmentsAtom); - const baseEnvironment = allEnvironments.find((e) => e.environmentId == null); + const baseEnvironment = allEnvironments.find((e) => e.environmentId == null) ?? null; const subEnvironments = allEnvironments.filter((e) => e.environmentId === (baseEnvironment?.id ?? 'n/a')) ?? []; diff --git a/src-web/hooks/useFastMutation.ts b/src-web/hooks/useFastMutation.ts index 4e4af412..afdb025f 100644 --- a/src-web/hooks/useFastMutation.ts +++ b/src-web/hooks/useFastMutation.ts @@ -32,6 +32,8 @@ export function useFastMutation([]); + export function keyValueQueryKey({ namespace = DEFAULT_NAMESPACE, key, @@ -23,44 +29,60 @@ export function useKeyValue key: string | string[]; fallback: T; }) { - const query = useQuery({ - queryKey: keyValueQueryKey({ namespace, key }), - queryFn: async () => getKeyValue({ namespace, key, fallback }), - refetchOnWindowFocus: false, - }); + const keyValues = useAtomValue(keyValuesAtom); + const keyValue = + keyValues?.find((kv) => buildKeyValueKey(kv.key) === buildKeyValueKey(key)) ?? null; + const value = extractKeyValueOrFallback(keyValue, fallback); + const isLoading = keyValues == null; - const mutate = useMutation({ + const { mutateAsync } = useMutation({ mutationKey: ['set_key_value', namespace, key], mutationFn: (value) => setKeyValue({ namespace, key, value }), }); const set = useCallback( - async (value: ((v: T) => T) | T) => { - if (typeof value === 'function') { - await getKeyValue({ namespace, key, fallback }).then((kv) => { - const newV = value(kv); - if (newV === kv) return; - return mutate.mutateAsync(newV); - }); + async (valueOrUpdate: ((v: T) => T) | T) => { + if (typeof valueOrUpdate === 'function') { + const newV = valueOrUpdate(value); + if (newV === value) return; + await mutateAsync(newV); } else { // TODO: Make this only update if the value is different. I tried this but it seems query.data // is stale. - await mutate.mutateAsync(value); + await mutateAsync(valueOrUpdate); } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [typeof key === 'string' ? key : key.join('::'), namespace], + [typeof key === 'string' ? key : key.join('::'), namespace, value], ); - const reset = useCallback(async () => mutate.mutateAsync(fallback), [mutate, fallback]); + const reset = useCallback(async () => mutateAsync(fallback), [fallback, mutateAsync]); return useMemo( () => ({ - value: query.data, - isLoading: query.isLoading, + value, + isLoading, set, reset, }), - [query.data, query.isLoading, reset, set], + [isLoading, reset, set, value], ); } + +export function getKeyValue({ + namespace, + key, + fallback, +}: { + namespace?: 'global' | 'no_sync' | 'license'; + key: string | string[]; + fallback: T; +}) { + const keyValues = jotaiStore.get(keyValuesAtom); + const keyValue = + keyValues?.find( + (kv) => kv.namespace === namespace && buildKeyValueKey(kv.key) === buildKeyValueKey(key), + ) ?? null; + const value = extractKeyValueOrFallback(keyValue, fallback); + return value; +} diff --git a/src-web/hooks/useOpenWorkspace.ts b/src-web/hooks/useOpenWorkspace.ts index daa7aebd..c6207068 100644 --- a/src-web/hooks/useOpenWorkspace.ts +++ b/src-web/hooks/useOpenWorkspace.ts @@ -21,7 +21,7 @@ export function useOpenWorkspace() { const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined; const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined; const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? undefined; - const search = { environmentId, cookieJarId }; + const search = { environment_id: environmentId, cookie_jar_id: cookieJarId }; if (inNewWindow) { const location = router.buildLocation({ diff --git a/src-web/hooks/useRecentRequests.ts b/src-web/hooks/useRecentRequests.ts index d12d9f9a..94de837e 100644 --- a/src-web/hooks/useRecentRequests.ts +++ b/src-web/hooks/useRecentRequests.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo } from 'react'; +import { jotaiStore } from '../lib/jotai'; import { getKeyValue } from '../lib/keyValueStore'; -import { useActiveRequestId } from './useActiveRequestId'; +import { activeRequestIdAtom } from './useActiveRequestId'; import { useActiveWorkspace } from './useActiveWorkspace'; import { useKeyValue } from './useKeyValue'; import { useRequests } from './useRequests'; @@ -12,30 +13,38 @@ const fallback: string[] = []; export function useRecentRequests() { const requests = useRequests(); const activeWorkspace = useActiveWorkspace(); - const activeRequestId = useActiveRequestId(); - const kv = useKeyValue({ + const { set: setRecentRequests, value: recentRequests } = useKeyValue({ key: kvKey(activeWorkspace?.id ?? 'n/a'), namespace, fallback, }); - // Set history when active request changes - useEffect(() => { - kv.set((currentHistory) => { - if (activeRequestId === null) return currentHistory; - const withoutCurrentRequest = currentHistory.filter((id) => id !== activeRequestId); - return [activeRequestId, ...withoutCurrentRequest]; - }).catch(console.error); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeRequestId]); - const onlyValidIds = useMemo( - () => kv.value?.filter((id) => requests.some((r) => r.id === id)) ?? [], - [kv.value, requests], + () => recentRequests?.filter((id) => requests.some((r) => r.id === id)) ?? [], + [recentRequests, requests], ); - return onlyValidIds; + return [onlyValidIds, setRecentRequests] as const; +} + +export function useSubscribeRecentRequests() { + const [recentRequests, setRecentRequests] = useRecentRequests(); + + useEffect(() => { + return jotaiStore.sub(activeRequestIdAtom, () => { + const activeRequestId = jotaiStore.get(activeRequestIdAtom) ?? null; + if (recentRequests[0] === activeRequestId) { + // Nothing to do + return; + } + setRecentRequests((currentHistory) => { + if (activeRequestId === null) return currentHistory; + const withoutCurrentRequest = currentHistory.filter((id) => id !== activeRequestId); + return [activeRequestId, ...withoutCurrentRequest]; + }).catch(console.error); + }); + }, [recentRequests, setRecentRequests]); } export async function getRecentRequests(workspaceId: string) { diff --git a/src-web/hooks/useSendAnyHttpRequest.ts b/src-web/hooks/useSendAnyHttpRequest.ts index d61fd490..e6c302bf 100644 --- a/src-web/hooks/useSendAnyHttpRequest.ts +++ b/src-web/hooks/useSendAnyHttpRequest.ts @@ -1,16 +1,14 @@ -import { useFastMutation } from './useFastMutation'; import type { HttpResponse } from '@yaakapp-internal/models'; import { trackEvent } from '../lib/analytics'; import { getHttpRequest } from '../lib/store'; import { invokeCmd } from '../lib/tauri'; -import { useActiveCookieJar } from './useActiveCookieJar'; -import { useActiveEnvironment } from './useActiveEnvironment'; +import { getActiveCookieJar } from './useActiveCookieJar'; +import { getActiveEnvironment } from './useActiveEnvironment'; import { useAlert } from './useAlert'; +import { useFastMutation } from './useFastMutation'; export function useSendAnyHttpRequest() { const alert = useAlert(); - const [environment] = useActiveEnvironment(); - const [activeCookieJar] = useActiveCookieJar(); return useFastMutation({ mutationKey: ['send_any_request'], mutationFn: async (id) => { @@ -21,8 +19,8 @@ export function useSendAnyHttpRequest() { return invokeCmd('cmd_send_http_request', { request, - environmentId: environment?.id, - cookieJarId: activeCookieJar?.id, + environmentId: getActiveEnvironment()?.id, + cookieJarId: getActiveCookieJar()?.id, }); }, onSettled: () => trackEvent('http_request', 'send'), diff --git a/src-web/hooks/useSidebarItemCollapsed.ts b/src-web/hooks/useSidebarItemCollapsed.ts new file mode 100644 index 00000000..86f40610 --- /dev/null +++ b/src-web/hooks/useSidebarItemCollapsed.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from 'react'; +import { jotaiStore } from '../lib/jotai'; +import { setKeyValue } from '../lib/keyValueStore'; +import { getActiveWorkspaceId } from './useActiveWorkspace'; +import { getKeyValue, keyValuesAtom } from './useKeyValue'; + +function kvKey(workspaceId: string | null) { + return ['sidebar_collapsed', workspaceId ?? 'n/a']; +} + +export function useSidebarItemCollapsed(itemId: string) { + const [isCollapsed, setIsCollapsed] = useState( + getSidebarCollapsedMap()[itemId] === true, + ); + useEffect( + () => + jotaiStore.sub(keyValuesAtom, () => { + setIsCollapsed(getSidebarCollapsedMap()[itemId] === true); + }), + [itemId], + ); + + const toggle = useCallback(() => { + setKeyValue({ + key: kvKey(getActiveWorkspaceId()), + namespace: 'no_sync', + value: { ...getSidebarCollapsedMap(), [itemId]: !isCollapsed }, + }).catch(console.error); + }, [isCollapsed, itemId]); + + return [isCollapsed, toggle] as const; +} + +export function getSidebarCollapsedMap() { + const activeWorkspaceId = getActiveWorkspaceId(); + if (activeWorkspaceId == null) return {}; + + const value = getKeyValue>({ + key: kvKey(activeWorkspaceId), + fallback: {}, + namespace: 'no_sync', + }); + + return value; +} diff --git a/src-web/hooks/useSyncModelStores.ts b/src-web/hooks/useSyncModelStores.ts index 48dbb25e..d3157fe3 100644 --- a/src-web/hooks/useSyncModelStores.ts +++ b/src-web/hooks/useSyncModelStores.ts @@ -1,8 +1,8 @@ import { useQueryClient } from '@tanstack/react-query'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; -import type { AnyModel } from '@yaakapp-internal/models'; +import type { AnyModel, KeyValue } from '@yaakapp-internal/models'; import { jotaiStore } from '../lib/jotai'; -import { extractKeyValue } from '../lib/keyValueStore'; +import { buildKeyValueKey } from '../lib/keyValueStore'; import { modelsEq } from '../lib/model_util'; import { useActiveWorkspace } from './useActiveWorkspace'; import { cookieJarsAtom } from './useCookieJars'; @@ -13,7 +13,7 @@ import { grpcEventsQueryKey } from './useGrpcEvents'; import { grpcRequestsAtom } from './useGrpcRequests'; import { httpRequestsAtom } from './useHttpRequests'; import { httpResponsesAtom } from './useHttpResponses'; -import { keyValueQueryKey } from './useKeyValue'; +import { keyValueQueryKey, keyValuesAtom } from './useKeyValue'; import { useListenToTauriEvent } from './useListenToTauriEvent'; import { pluginsAtom } from './usePlugins'; import { useRequestUpdateKey } from './useRequestUpdateKey'; @@ -71,14 +71,11 @@ export function useSyncModelStores() { jotaiStore.set(cookieJarsAtom, updateModelList(model)); } else if (model.model === 'settings') { jotaiStore.set(settingsAtom, model); + } else if (model.model === 'key_value') { + jotaiStore.set(keyValuesAtom, updateModelList(model)); } else if (queryKey != null) { // TODO: Convert all models to use Jotai queryClient.setQueryData(queryKey, (current: unknown) => { - if (model.model === 'key_value') { - // Special-case for KeyValue - return extractKeyValue(model); - } - if (Array.isArray(current)) { return updateModelList(model)(current); } @@ -111,7 +108,7 @@ export function useSyncModelStores() { } else if (model.model === 'grpc_event') { queryClient.setQueryData(grpcEventsQueryKey(model), removeModelById(model)); } else if (model.model === 'key_value') { - queryClient.setQueryData(keyValueQueryKey(model), undefined); + queryClient.setQueryData(keyValueQueryKey(model), removeModelByKeyValue(model)); } else if (model.model === 'cookie_jar') { jotaiStore.set(cookieJarsAtom, removeModelById(model)); } @@ -136,6 +133,18 @@ export function removeModelById(model: T) { return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id) ?? []; } +export function removeModelByKeyValue(model: KeyValue) { + return (entries: KeyValue[] | undefined) => + entries?.filter( + (e) => + !( + e.namespace === model.namespace && + buildKeyValueKey(e.key) === buildKeyValueKey(model.key) && + e.value == model.value + ), + ) ?? []; +} + const shouldIgnoreModel = (payload: AnyModel, windowLabel: string) => { if (windowLabel === getCurrentWebviewWindow().label) { // Never ignore same-window updates diff --git a/src-web/hooks/useSyncWorkspaceChildModels.ts b/src-web/hooks/useSyncWorkspaceChildModels.ts index ec0378e5..54174fb0 100644 --- a/src-web/hooks/useSyncWorkspaceChildModels.ts +++ b/src-web/hooks/useSyncWorkspaceChildModels.ts @@ -1,7 +1,7 @@ -import { useSetAtom } from 'jotai/index'; import { useEffect } from 'react'; +import { jotaiStore } from '../lib/jotai'; import { invokeCmd } from '../lib/tauri'; -import { useActiveWorkspace } from './useActiveWorkspace'; +import { activeWorkspaceIdAtom, getActiveWorkspaceId } from './useActiveWorkspace'; import { cookieJarsAtom } from './useCookieJars'; import { environmentsAtom } from './useEnvironments'; import { foldersAtom } from './useFolders'; @@ -9,36 +9,28 @@ import { grpcConnectionsAtom } from './useGrpcConnections'; import { grpcRequestsAtom } from './useGrpcRequests'; import { httpRequestsAtom } from './useHttpRequests'; import { httpResponsesAtom } from './useHttpResponses'; +import { keyValuesAtom } from './useKeyValue'; export function useSyncWorkspaceChildModels() { - const setCookieJars = useSetAtom(cookieJarsAtom); - const setFolders = useSetAtom(foldersAtom); - const setHttpRequests = useSetAtom(httpRequestsAtom); - const setHttpResponses = useSetAtom(httpResponsesAtom); - const setGrpcConnections = useSetAtom(grpcConnectionsAtom); - const setGrpcRequests = useSetAtom(grpcRequestsAtom); - const setEnvironments = useSetAtom(environmentsAtom); - - const workspace = useActiveWorkspace(); - const workspaceId = workspace?.id; useEffect(() => { - if (workspaceId == null) { - return; - } - (async function () { - console.log('Syncing model stores', { workspaceId }); - // Set the things we need first, first - setHttpRequests(await invokeCmd('cmd_list_http_requests', { workspaceId })); - setGrpcRequests(await invokeCmd('cmd_list_grpc_requests', { workspaceId })); - setFolders(await invokeCmd('cmd_list_folders', { workspaceId })); - - // Then, set the rest - setCookieJars(await invokeCmd('cmd_list_cookie_jars', { workspaceId })); - setHttpResponses(await invokeCmd('cmd_list_http_responses', { workspaceId })); - setGrpcConnections(await invokeCmd('cmd_list_grpc_connections', { workspaceId })); - setEnvironments(await invokeCmd('cmd_list_environments', { workspaceId })); - })().catch(console.error); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId]); + jotaiStore.sub(activeWorkspaceIdAtom, sync); + sync().catch(console.error); + }, []); +} + +async function sync() { + const workspaceId = getActiveWorkspaceId(); + const args = { workspaceId }; + console.log('Syncing model stores', args); + // Set the things we need first, first + jotaiStore.set(httpRequestsAtom, await invokeCmd('cmd_list_http_requests', args)); + jotaiStore.set(grpcRequestsAtom, await invokeCmd('cmd_list_grpc_requests', args)); + jotaiStore.set(foldersAtom, await invokeCmd('cmd_list_folders', args)); + + // Then, set the rest + jotaiStore.set(keyValuesAtom, await invokeCmd('cmd_list_key_values', args)); + jotaiStore.set(cookieJarsAtom, await invokeCmd('cmd_list_cookie_jars', args)); + jotaiStore.set(httpResponsesAtom, await invokeCmd('cmd_list_http_responses', args)); + jotaiStore.set(grpcConnectionsAtom, await invokeCmd('cmd_list_grpc_connections', args)); + jotaiStore.set(environmentsAtom, await invokeCmd('cmd_list_environments', args)); } diff --git a/src-web/hooks/useTemplateFunctions.ts b/src-web/hooks/useTemplateFunctions.ts index 07e44118..6e0b0b4d 100644 --- a/src-web/hooks/useTemplateFunctions.ts +++ b/src-web/hooks/useTemplateFunctions.ts @@ -1,14 +1,23 @@ import { useQuery } from '@tanstack/react-query'; -import type { GetTemplateFunctionsResponse } from '@yaakapp-internal/plugin'; +import type { GetTemplateFunctionsResponse, TemplateFunction } from '@yaakapp-internal/plugin'; +import { atom, useAtomValue } from 'jotai'; +import { useSetAtom } from 'jotai/index'; import { useState } from 'react'; import { invokeCmd } from '../lib/tauri'; import { usePluginsKey } from './usePlugins'; +const templateFunctionsAtom = atom([]); + export function useTemplateFunctions() { + return useAtomValue(templateFunctionsAtom); +} + +export function useSubscribeTemplateFunctions() { const pluginsKey = usePluginsKey(); const [numFns, setNumFns] = useState(0); + const setAtom = useSetAtom(templateFunctionsAtom); - const result = useQuery({ + useQuery({ queryKey: ['template_functions', pluginsKey], // Fetch periodically until functions are returned // NOTE: visibilitychange (refetchOnWindowFocus) does not work on Windows, so we'll rely on this logic @@ -19,9 +28,9 @@ export function useTemplateFunctions() { queryFn: async () => { const result = await invokeCmd('cmd_template_functions'); setNumFns(result.length); - return result; + const functions = result.flatMap((r) => r.functions) ?? []; + setAtom(functions); + return functions; }, }); - - return result.data?.flatMap((r) => r.functions) ?? []; } diff --git a/src-web/lib/keyValueStore.ts b/src-web/lib/keyValueStore.ts index e8b6dd20..5237559a 100644 --- a/src-web/lib/keyValueStore.ts +++ b/src-web/lib/keyValueStore.ts @@ -54,7 +54,7 @@ export function extractKeyValue(kv: KeyValue | null): T | undefined { } } -function extractKeyValueOrFallback(kv: KeyValue | null, fallback: T): T { +export function extractKeyValueOrFallback(kv: KeyValue | null, fallback: T): T { const v = extractKeyValue(kv); if (v === undefined) return fallback; return v; diff --git a/src-web/lib/tauri.ts b/src-web/lib/tauri.ts index 0538d893..2062bb7e 100644 --- a/src-web/lib/tauri.ts +++ b/src-web/lib/tauri.ts @@ -45,6 +45,7 @@ type TauriCmd = | 'cmd_import_data' | 'cmd_install_plugin' | 'cmd_list_cookie_jars' + | 'cmd_list_key_values' | 'cmd_list_environments' | 'cmd_list_folders' | 'cmd_list_grpc_connections' @@ -79,7 +80,7 @@ type TauriCmd = | 'cmd_write_file_dev'; export async function invokeCmd(cmd: TauriCmd, args?: InvokeArgs): Promise { - // console.log('RUN COMMAND', cmd, args); + console.log('RUN COMMAND', cmd, args); try { return await invoke(cmd, args); } catch (err) { diff --git a/src-web/routes/__root.tsx b/src-web/routes/__root.tsx index 73dc364c..6c0f1939 100644 --- a/src-web/routes/__root.tsx +++ b/src-web/routes/__root.tsx @@ -7,11 +7,12 @@ import React, { Suspense } from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { HelmetProvider } from 'react-helmet-async'; +import { DialogProvider, Dialogs } from '../components/Dialogs'; import { GlobalHooks } from '../components/GlobalHooks'; +import RouteError from '../components/RouteError'; +import { ToastProvider, Toasts } from '../components/Toasts'; import { useOsInfo } from '../hooks/useOsInfo'; import { jotaiStore } from '../lib/jotai'; -import { ToastProvider, Toasts } from '../components/Toasts'; -import { DialogProvider, Dialogs } from '../components/Dialogs'; const queryClient = new QueryClient({ queryCache: new QueryCache({ @@ -52,6 +53,7 @@ const ReactQueryDevtools = export const Route = createRootRoute({ component: RouteComponent, + errorComponent: RouteError, }); function RouteComponent() {