diff --git a/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx b/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx index 91a3e31e..a82d0aef 100644 --- a/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx +++ b/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx @@ -1,6 +1,8 @@ import type { HttpResponse } from "@yaakapp-internal/models"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse"; -import { useResponseBodyText } from "../../hooks/useResponseBodyText"; +import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText"; import { useResponseFilter } from "../../hooks/useResponseFilter"; import { useSaveResponse } from "../../hooks/useSaveResponse"; import { languageFromContentType } from "../../lib/contentType"; @@ -52,8 +54,16 @@ interface HttpTextViewerProps { } function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) { - const filter = useResponseFilter({ stateKey: `response.body.${response.requestId}` }); - const filteredBody = useResponseBodyText({ response, filter: filter.debouncedFilterText }); + const queryClient = useQueryClient(); + const filter = useResponseFilter({ + stateKey: `response.body.${response.requestId}`, + // Shares the display query's cache entry, so the verdict costs no extra RPC + runFilter: useCallback( + (f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })), + [queryClient, response], + ), + }); + const filteredBody = useResponseBodyText({ response, filter: filter.appliedFilter }); const saveResponse = useSaveResponse(response); const copyResponse = useCopyHttpResponse(response); const actionsDisabled = response.state !== "closed" && response.status >= 100; diff --git a/apps/yaak-client/components/responseViewers/RecentFiltersDropdown.tsx b/apps/yaak-client/components/responseViewers/RecentFiltersDropdown.tsx new file mode 100644 index 00000000..507928f1 --- /dev/null +++ b/apps/yaak-client/components/responseViewers/RecentFiltersDropdown.tsx @@ -0,0 +1,96 @@ +import { Icon } from "@yaakapp-internal/ui"; +import type { RecentFilter } from "../../hooks/useRecentFilters"; +import { Dropdown, type DropdownItem } from "../core/Dropdown"; +import { IconButton } from "../core/IconButton"; + +interface Props { + recentFilters: RecentFilter[]; + activeFilter: string | null; + onSelect: (value: string) => void; + onRemove: (value: string) => void; + onTogglePin: (value: string) => void; + onClear: () => void; +} + +export function RecentFiltersDropdown({ + recentFilters, + activeFilter, + onSelect, + onRemove, + onTogglePin, + onClear, +}: Props) { + const pinned = recentFilters.filter((f) => f.pinned); + const unpinned = recentFilters.filter((f) => !f.pinned); + + const toItem = (filter: RecentFilter): DropdownItem => ({ + label: ( +
+ {filter.value} +
+ ), + leftSlot: , + onSelect: () => onSelect(filter.value), + submenuTrigger: "button", + submenu: [ + { + label: filter.pinned ? "Unpin" : "Pin", + icon: filter.pinned ? "unpin" : "pin", + keepOpenOnSelect: true, + onSelect: () => onTogglePin(filter.value), + }, + { + label: "Remove", + icon: "trash", + color: "danger", + keepOpenOnSelect: true, + onSelect: () => onRemove(filter.value), + }, + ], + }); + + const items: DropdownItem[] = []; + + if (recentFilters.length === 0) { + items.push({ + type: "content", + label: ( + + Filters you use are remembered here + + ), + }); + } + + if (pinned.length > 0) { + items.push({ type: "separator", label: "Pinned" }, ...pinned.map(toItem)); + } + + if (unpinned.length > 0) { + items.push({ type: "separator", label: "Recent" }, ...unpinned.map(toItem)); + } + + if (recentFilters.length > 0) { + items.push( + { type: "separator" }, + { + label: "Clear All", + leftSlot: , + color: "danger", + onSelect: onClear, + }, + ); + } + + return ( + + + + ); +} diff --git a/apps/yaak-client/components/responseViewers/TextViewer.tsx b/apps/yaak-client/components/responseViewers/TextViewer.tsx index 533283ff..d277992c 100644 --- a/apps/yaak-client/components/responseViewers/TextViewer.tsx +++ b/apps/yaak-client/components/responseViewers/TextViewer.tsx @@ -1,13 +1,15 @@ -import classNames from "classnames"; import type { ReactNode } from "react"; -import { Children, useMemo } from "react"; +import { Children, useCallback, useMemo } from "react"; +import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui"; import { useFormatText } from "../../hooks/useFormatText"; import type { ResponseFilterApi } from "../../hooks/useResponseFilter"; +import { Button } from "../core/Button"; import type { EditorProps } from "../core/Editor/Editor"; import { hyperlink } from "../core/Editor/hyperlink/extension"; import { Editor } from "../core/Editor/LazyEditor"; import { IconButton } from "../core/IconButton"; import { Input } from "../core/Input"; +import { RecentFiltersDropdown } from "./RecentFiltersDropdown"; const extraExtensions = [hyperlink]; @@ -18,9 +20,7 @@ interface Props { pretty?: boolean; className?: string; footerActions?: ReactNode; - /** Filter state, from useResponseFilter in whichever component runs the filter */ filter?: ResponseFilterApi; - /** Result of applying `filter.debouncedFilterText` to the body */ filterResult?: { data: string | null | undefined; isPending: boolean; @@ -41,9 +41,21 @@ export function TextViewer({ const canFilter = filter != null && (language === "json" || language === "xml" || language === "html"); const isSearching = filter?.isSearching ?? false; - const filterText = filter?.filterText ?? null; + const appliedFilter = filter?.appliedFilter ?? null; const resultError = filterResult?.error ?? false; + const handleFilterKeyDown = useCallback( + (e: KeyboardEvent) => { + if (filter == null) return; + if (e.key === "Escape") { + filter.toggleSearch(); + } else if (e.key === "Enter" && filter.filterText != null) { + filter.applyFilter(filter.filterText); + } + }, + [filter], + ); + const actions = useMemo(() => { const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions); @@ -62,37 +74,62 @@ export function TextViewer({ placeholder={language === "json" ? "JSONPath expression" : "XPath expression"} label="Filter expression" name="filter" - defaultValue={filterText} - onKeyDown={(e) => e.key === "Escape" && filter.toggleSearch()} + defaultValue={filter.filterText} + forceUpdateKey={filter.filterUpdateKey} + onKeyDown={handleFilterKeyDown} onChange={filter.setFilterText} stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null} + leftSlot={ +
+ +
+ } + rightSlot={ +
+ +
+ } /> , ); + } else { + nodes.push( + , + ); } - nodes.push( - , - ); - return nodes; }, [ canFilter, footerActions, filter, - filterText, filterResult?.isPending, resultError, isSearching, language, + handleFilterKeyDown, ]); const formattedBody = useFormatText({ text, language, pretty: pretty ?? false }); @@ -101,7 +138,7 @@ export function TextViewer({ } let body: string; - if (isSearching && filterText != null && filterText.length > 0) { + if (appliedFilter) { if (resultError) { body = ""; } else { @@ -118,15 +155,61 @@ export function TextViewer({ } return ( - +
+ {appliedFilter && filter != null ? ( + filter.replaceFilter("")} + /> + ) : ( + + )} + +
+ ); +} + +/** + * Shows what's actually filtering the body, which the filter box below can't convey + * once it holds an edited expression that hasn't been applied yet. + */ +function AppliedFilterBar({ + filter, + error, + onClear, +}: { + filter: string; + error: boolean; + onClear: () => void; +}) { + return ( + + + + + Response filtered by {filter} + {error && " (invalid expression)"} + + + + ); } diff --git a/apps/yaak-client/hooks/useRecentFilters.ts b/apps/yaak-client/hooks/useRecentFilters.ts new file mode 100644 index 00000000..77a9fc82 --- /dev/null +++ b/apps/yaak-client/hooks/useRecentFilters.ts @@ -0,0 +1,67 @@ +import { useCallback } from "react"; +import { useKeyValue } from "./useKeyValue"; + +export interface RecentFilter { + value: string; + pinned?: boolean; +} + +const MAX_RECENT_FILTERS = 20; +const kvKey = (filterStateKey: string) => `recent_filters::${filterStateKey}`; +const namespace = "global"; +const fallback: RecentFilter[] = []; + +export function useRecentFilters(filterStateKey: string | null) { + const { value, set } = useKeyValue({ + key: kvKey(filterStateKey ?? "n/a"), + namespace, + fallback, + }); + + const addFilter = useCallback( + async (rawValue: string) => { + const value = rawValue.trim(); + if (filterStateKey == null || value === "") return; + await set((prev) => { + // Returning the same reference skips the write, so re-committing the + // expression already at the top (on every blur) costs nothing + if (prev[0]?.value === value) return prev; + const existing = prev.find((f) => f.value === value); + const rest = prev.filter((f) => f.value !== value); + return trim([{ value, pinned: existing?.pinned }, ...rest]); + }); + }, + [filterStateKey, set], + ); + + const removeFilter = useCallback( + async (value: string) => set((prev) => prev.filter((f) => f.value !== value)), + [set], + ); + + const togglePin = useCallback( + async (value: string) => + set((prev) => prev.map((f) => (f.value === value ? { ...f, pinned: !f.pinned } : f))), + [set], + ); + + const clearFilters = useCallback(async () => set([]), [set]); + + return { recentFilters: value ?? fallback, addFilter, removeFilter, togglePin, clearFilters }; +} + +/** Bound the list, evicting the oldest unpinned entries before any pinned ones */ +function trim(filters: RecentFilter[]): RecentFilter[] { + const excess = filters.length - MAX_RECENT_FILTERS; + if (excess <= 0) return filters; + + const evicted = new Set(); + for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) { + if (!filters[i]?.pinned) evicted.add(i); + } + for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) { + evicted.add(i); + } + + return filters.filter((_, i) => !evicted.has(i)); +} diff --git a/apps/yaak-client/hooks/useResponseBodyText.ts b/apps/yaak-client/hooks/useResponseBodyText.ts index d81368fa..c4d25df2 100644 --- a/apps/yaak-client/hooks/useResponseBodyText.ts +++ b/apps/yaak-client/hooks/useResponseBodyText.ts @@ -2,6 +2,25 @@ import { useQuery } from "@tanstack/react-query"; import type { HttpResponse } from "@yaakapp-internal/models"; import { getResponseBodyBytes, getResponseBodyText } from "../lib/responseBody"; +export function responseBodyTextQuery({ + response, + filter, +}: { + response: HttpResponse; + filter: string | null; +}) { + return { + queryKey: [ + "response_body_text", + response.id, + response.updatedAt, + response.contentLength, + filter ?? "", + ], + queryFn: () => getResponseBodyText({ response, filter }), + }; +} + export function useResponseBodyText({ response, filter, @@ -11,14 +30,7 @@ export function useResponseBodyText({ }) { return useQuery({ placeholderData: (prev) => prev, // Keep previous data on refetch - queryKey: [ - "response_body_text", - response.id, - response.updatedAt, - response.contentLength, - filter ?? "", - ], - queryFn: () => getResponseBodyText({ response, filter }), + ...responseBodyTextQuery({ response, filter }), }); } diff --git a/apps/yaak-client/hooks/useResponseFilter.ts b/apps/yaak-client/hooks/useResponseFilter.ts index ea263c8c..bc6cd004 100644 --- a/apps/yaak-client/hooks/useResponseFilter.ts +++ b/apps/yaak-client/hooks/useResponseFilter.ts @@ -1,33 +1,57 @@ -import { useDebouncedValue } from "@yaakapp-internal/ui"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { createGlobalState } from "react-use"; +import type { RecentFilter } from "./useRecentFilters"; +import { useRecentFilters } from "./useRecentFilters"; /** What's typed in the filter box. `null` means the filter box is closed */ const useFilterTextMap = createGlobalState>({}); +/** What's actually applied to the response. Only changes on an explicit apply */ +const useAppliedFilterMap = createGlobalState>({}); + export interface ResponseFilterApi { stateKey: string | null; - /** What's typed in the filter box, or `null` when the box is closed */ + /** Draft text in the filter box, or `null` when the box is closed */ filterText: string | null; - /** The expression to actually filter with, lagging `filterText` by a debounce */ - debouncedFilterText: string | null; + /** The expression currently filtering the response */ + appliedFilter: string | null; isSearching: boolean; + /** The box holds an expression that isn't the one currently applied */ + isDirty: boolean; + /** Bumped when the (uncontrolled) filter input must re-read its defaultValue */ + filterUpdateKey: number; setFilterText: (value: string | null) => void; + /** Apply the expression to the response, recording it if the filter accepts it */ + applyFilter: (value: string) => void; + /** Like applyFilter, but also replaces what's shown in the filter box */ + replaceFilter: (value: string) => void; toggleSearch: () => void; + recentFilters: RecentFilter[]; + removeRecentFilter: (value: string) => void; + togglePinRecentFilter: (value: string) => void; + clearRecentFilters: () => void; } /** - * Filter state for a response viewer, keyed so it persists across responses of the - * same request. + * Draft/applied state and history for a response filter (JSONPath/XPath). * - * Owned by the component that runs the filter, so the viewer can stay presentational. - * Evaluating the expression during the viewer's render (its previous shape) meant - * updating the parent mid-render, which React warns about. + * History records at most one entry per apply gesture, and only after `runFilter` + * confirms the plugin accepts the expression — the plugin is the sole judge of + * validity. Because nothing but the gesture ever writes, refetches can't resurrect + * deleted entries and a gesture can't record into another request's history. */ -export function useResponseFilter({ stateKey }: { stateKey: string | null }): ResponseFilterApi { +export function useResponseFilter({ + stateKey, + runFilter, +}: { + stateKey: string | null; + /** Evaluate an expression, rejecting if the filter plugin reports an error */ + runFilter: (filter: string) => Promise; +}): ResponseFilterApi { const [filterTextMap, setFilterTextMap] = useFilterTextMap(); + const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap(); const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null; - const debouncedFilterText = useDebouncedValue(filterText); + const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null; const setFilterText = useCallback( (v: string | null) => { @@ -37,17 +61,69 @@ export function useResponseFilter({ stateKey }: { stateKey: string | null }): Re [stateKey, setFilterTextMap], ); + const setAppliedFilter = useCallback( + (v: string | null) => { + if (!stateKey) return; + setAppliedFilterMap((m) => ({ ...m, [stateKey]: v })); + }, + [stateKey, setAppliedFilterMap], + ); + + const { + recentFilters, + addFilter, + removeFilter: removeRecentFilter, + togglePin: togglePinRecentFilter, + clearFilters: clearRecentFilters, + } = useRecentFilters(stateKey); + + const applyFilter = useCallback( + (value: string) => { + setFilterText(value); + const applied = value.trim() === "" ? null : value.trim(); + setAppliedFilter(applied); + if (applied == null) return; + runFilter(applied).then( + () => addFilter(applied), + () => {}, // Rejected by the filter plugin — don't record + ); + }, + [setFilterText, setAppliedFilter, runFilter, addFilter], + ); + + const [filterUpdateKey, setFilterUpdateKey] = useState(0); + const replaceFilter = useCallback( + (value: string) => { + applyFilter(value); + setFilterUpdateKey((k) => k + 1); + }, + [applyFilter], + ); + const isSearching = filterText != null; const toggleSearch = useCallback(() => { - setFilterText(isSearching ? null : ""); - }, [isSearching, setFilterText]); + if (isSearching) { + setFilterText(null); + setAppliedFilter(null); + } else { + setFilterText(""); + } + }, [isSearching, setFilterText, setAppliedFilter]); return { stateKey, filterText, - debouncedFilterText, + appliedFilter, isSearching, + isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""), + filterUpdateKey, setFilterText, + applyFilter, + replaceFilter, toggleSearch, + recentFilters, + removeRecentFilter, + togglePinRecentFilter, + clearRecentFilters, }; }