Add response filter history with pinning (#338)

This commit is contained in:
pixel-hawk
2026-08-14 22:44:11 -07:00
committed by GitHub
parent dc793181bb
commit 2e0f7d1818
6 changed files with 400 additions and 56 deletions
@@ -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;
@@ -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: (
<div className="font-mono text-sm truncate max-w-sm" title={filter.value}>
{filter.value}
</div>
),
leftSlot: <Icon icon={filter.value === activeFilter ? "check" : "empty"} />,
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: (
<span className="block px-4 py-1 text-sm text-text-subtle">
Filters you use are remembered here
</span>
),
});
}
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: <Icon icon="trash" />,
color: "danger",
onSelect: onClear,
},
);
}
return (
<Dropdown items={items}>
<IconButton
size="xs"
icon="filter"
title="Recent filters"
iconColor="secondary"
className="w-8 ml-0.5 mr-1 h-auto!"
/>
</Dropdown>
);
}
@@ -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<ReactNode[]>(() => {
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={
<div className="py-0.5 flex">
<RecentFiltersDropdown
recentFilters={filter.recentFilters}
activeFilter={filter.appliedFilter}
onSelect={filter.replaceFilter}
onRemove={filter.removeRecentFilter}
onTogglePin={filter.togglePinRecentFilter}
onClear={filter.clearRecentFilters}
/>
</div>
}
rightSlot={
<div className="py-0.5 flex">
<IconButton
size="xs"
icon="x"
title="Close filter"
iconColor="secondary"
onClick={filter.toggleSearch}
className="w-8 mr-0.5 h-auto!"
/>
</div>
}
/>
</div>,
);
} else {
nodes.push(
<IconButton
key="icon"
size="sm"
isLoading={filterResult?.isPending ?? false}
icon="filter"
title="Filter response"
onClick={filter.toggleSearch}
className="border border-border-subtle!"
/>,
);
}
nodes.push(
<IconButton
key="icon"
size="sm"
isLoading={filterResult?.isPending ?? false}
icon={isSearching ? "x" : "filter"}
title={isSearching ? "Close filter" : "Filter response"}
onClick={filter.toggleSearch}
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")}
/>,
);
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 (
<Editor
readOnly
className={className}
defaultValue={body}
language={language}
actions={actions}
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
{appliedFilter && filter != null ? (
<AppliedFilterBar
filter={appliedFilter}
error={resultError}
onClear={() => filter.replaceFilter("")}
/>
) : (
<span />
)}
<Editor
readOnly
className={className}
defaultValue={body}
language={language}
actions={actions}
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
</div>
);
}
/**
* 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 (
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
<HStack space={2} className="min-w-0">
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
<span className="truncate min-w-0" title={filter}>
Response filtered by <InlineCode>{filter}</InlineCode>
{error && " (invalid expression)"}
</span>
<Button
size="2xs"
variant="border"
color={error ? "danger" : "info"}
className="ml-auto shrink-0"
onClick={onClear}
>
Clear
</Button>
</HStack>
</Banner>
);
}
@@ -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<RecentFilter[]>({
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<number>();
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));
}
+20 -8
View File
@@ -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 }),
});
}
+91 -15
View File
@@ -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<Record<string, string | null>>({});
/** What's actually applied to the response. Only changes on an explicit apply */
const useAppliedFilterMap = createGlobalState<Record<string, string | null>>({});
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<unknown>;
}): 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,
};
}