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,
};
}