From c2d4c39c6269871d221c942b58398b1876e98c99 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 10:33:18 -0700 Subject: [PATCH 1/4] Apply Yaak Proxy's filter on Enter, with places, Back and Forward, and filter history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search box now composes a filter and Enter applies it. The main window's view (filter, selected flow, card tabs) is a place: a session stack of them backs Back and Forward (⌘[ ⌘], mouse buttons, a Go menu), the current one survives a reload, and the last filter comes back at launch. Filters applied with Enter go into a new filter_history table, listed from the magnifier and recalled with Up in the empty box. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/components/FlowCard.tsx | 26 +- apps/yaak-proxy/components/FlowList.tsx | 79 ++-- apps/yaak-proxy/components/FlowListEmpty.tsx | 18 +- apps/yaak-proxy/components/ProxyLayout.tsx | 2 + apps/yaak-proxy/components/SearchBar.tsx | 356 ++++++++++++++---- apps/yaak-proxy/components/Sidebar.tsx | 3 +- apps/yaak-proxy/harness/LockedHarness.tsx | 4 +- apps/yaak-proxy/harness/backend.ts | 145 ++++++- apps/yaak-proxy/lib/atoms.ts | 41 ++ apps/yaak-proxy/lib/filterBox.test.ts | 107 ++++++ apps/yaak-proxy/lib/filterBox.ts | 76 ++++ apps/yaak-proxy/lib/navigation.test.ts | 148 ++++++++ apps/yaak-proxy/lib/navigation.ts | 335 ++++++++++++++++ apps/yaak-proxy/lib/places.test.ts | 121 ++++++ apps/yaak-proxy/lib/places.ts | 141 +++++++ apps/yaak-proxy/lib/store.ts | 113 +----- apps/yaak-proxy/main.tsx | 5 +- .../yaak-proxy-lib/bindings/gen_models.ts | 10 + .../yaak-proxy-lib/bindings/gen_rpc.ts | 24 +- .../migrations/00000014_filter_history.sql | 6 + crates-proxy/yaak-proxy-lib/src/db.rs | 4 +- .../yaak-proxy-lib/src/filter_history.rs | 152 ++++++++ crates-proxy/yaak-proxy-lib/src/lib.rs | 138 ++++++- crates-tauri/yaak-app-proxy/src/lib.rs | 18 +- .../yaak-app-proxy/src/window_menu.rs | 47 +++ packages/ui/src/components/Dropdown.tsx | 89 +++-- .../src/components/editor/query/extension.ts | 15 +- .../components/http/HttpRequestPaneView.tsx | 4 + .../components/http/HttpResponsePaneView.tsx | 4 + packages/ui/src/components/tabs/Tabs.tsx | 13 +- packages/ui/src/index.ts | 1 + 31 files changed, 1981 insertions(+), 264 deletions(-) create mode 100644 apps/yaak-proxy/lib/filterBox.test.ts create mode 100644 apps/yaak-proxy/lib/filterBox.ts create mode 100644 apps/yaak-proxy/lib/navigation.test.ts create mode 100644 apps/yaak-proxy/lib/navigation.ts create mode 100644 apps/yaak-proxy/lib/places.test.ts create mode 100644 apps/yaak-proxy/lib/places.ts create mode 100644 crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql create mode 100644 crates-proxy/yaak-proxy-lib/src/filter_history.rs diff --git a/apps/yaak-proxy/components/FlowCard.tsx b/apps/yaak-proxy/components/FlowCard.tsx index 6fd906c9c..6469b05d4 100644 --- a/apps/yaak-proxy/components/FlowCard.tsx +++ b/apps/yaak-proxy/components/FlowCard.tsx @@ -18,9 +18,10 @@ import { Tooltip, } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom, useStore } from "jotai"; import { useCallback, useMemo, useState } from "react"; import { BODY_TYPE_BINARY } from "@yaakapp-internal/ui/httpTypes"; +import { placeTabsAtom } from "../lib/atoms"; import { findHeader } from "../lib/body"; import { browserTabMemory, @@ -48,6 +49,7 @@ import { flowResponseToView, } from "../lib/flowViews"; import { messagesTabLabel } from "../lib/messages"; +import { goToPlace } from "../lib/navigation"; import { FlowBodyViewer, useFlowBody } from "./FlowBodyViewer"; import { FlowInfo } from "./FlowInfo"; import { FlowRequestBody } from "./FlowRequestBody"; @@ -86,16 +88,24 @@ export function FlowCard({ flow, listBeside }: { flow: Flow; listBeside: boolean ); } -/** The tab a half opens on, and learning from the ones chosen by hand */ +/** + * The tab a half opens on, and learning from the ones chosen by hand. The tab chosen is part of + * the window's place, so going back to it, or reloading, opens it again. + */ function useShapeTab(pane: FlowPane, flow: Flow, available: string[]) { + const store = useStore(); const memory = browserTabMemory(); + const activeTab = useAtomValue(placeTabsAtom)[pane]; // Read again for every flow, so a tab learned on one opens the next of its shape const defaultTab = defaultTabFor(pane, flow, available, memory); const onTabChange = useCallback( - (tab: string) => learnTab(pane, flow, tab, memory), - [pane, flow, memory], + (tab: string) => { + learnTab(pane, flow, tab, memory); + goToPlace(store, "update", { tabs: { ...store.get(placeTabsAtom), [pane]: tab } }); + }, + [pane, flow, memory, store], ); - return { defaultTab, onTabChange }; + return { activeTab, defaultTab, onTabChange }; } /** A body read through the engine's body RPC, as the shared panes' loaders ask for it */ @@ -148,7 +158,7 @@ function FlowRequestHalf({ flow }: { flow: Flow }) { ...(request.auth == null ? [] : [REQUEST_TAB_AUTH]), REQUEST_TAB_COOKIES, ]; - const { defaultTab, onTabChange } = useShapeTab("request", flow, available); + const { activeTab, defaultTab, onTabChange } = useShapeTab("request", flow, available); return ( } renderBody={(editor) => ( @@ -255,7 +266,7 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } RESPONSE_TAB_INFO, ...(messagesLabel == null ? [] : [RESPONSE_TAB_MESSAGES]), ]; - const { defaultTab, onTabChange } = useShapeTab("response", flow, available); + const { activeTab, defaultTab, onTabChange } = useShapeTab("response", flow, available); const saved = flow.savedRequestId != null; return ( @@ -273,6 +284,7 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } tabsStorageKey="proxy_response_tabs" activeTabKey={flow.id} defaultTab={defaultTab} + activeTab={activeTab} onTabChange={onTabChange} extraTabs={extraTabs} renderBodyViewer={() => ( diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index edf49d933..867d54095 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -16,7 +16,12 @@ import classNames from "classnames"; import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import type { CSSProperties, KeyboardEvent, MouseEvent, ReactElement, WheelEvent } from "react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms"; +import { + appliedFilterAtom, + placeSelectionAtom, + revealFlowAtom, + selectedFlowIdAtom, +} from "../lib/atoms"; import { shortContentType } from "../lib/body"; import { utcTimestamp } from "../lib/flowViews"; import { @@ -40,6 +45,7 @@ import { import { useExportShownHar, useFlowActions } from "../lib/flowActions"; import { compareFlows } from "../lib/flowWindow"; import { countNewFlows, isAtEnd, resumesFollowing } from "../lib/follow"; +import { applyFilter, goToPlace } from "../lib/navigation"; import { asOneTerm } from "../lib/query"; import { folderOf } from "../lib/search"; import { @@ -53,8 +59,6 @@ import { oldestFlowAtom, proxyStateAtom, searchLoadingAtom, - searchTextAtom, - setSearchTextAtom, flowsAtom, shownFlowsAtom, } from "../lib/store"; @@ -100,8 +104,8 @@ export function FlowList({ style, className }: Props) { const flowActions = useFlowActions(); const cellActions = useCellActions(); const exportShown = useExportShownHar(); + const store = useStore(); const selectedId = useAtomValue(selectedFlowIdAtom); - const setSelectedFlow = useSetAtom(selectedFlowAtom); const loadOlder = useSetAtom(loadOlderFlowsAtom); const loadMoreSorted = useSetAtom(loadMoreSortedAtom); const oldestFlow = useAtomValue(oldestFlowAtom); @@ -309,23 +313,51 @@ export function FlowList({ style, className }: Props) { [loadOlderHeld], ); - const select = useCallback( - (index: number) => { - const flow = latest.current.flows[index]; - if (flow == null) return; - setSelectedFlow(flow); - hold(); + /** Scrolls a row into view, to its middle when `center` and it is out of view */ + const scrollToRow = useCallback( + (index: number, center = false) => { const el = scrollRef.current; if (el == null) return; const top = index * ROW_HEIGHT; - if (top < el.scrollTop) setScrollTop(top); - else if (top + ROW_HEIGHT > el.scrollTop + el.clientHeight) { - setScrollTop(top + ROW_HEIGHT - el.clientHeight); - } + const above = top < el.scrollTop; + const below = top + ROW_HEIGHT > el.scrollTop + el.clientHeight; + if (center && (above || below)) setScrollTop(top - (el.clientHeight - ROW_HEIGHT) / 2); + else if (above) setScrollTop(top); + else if (below) setScrollTop(top + ROW_HEIGHT - el.clientHeight); }, - [hold, setSelectedFlow, setScrollTop, ROW_HEIGHT], + [setScrollTop, ROW_HEIGHT], ); + // A click is a place of its own to go back to; the arrow keys move the current place along. + const select = useCallback( + (index: number, move: "add" | "update") => { + const flow = latest.current.flows[index]; + if (flow == null) return; + goToPlace(store, move, { selection: [flow.id], flow }); + hold(); + scrollToRow(index); + }, + [store, hold, scrollToRow], + ); + const clickRow = useCallback((index: number) => select(index, "add"), [select]); + + // A place gone back to scrolls its flow into view once the list has it. + const [reveal, setReveal] = useAtom(revealFlowAtom); + const placeSelection = useAtomValue(placeSelectionAtom); + useLayoutEffect(() => { + if (reveal == null) return; + // Selected elsewhere since + if (!placeSelection.has(reveal.id)) { + setReveal(null); + return; + } + const index = flows.findIndex((f) => f.id === reveal.id); + if (index === -1) return; + setReveal(null); + hold(); + scrollToRow(index, true); + }, [reveal, placeSelection, flows, hold, scrollToRow, setReveal]); + const handleKeyDown = useCallback( (e: KeyboardEvent) => { // ⌘↓ catches up, as the pill does. @@ -338,11 +370,11 @@ export function FlowList({ style, className }: Props) { if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; e.preventDefault(); const index = flows.findIndex((f) => f.id === selectedId); - if (index === -1) select(e.key === "ArrowDown" ? 0 : flows.length - 1); + if (index === -1) select(e.key === "ArrowDown" ? 0 : flows.length - 1, "update"); else if (index === 0 && e.key === "ArrowUp") loadOlderHeld(); else { if (sorted && index >= flows.length - 2 && e.key === "ArrowDown") void loadMoreSorted(); - select(e.key === "ArrowDown" ? index + 1 : index - 1); + select(e.key === "ArrowDown" ? index + 1 : index - 1, "update"); } }, [flows, follow, loadOlderHeld, loadMoreSorted, select, selectedId, sorted], @@ -353,7 +385,7 @@ export function FlowList({ style, className }: Props) { const flow = latest.current.flows[index]; if (flow == null) return; e.preventDefault(); - select(index); + select(index, "add"); // The cell under the pointer, for the menu's items on its value const cellEl = (e.target as HTMLElement).closest("[data-column]"); const column = (cellEl?.dataset.column as ColumnId | undefined) ?? null; @@ -466,7 +498,7 @@ export function FlowList({ style, className }: Props) { template={template} selected={flow.id === selectedId} arrivedAt={arrivals.get(flow.id) ?? null} - onSelect={select} + onSelect={clickRow} onContextMenu={openMenu} /> ); @@ -905,18 +937,17 @@ function clockTime(at: Date): string { /** * The items a cell's menu adds for its value: a search term for it, either way, where its column - * has a field to match it by, and copying it + * has a field to match it by, applied at once, and copying it */ function useCellActions(): (flow: Flow, column: ColumnId) => DropdownItem[] { const store = useStore(); - const setSearchText = useSetAtom(setSearchTextAtom); const showToast = useShowToast(); return useCallback( (flow: Flow, column: ColumnId) => { const add = (term: string) => { // A search with `OR` at its top is grouped first, so the term narrows all of it. - const text = store.get(searchTextAtom).trim(); - setSearchText(text === "" ? term : `${asOneTerm(text)} ${term}`); + const text = store.get(appliedFilterAtom).trim(); + applyFilter(store, text === "" ? term : `${asOneTerm(text)} ${term}`); }; const include = cellFilterTerm(flow, column, false); const exclude = cellFilterTerm(flow, column, true); @@ -944,7 +975,7 @@ function useCellActions(): (flow: Flow, column: ColumnId) => DropdownItem[] { } return items; }, - [store, setSearchText, showToast], + [store, showToast], ); } diff --git a/apps/yaak-proxy/components/FlowListEmpty.tsx b/apps/yaak-proxy/components/FlowListEmpty.tsx index 8003f045c..1b71d059f 100644 --- a/apps/yaak-proxy/components/FlowListEmpty.tsx +++ b/apps/yaak-proxy/components/FlowListEmpty.tsx @@ -1,21 +1,16 @@ import { EmptyStateText, Hotkey, Icon, InlineCode } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue, useStore } from "jotai"; import { useEffect, useState } from "react"; +import { appliedFilterAtom } from "../lib/atoms"; +import { applyFilter } from "../lib/navigation"; import { rpc } from "../lib/rpc"; import { activeQuery, widerQueries } from "../lib/query"; -import { - hideSystemAtom, - pausedAtAtom, - proxyStateAtom, - searchTermsAtom, - searchWindowAtom, - setSearchTextAtom, -} from "../lib/store"; +import { hideSystemAtom, pausedAtAtom, proxyStateAtom, searchWindowAtom } from "../lib/store"; /** Nothing to list: no flows yet, recording paused, or a query nothing matches */ export function FlowListEmpty() { - const text = useAtomValue(searchTermsAtom); + const text = useAtomValue(appliedFilterAtom); const search = useAtomValue(searchWindowAtom); const proxyState = useAtomValue(proxyStateAtom); if (search?.loading) return null; @@ -69,7 +64,8 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) { */ function NoResults({ text }: { text: string }) { const hideSystem = useAtomValue(hideSystemAtom); - const setText = useSetAtom(setSearchTextAtom); + const store = useStore(); + const setText = (wider: string) => applyFilter(store, wider); const [wider, setWider] = useState<{ text: string; count: number }[]>([]); useEffect(() => { diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index 0cd38b228..264bebc2c 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -13,6 +13,7 @@ import { useLocalStorage } from "react-use"; import { flowLayoutAtom } from "../lib/atoms"; import { setupIncomplete } from "../lib/firstRun"; import { useMenuExportHar } from "../lib/flowActions"; +import { useNavigationControls } from "../lib/navigation"; import { lockedAtom, noFlowsAtom, systemProxyEverOnAtom } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { AddColumnDialog } from "./AddColumnDialog"; @@ -35,6 +36,7 @@ export function ProxyLayout() { const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom); const openSettings = useOpenSettings(); useMenuExportHar(); + useNavigationControls(); const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250); const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false); const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage( diff --git a/apps/yaak-proxy/components/SearchBar.tsx b/apps/yaak-proxy/components/SearchBar.tsx index fd0a1c0f6..c092840b8 100644 --- a/apps/yaak-proxy/components/SearchBar.tsx +++ b/apps/yaak-proxy/components/SearchBar.tsx @@ -1,9 +1,16 @@ -import { autocompletion, startCompletion } from "@codemirror/autocomplete"; +import { + autocompletion, + closeCompletion, + type Completion, + completionStatus, + startCompletion, +} from "@codemirror/autocomplete"; import type { Extension } from "@codemirror/state"; -import { Compartment } from "@codemirror/state"; -import { EditorView, tooltips, ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import { Compartment, Prec } from "@codemirror/state"; +import { EditorView, keymap, tooltips, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { Dropdown, + type DropdownItem, Icon, IconButton, Input, @@ -22,36 +29,37 @@ import { useRef, useState, } from "react"; +import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent"; import { useRpcQuery } from "../hooks/useRpcQuery"; +import { appliedFilterAtom } from "../lib/atoms"; +import { isUnapplied, pressEnter, pressEscape, type Recall, stepHistory } from "../lib/filterBox"; import { useExportShownHar } from "../lib/flowActions"; +import { applyFilter, searchDraftAtom, searchDraftVersionAtom } from "../lib/navigation"; +import { rpc } from "../lib/rpc"; import { loadSeenValues, savedCompletions, searchProblems } from "../lib/searchBox"; -import { - allTrafficTotalAtom, - lockedAtom, - savedQueriesAtom, - searchTermsAtom, - searchTextAtom, - searchTextVersionAtom, - setSearchTextAtom, -} from "../lib/store"; +import { allTrafficTotalAtom, lockedAtom, savedQueriesAtom, type Store } from "../lib/store"; +import { useShowToast } from "../lib/toast"; import { savedSearchDialogAtom } from "./SavedSearchDialog"; -/** How long typing settles before the search runs */ -const SEARCH_DEBOUNCE_MS = 200; +/** Recent filters listed above the fields when the empty box is focused */ +const RECENT_IN_COMPLETIONS = 5; + +const NO_HISTORY: string[] = []; /** * The header's search over every flow in the database, in the query language: bare words match * the URL, method, status and content type, and `@field:value` terms the rest, highlighted and - * completed as Yaak's sidebar filter is, with the values the proxy has seen. The backend runs - * it and marks what it cannot. Its placeholder counts the flows. ⌘F focuses it and Escape - * clears it. Focused, it wraps a long query over as many lines as it needs, as Yaak's URL bar + * completed as Yaak's sidebar filter is, with the values the proxy has seen. The box composes a + * filter and Enter applies it, as Yaak's URL bar sends; until then a return key at its right + * says so, and Escape puts the applied filter back. The magnifier lists the filters applied + * before, which Up in the empty box steps through too. Its placeholder counts the flows. ⌘F + * focuses it. Focused, it wraps a long query over as many lines as it needs, as Yaak's URL bar * does, over what is below; blurred, it is one line again. */ export const SearchBar = memo(function SearchBar() { - const [text, setText] = useAtom(searchTextAtom); - const setTerms = useSetAtom(searchTermsAtom); - const setSearchText = useSetAtom(setSearchTextAtom); - const version = useAtomValue(searchTextVersionAtom); + const [draft, setDraft] = useAtom(searchDraftAtom); + const version = useAtomValue(searchDraftVersionAtom); + const applied = useAtomValue(appliedFilterAtom); const total = useAtomValue(allTrafficTotalAtom); const store = useStore(); const setSavedDialog = useSetAtom(savedSearchDialogAtom); @@ -59,12 +67,25 @@ export const SearchBar = memo(function SearchBar() { // Nothing to search while captured traffic is locked. const locked = useAtomValue(lockedAtom) != null; const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema; + const historyQuery = useRpcQueryWithEvent("list_filter_history", {}, "filter_history_changed", { + enabled: !locked, + }); + const history = useMemo( + () => historyQuery.data?.map((e) => e.filter) ?? NO_HISTORY, + [historyQuery.data], + ); const handleRef = useRef(null); const boxRef = useRef(null); const [focused, setFocused] = useState(false); // Where the box is while focused, so it grows over the list from the header's place for it const [place, setPlace] = useState(null); + // Read by the editor's keys, which are made once + const latest = useRef({ applied, history }); + useLayoutEffect(() => { + latest.current = { applied, history }; + }); + useLayoutEffect(() => { if (!focused) { setPlace(null); @@ -79,11 +100,6 @@ export const SearchBar = memo(function SearchBar() { return () => window.removeEventListener("resize", measure); }, [focused]); - useEffect(() => { - const timer = setTimeout(() => setTerms(text), SEARCH_DEBOUNCE_MS); - return () => clearTimeout(timer); - }, [text, setTerms]); - useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "f") { @@ -107,21 +123,31 @@ export const SearchBar = memo(function SearchBar() { loadValues: loadSeenValues, // Read as completion opens, so saved searches are current without remaking it. moreCompletions: (typed) => savedCompletions(store.get(savedQueriesAtom), typed), + leadCompletions: () => recentCompletions(latest.current.history), problems: searchProblems, }), [schema, store], ); - const extensions = useMemo(() => [compartment.current.of(filter), completionAcrossBox], [filter]); + const keys = useMemo(() => boxKeys(store, latest), [store]); + const extensions = useMemo( + () => [compartment.current.of(filter), completionAcrossBox, keys], + [filter, keys], + ); useEffect(() => { handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) }); }, [filter]); - // The field is replaced to take a value from outside, so focus moves to the new one. + // Applies no filter at once, leaving the box focused and empty. const clear = () => { - setSearchText(""); + const handle = handleRef.current; + handle?.dispatch({ changes: { from: 0, to: handle.value().length, insert: "" } }); + applyFilter(store, ""); requestAnimationFrame(() => handleRef.current?.focus()); }; + const unapplied = isUnapplied({ draft, applied }); + const hasText = draft !== "" || applied !== ""; + return ( // Holds the header's place for the box, which floats over the list while focused
@@ -148,60 +174,67 @@ export const SearchBar = memo(function SearchBar() { onBlur={() => setFocused(false)} placeholder={locked ? "Traffic is locked" : placeholderFor(total)} disabled={locked} - defaultValue={text} + defaultValue={draft} forceUpdateKey={String(version)} - onChange={setText} + onChange={setDraft} extensions={extensions} - onKeyDown={(e) => { - if (e.key === "Escape" && handleRef.current?.value() !== "") { - e.stopPropagation(); - clear(); - } - }} leftSlot={ - + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor +
e.preventDefault()} + className="flex items-center self-center ml-0.5" + > + +
} rightSlot={ - text === "" ? ( - - ⌘F - - ) : ( - // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor -
e.preventDefault()} - className="flex items-center self-center ml-1 mr-0.5" - > - , - onSelect: () => setSavedDialog({ mode: "save", query: text.trim() }), - }, - exportShown(), - ]} - > + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor +
e.preventDefault()} + className="flex items-center self-center ml-1 mr-0.5" + > + {unapplied ? ( + ↵ + ) : hasText ? null : ( + ⌘F + )} + {hasText && ( + <> + , + onSelect: () => + setSavedDialog({ + mode: "save", + query: (draft.trim() === "" ? applied : draft).trim(), + }), + }, + exportShown(), + ]} + > + + - - -
- ) + + )} +
} />
@@ -209,6 +242,183 @@ export const SearchBar = memo(function SearchBar() { ); }); +function Key({ children, className }: { children: string; className?: string }) { + return ( + + {children} + + ); +} + +/** + * The magnifier, which lists the filters applied before, newest first: choosing one applies it + * again, and each can be saved as a search or forgotten + */ +function FilterHistory({ + history, + store, + disabled, +}: { + history: string[]; + store: Store; + disabled: boolean; +}) { + const setSavedDialog = useSetAtom(savedSearchDialogAtom); + const showToast = useShowToast(); + const fail = (err: unknown) => + showToast({ id: "filter-history", message: String(err), color: "danger" }); + + const items: DropdownItem[] = + history.length === 0 + ? [{ label: "No recent filters", disabled: true }] + : [ + ...history.map((filter): DropdownItem => ({ + label: ( + + {filter} + + ), + onSelect: () => applyFilter(store, filter, { record: true }), + actions: [ + { + icon: "bookmark", + title: "Save search", + closeMenu: true, + onSelect: () => setSavedDialog({ mode: "save", query: filter }), + }, + { + icon: "x", + title: "Remove", + onSelect: () => void rpc("delete_filter_history", { filter }).catch(fail), + }, + ], + })), + { type: "separator" }, + { + label: "Clear History", + leftSlot: , + onSelect: () => void rpc("clear_filter_history", {}).catch(fail), + }, + ]; + + return ( + + + + ); +} + +/** The recent filters the empty box's completions start with */ +function recentCompletions(history: string[]): Completion[] { + return history.slice(0, RECENT_IN_COMPLETIONS).map((filter) => ({ + label: filter, + detail: "Recent", + type: "text", + apply: filter, + })); +} + +/** The menu of completions is open, so the keys are its own */ +function menuOpen(view: EditorView): boolean { + return completionStatus(view.state) === "active"; +} + +function replaceText(view: EditorView, text: string) { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: text }, + selection: { anchor: text.length }, + }); +} + +/** + * Enter, Escape, Up and Down in the box, over the editor's own and under its completions': Enter + * applies the draft, Escape puts the applied filter back or clears it, and Up and Down step + * through the filter history from an empty box. Each is the completion menu's while it is open, + * except Up from an empty box, which the history has. + */ +function boxKeys( + store: Store, + latest: { current: { applied: string; history: string[] } }, +): Extension { + let recall: Recall | null = null; + const step = (view: EditorView, key: "up" | "down") => { + const next = stepHistory(latest.current.history, recall, view.state.doc.toString(), key); + if (next == null) return false; + recall = next.recall; + closeCompletion(view); + replaceText(view, next.draft); + return true; + }; + const box = (view: EditorView) => ({ + draft: view.state.doc.toString(), + applied: latest.current.applied, + }); + return [ + Prec.high( + keymap.of([ + { + key: "Enter", + stopPropagation: true, + run: (view) => { + if (menuOpen(view)) return false; + const next = pressEnter(box(view)); + recall = null; + applyFilter(store, next.apply ?? "", { record: next.record }); + return true; + }, + }, + { + key: "Escape", + stopPropagation: true, + run: (view) => { + if (menuOpen(view)) return false; + const next = pressEscape(box(view)); + if (next == null) return false; + recall = null; + replaceText(view, next.draft); + if (next.apply != null) applyFilter(store, next.apply); + return true; + }, + }, + { + key: "ArrowUp", + run: (view) => { + const draft = view.state.doc.toString(); + const stepping = recall != null && recall.text === draft; + if (menuOpen(view) && draft !== "" && !stepping) return false; + return step(view, "up"); + }, + }, + { + key: "ArrowDown", + run: (view) => (menuOpen(view) ? false : step(view, "down")), + }, + ]), + ), + // The empty box lists the recent filters as it is focused. + EditorView.domEventHandlers({ + focus: (_e, view) => { + if (view.state.doc.length > 0 || latest.current.history.length === 0) return; + requestAnimationFrame(() => { + if (view.hasFocus && view.state.doc.length === 0) startCompletion(view); + }); + }, + }), + ]; +} + /** The search box's width, which its completions take */ const BOX_WIDTH_VAR = "--proxy-search-width"; diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx index 28e71eb77..4c7ce1292 100644 --- a/apps/yaak-proxy/components/Sidebar.tsx +++ b/apps/yaak-proxy/components/Sidebar.tsx @@ -19,7 +19,8 @@ import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } fro import { revealLabel, revealPath } from "../lib/opener"; import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse"; import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree"; -import { hideSystemAtom, noteSidebarInput, sidebarTreeAtom } from "../lib/store"; +import { noteSidebarInput } from "../lib/navigation"; +import { hideSystemAtom, sidebarTreeAtom } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; import { AppIcon } from "./AppIcon"; diff --git a/apps/yaak-proxy/harness/LockedHarness.tsx b/apps/yaak-proxy/harness/LockedHarness.tsx index 7c0b2e4eb..cf948763e 100644 --- a/apps/yaak-proxy/harness/LockedHarness.tsx +++ b/apps/yaak-proxy/harness/LockedHarness.tsx @@ -5,7 +5,8 @@ import { type ReactNode, useEffect, useState } from "react"; import { ProxyLayout } from "../components/ProxyLayout"; import { DragRegionCheck } from "./DragRegionCheck"; import { useHotkeyContextValue } from "../lib/hotkeys"; -import { syncFlows, syncProxyState, syncSidebarSelection } from "../lib/store"; +import { syncNavigation, syncSidebarSelection } from "../lib/navigation"; +import { syncFlows, syncProxyState } from "../lib/store"; const LOCKED = "Traffic is locked: Yaak Proxy stores traffic encrypted and couldn't read its key from the " + @@ -81,6 +82,7 @@ export function LockedHarness() { const [synced, setSynced] = useState(false); useEffect(() => { syncProxyState(store); + syncNavigation(store); syncFlows(store); syncSidebarSelection(store); setSynced(true); diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index e10e37b1f..48c5d3dbc 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -1,4 +1,9 @@ -import type { Flow, FlowSortKey, GetProxyStateResponse } from "@yaakapp-internal/proxy-lib"; +import type { + Flow, + FlowSortKey, + GetProxyStateResponse, + SavedQuery, +} from "@yaakapp-internal/proxy-lib"; import { sortFlows } from "../lib/columns"; import { appIconPng, fixtures } from "./fixtures"; @@ -9,9 +14,23 @@ import { appIconPng, fixtures } from "./fixtures"; export function installFixtureBackend() { const internals = (window as unknown as { __TAURI_INTERNALS__: Record }) .__TAURI_INTERNALS__; - internals.invoke = async (cmd: string, args?: { cmd?: string; payload?: unknown }) => { + internals.transformCallback = (callback: (event: unknown) => void) => { + const id = callbacks.size + 1; + callbacks.set(id, callback); + return id; + }; + internals.invoke = async ( + cmd: string, + args?: { cmd?: string; payload?: unknown; event?: string; handler?: number }, + ) => { if (cmd === "rpc") return answer(args?.cmd ?? "", args?.payload); + if (cmd === "plugin:event|listen" && args?.event != null && args.handler != null) { + listeners.set(args.event, [...(listeners.get(args.event) ?? []), args.handler]); + return args.handler; + } if (cmd.startsWith("plugin:event|")) return 0; + // The app menu's Go items, which the harness has none of + if (cmd === "set_go_menu") return null; throw new Error(`Not in the harness: ${cmd}`); }; ( @@ -41,9 +60,113 @@ export const HARNESS_STATE: GetProxyStateResponse = { otherOwner: null, }; +/** Callbacks the window registered, by id, and the ids listening to each event */ +const callbacks = new Map void>(); +const listeners = new Map(); + +/** Sends an event to the window, as the engine does */ +function emit(event: string, payload: unknown = {}) { + for (const id of listeners.get(event) ?? []) callbacks.get(id)?.({ event, id, payload }); +} + +/** Filters applied, newest first, and saved searches, kept across reloads as the database is */ +const HISTORY_KEY = "harness_filter_history"; +const SAVED_KEY = "harness_saved_queries"; + +function stored(key: string): T[] { + try { + return JSON.parse(localStorage.getItem(key) ?? "[]") as T[]; + } catch { + return []; + } +} + +function store(key: string, value: unknown[]) { + localStorage.setItem(key, JSON.stringify(value)); +} + +/** + * A rough stand-in for the engine's search: `@host:`, `@method:` and `@status:` terms, and bare + * words in the method, URL and status + */ +function matches(flow: Flow, query: string): boolean { + const host = (() => { + try { + return new URL(flow.url).host; + } catch { + return flow.url; + } + })(); + const text = `${flow.method} ${flow.url} ${flow.status ?? ""}`.toLowerCase(); + return query + .toLowerCase() + .split(/\s+/) + .filter((t) => t !== "") + .every((term) => { + const [, field, value = ""] = /^@([a-z.]+):(.*)$/.exec(term) ?? []; + if (field === "host") return host.toLowerCase().includes(value.replaceAll("*", "")); + if (field === "method") return flow.method.toLowerCase() === value; + if (field === "status") return String(flow.status ?? "").startsWith(value.replace(/x+$/, "")); + return field == null ? text.includes(term) : true; + }); +} + +function allFlows(): Flow[] { + const many = Number(new URLSearchParams(location.search).get("many") ?? 0); + return (many > 0 ? manyFlows(many) : fixtures.map((f) => f.flow)).map((f, i) => ({ + ...f, + number: 1401 + i, + })); +} + function answer(cmd: string, payload: unknown): unknown { const req = (payload ?? {}) as Record; switch (cmd) { + case "get_flows": { + const ids = new Set(req.ids as string[]); + return allFlows().filter((f) => ids.has(f.id)); + } + case "list_filter_history": + return stored(HISTORY_KEY).map((filter) => ({ filter, appliedAt: "" })); + case "record_filter": { + const filter = String(req.filter).trim(); + if (filter === "") return false; + store(HISTORY_KEY, [filter, ...stored(HISTORY_KEY).filter((f) => f !== filter)]); + emit("filter_history_changed"); + return true; + } + case "delete_filter_history": + store( + HISTORY_KEY, + stored(HISTORY_KEY).filter((f) => f !== req.filter), + ); + emit("filter_history_changed"); + return true; + case "clear_filter_history": + store(HISTORY_KEY, []); + emit("filter_history_changed"); + return 0; + case "list_saved_queries": + return stored(SAVED_KEY); + case "create_saved_query": { + const saved = stored(SAVED_KEY); + const query = { + id: `sq_${saved.length + 1}`, + name: String(req.name), + query: String(req.query), + sortOrder: saved.length, + }; + store(SAVED_KEY, [...saved, query]); + emit("saved_queries_changed"); + return query; + } + case "count_saved_queries": + return stored(SAVED_KEY).map((s) => ({ + id: s.id, + count: allFlows().filter((f) => matches(f, s.query)).length, + })); + case "watch_query": + return true; case "get_flow_body": { // A copy from `?many` reads its original's body const id = String(req.flowId).split("~")[0]; @@ -91,15 +214,19 @@ function answer(cmd: string, payload: unknown): unknown { case "list_actions": return { actions: [] }; case "list_flows": { - const many = Number(new URLSearchParams(location.search).get("many") ?? 0); - const all = (many > 0 ? manyFlows(many) : fixtures.map((f) => f.flow)).map((f, i) => ({ - ...f, - number: 1401 + i, - })); + const all = allFlows(); + const query = typeof req.query === "string" ? req.query : ""; + const matching = all.filter((f) => matches(f, query)); // In a sort's order, as the database lists it, all in one page const sort = (req.sort ?? []) as FlowSortKey[]; - const flows = sort.length > 0 ? sortFlows(all, sort) : all.reverse(); - return { flows, total: flows.length, matching: null, cursor: null }; + const flows = sort.length > 0 ? sortFlows(matching, sort) : matching.reverse(); + const page = req.limit === 0 ? [] : flows; + return { + flows: page, + total: all.length, + matching: query === "" ? null : matching.length, + cursor: null, + }; } case "list_seen": return { hosts: [], apps: [] }; diff --git a/apps/yaak-proxy/lib/atoms.ts b/apps/yaak-proxy/lib/atoms.ts index 291ddef95..59446796d 100644 --- a/apps/yaak-proxy/lib/atoms.ts +++ b/apps/yaak-proxy/lib/atoms.ts @@ -2,6 +2,7 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; import type { SplitLayoutLayout } from "@yaakapp-internal/ui"; import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; +import { currentPlace, EMPTY_PLACE, type PlaceStack, stackOf, updatePlace } from "./places"; /** The sidebar's tree, whose selection narrows the list */ export const SIDEBAR_TREE_ID = "proxy-sidebar"; @@ -11,6 +12,46 @@ export const selectedFlowAtom = atom(null); export const selectedFlowIdAtom = atom((get) => get(selectedFlowAtom)?.id ?? null); +/** The places Back and Forward walk, the current one what the window shows */ +export const placeStackAtom = atom(stackOf(EMPTY_PLACE)); + +export const placeAtom = atom((get) => currentPlace(get(placeStackAtom))); + +/** The filter the list, its counts and the sidebar follow, changed only by applying one */ +export const appliedFilterAtom = atom((get) => get(placeAtom).filter); + +/** The flows selected at the current place */ +export const placeSelectionAtom = atom((get) => get(placeAtom).selection); + +/** The tab each half of the card has open at the current place */ +export const placeTabsAtom = atom((get) => get(placeAtom).tabs); + +export const canGoBackAtom = atom((get) => get(placeStackAtom).index > 0); + +export const canGoForwardAtom = atom((get) => { + const stack = get(placeStackAtom); + return stack.index < stack.places.length - 1; +}); + +/** + * Lets go of flows that are gone, deleted or cleared: the detail empties and the current place + * no longer selects them, without a place of its own. All of them without `ids`. + */ +export const dropSelectedAtom = atom(null, (get, set, ids?: ReadonlySet) => { + const selected = get(selectedFlowAtom); + if (selected != null && (ids == null || ids.has(selected.id))) set(selectedFlowAtom, null); + set(placeStackAtom, (stack) => { + const place = currentPlace(stack); + const kept = [...place.selection].filter((id) => ids != null && !ids.has(id)); + if (kept.length === place.selection.size) return stack; + const tabs = kept.length === 0 ? {} : place.tabs; + return updatePlace(stack, { ...place, selection: new Set(kept), tabs }); + }); +}); + +/** A flow to scroll into view once the list has it, as going back to a place asks */ +export const revealFlowAtom = atom<{ id: string } | null>(null); + /** The list follows new traffic, rather than being held where it was scrolled or selected */ export const followingAtom = atom(true); diff --git a/apps/yaak-proxy/lib/filterBox.test.ts b/apps/yaak-proxy/lib/filterBox.test.ts new file mode 100644 index 000000000..f6e7b33ce --- /dev/null +++ b/apps/yaak-proxy/lib/filterBox.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + applyFromOutside, + isUnapplied, + pressEnter, + pressEscape, + type Recall, + stepHistory, +} from "./filterBox"; + +describe("the draft and the applied filter", () => { + test("Enter applies the draft, and keeps it unless it is blank", () => { + expect(pressEnter({ draft: "@status:>=400 ", applied: "" })).toEqual({ + draft: "@status:>=400 ", + apply: "@status:>=400", + record: true, + }); + expect(pressEnter({ draft: " ", applied: "stripe" })).toEqual({ + draft: " ", + apply: "", + record: false, + }); + }); + + test("a draft is unapplied while it differs from the filter, spaces aside", () => { + expect(isUnapplied({ draft: "stripe", applied: "" })).toBe(true); + expect(isUnapplied({ draft: "", applied: "stripe" })).toBe(true); + expect(isUnapplied({ draft: "stripe ", applied: "stripe" })).toBe(false); + expect(isUnapplied({ draft: "", applied: "" })).toBe(false); + }); + + test("Escape over an unapplied draft puts the applied filter back", () => { + expect(pressEscape({ draft: "@host:x", applied: "stripe" })).toEqual({ + draft: "stripe", + apply: null, + record: false, + }); + expect(pressEscape({ draft: "@host:x", applied: "" })).toEqual({ + draft: "", + apply: null, + record: false, + }); + }); + + test("Escape with nothing unapplied clears the filter and applies", () => { + expect(pressEscape({ draft: "stripe", applied: "stripe" })).toEqual({ + draft: "", + apply: "", + record: false, + }); + }); + + test("Escape in an empty box is left to anything else", () => { + expect(pressEscape({ draft: "", applied: "" })).toBeNull(); + }); + + test("a filter set from outside the box applies at once, unrecorded", () => { + expect(applyFromOutside("@host:api.stripe.com")).toEqual({ + draft: "@host:api.stripe.com", + apply: "@host:api.stripe.com", + record: false, + }); + }); +}); + +describe("stepping through the filter history", () => { + const history = ["newest", "middle", "oldest"]; + + function steps(keys: ("up" | "down")[], draft = ""): (string | null)[] { + let recall: Recall | null = null; + let text = draft; + return keys.map((key) => { + const next = stepHistory(history, recall, text, key); + if (next == null) return null; + recall = next.recall; + text = next.draft; + return text; + }); + } + + test("Up from an empty box goes back through it, and stays at the oldest", () => { + expect(steps(["up", "up", "up", "up"])).toEqual(["newest", "middle", "oldest", "oldest"]); + }); + + test("Down steps forward, then empties the box", () => { + expect(steps(["up", "up", "down", "down", "down"])).toEqual([ + "newest", + "middle", + "newest", + "", + null, + ]); + }); + + test("is not for a box with text of its own, or Down first", () => { + expect(steps(["up"], "stripe")).toEqual([null]); + expect(steps(["down"])).toEqual([null]); + expect(stepHistory([], null, "", "up")).toBeNull(); + }); + + test("typing over a recalled filter ends the stepping", () => { + const first = stepHistory(history, null, "", "up"); + expect(first?.draft).toBe("newest"); + expect(stepHistory(history, first?.recall ?? null, "newest @x", "up")).toBeNull(); + expect(stepHistory(history, first?.recall ?? null, "newest @x", "down")).toBeNull(); + }); +}); diff --git a/apps/yaak-proxy/lib/filterBox.ts b/apps/yaak-proxy/lib/filterBox.ts new file mode 100644 index 000000000..76bab06bd --- /dev/null +++ b/apps/yaak-proxy/lib/filterBox.ts @@ -0,0 +1,76 @@ +/** + * The search box composes a filter, then applies it, as Yaak's URL bar composes a request: the + * draft is what the box holds, the applied filter what the list shows. + */ +export interface FilterBox { + draft: string; + applied: string; +} + +/** What a key does to the box: its text from now, and a filter to apply, or null for none */ +export interface BoxStep { + draft: string; + apply: string | null; + /** Applied with Enter, so kept in the filter history */ + record: boolean; +} + +/** The draft differs from what is applied, so Enter has something to do */ +export function isUnapplied(box: FilterBox): boolean { + return box.draft.trim() !== box.applied.trim(); +} + +/** Enter applies the draft, kept in the history unless it is blank */ +export function pressEnter(box: FilterBox): BoxStep { + const apply = box.draft.trim(); + return { draft: box.draft, apply, record: apply !== "" }; +} + +/** + * Escape puts the applied filter back over an unapplied draft; with nothing unapplied, it clears + * the filter. Null when it has nothing to do, so the key goes on to whatever else wants it. + */ +export function pressEscape(box: FilterBox): BoxStep | null { + if (isUnapplied(box)) return { draft: box.applied, apply: null, record: false }; + if (box.applied.trim() !== "") return { draft: "", apply: "", record: false }; + return null; +} + +/** Something outside the box sets the filter, applying it at once */ +export function applyFromOutside(text: string): BoxStep { + return { draft: text, apply: text, record: false }; +} + +/** + * Where Up and Down are in the filter history: `index` into it, newest first, and the text it + * put in the box, so typing over it ends the stepping + */ +export interface Recall { + index: number; + text: string; +} + +/** + * Up in an empty box puts the latest filter applied in it, and each Up after an older one; Down + * steps back toward the newest, then empties the box. Null when the key is not for stepping: + * the box holds text of its own, or there is nowhere to go. + */ +export function stepHistory( + history: string[], + recall: Recall | null, + draft: string, + key: "up" | "down", +): { recall: Recall | null; draft: string } | null { + const stepping = recall != null && recall.text === draft; + if (!stepping) { + if (key === "down" || draft !== "") return null; + const first = history[0]; + return first == null ? null : { recall: { index: 0, text: first }, draft: first }; + } + const index = recall.index + (key === "up" ? 1 : -1); + if (index < 0) return { recall: null, draft: "" }; + const text = history[index]; + // At the oldest, Up stays put. + if (text == null) return { recall, draft }; + return { recall: { index, text }, draft: text }; +} diff --git a/apps/yaak-proxy/lib/navigation.test.ts b/apps/yaak-proxy/lib/navigation.test.ts new file mode 100644 index 000000000..39d295b07 --- /dev/null +++ b/apps/yaak-proxy/lib/navigation.test.ts @@ -0,0 +1,148 @@ +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { atom, createStore } from "jotai"; +import { beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import { + appliedFilterAtom, + canGoBackAtom, + canGoForwardAtom, + placeAtom, + placeStackAtom, + revealFlowAtom, + selectedFlowAtom, +} from "./atoms"; +import { EMPTY_PLACE, stackOf } from "./places"; + +const existing = new Set(); +const recorded: string[] = []; + +vi.mock("./rpc", () => ({ + rpc: vi.fn(async (cmd: string, payload: { ids?: string[]; filter?: string }) => { + if (cmd === "get_flows") { + return (payload.ids ?? []).filter((id) => existing.has(id)).map((id) => flow(id)); + } + if (cmd === "record_filter") recorded.push(payload.filter ?? ""); + return true; + }), + listen: vi.fn(() => () => {}), +})); +vi.mock("./tauri", () => ({ command: vi.fn(async () => {}), subscribe: vi.fn(() => () => {}) })); +// The package's index reaches for the window as it loads; the sidebar's selection is all used. +vi.mock("@yaakapp-internal/ui", () => ({ selectedIdsFamily: () => atom([]) })); + +const { applyFilter, goToPlace, searchDraftAtom, searchDraftVersionAtom } = + await import("./navigation"); + +function flow(id: string): Flow { + return { id } as Flow; +} + +function newStore() { + const store = createStore(); + store.set(placeStackAtom, stackOf(EMPTY_PLACE)); + return store; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +beforeEach(() => { + existing.clear(); + recorded.length = 0; +}); + +describe("goToPlace", () => { + test("a click, a run of arrow keys and a filter go back as two places", () => { + const store = newStore(); + applyFilter(store, "before"); + goToPlace(store, "add", { selection: ["A"], flow: flow("A") }); + for (let i = 0; i < 50; i++) { + goToPlace(store, "update", { selection: [`row${i}`], flow: flow(`row${i}`) }); + } + goToPlace(store, "update", { selection: ["B"], flow: flow("B") }); + applyFilter(store, "after"); + expect(store.get(placeStackAtom).places.length).toBe(4); + + expect(goToPlace(store, "back")).toBe(true); + expect(store.get(appliedFilterAtom)).toBe("before"); + expect(store.get(selectedFlowAtom)?.id).toBe("B"); + expect(store.get(searchDraftAtom)).toBe("before"); + expect(store.get(revealFlowAtom)).toEqual({ id: "B" }); + + goToPlace(store, "back"); + expect(store.get(appliedFilterAtom)).toBe("before"); + expect(store.get(selectedFlowAtom)).toBeNull(); + expect(store.get(canGoForwardAtom)).toBe(true); + + goToPlace(store, "forward"); + goToPlace(store, "forward"); + expect(store.get(appliedFilterAtom)).toBe("after"); + expect(store.get(canGoForwardAtom)).toBe(false); + expect(goToPlace(store, "forward")).toBe(false); + }); + + test("a new selection opens on the card's own tabs; the same one keeps them", () => { + const store = newStore(); + goToPlace(store, "add", { selection: ["A"], flow: flow("A") }); + goToPlace(store, "update", { tabs: { response: "headers" } }); + goToPlace(store, "add", { selection: ["A"], flow: flow("A") }); + expect(store.get(placeAtom).tabs).toEqual({ response: "headers" }); + expect(store.get(placeStackAtom).places.length).toBe(2); + goToPlace(store, "update", { selection: ["B"], flow: flow("B") }); + expect(store.get(placeAtom).tabs).toEqual({}); + }); + + test("going back restores the tabs that place had open", () => { + const store = newStore(); + goToPlace(store, "add", { selection: ["A"], flow: flow("A") }); + goToPlace(store, "update", { tabs: { request: "params" } }); + goToPlace(store, "add", { selection: ["B"], flow: flow("B") }); + goToPlace(store, "back"); + expect(store.get(placeAtom).tabs).toEqual({ request: "params" }); + }); + + test("a place whose flow is gone comes back with its filter and no selection", async () => { + const store = newStore(); + applyFilter(store, "x"); + goToPlace(store, "add", { selection: ["gone"], flow: flow("gone") }); + applyFilter(store, "y"); + store.set(selectedFlowAtom, null); + goToPlace(store, "add", { selection: ["other"], flow: flow("other") }); + + goToPlace(store, "back"); + goToPlace(store, "back"); + expect(store.get(appliedFilterAtom)).toBe("x"); + await settle(); + expect(store.get(selectedFlowAtom)).toBeNull(); + expect(store.get(placeAtom).selection.size).toBe(0); + }); + + test("a place whose flow the window doesn't hold reads it", async () => { + existing.add("old"); + const store = newStore(); + goToPlace(store, "add", { selection: ["old"], flow: flow("old") }); + goToPlace(store, "add", { selection: ["new"], flow: flow("new") }); + goToPlace(store, "back"); + await settle(); + expect(store.get(selectedFlowAtom)?.id).toBe("old"); + }); + + test("only filters applied with record go to the history", async () => { + const store = newStore(); + applyFilter(store, "@status:>=400", { record: true }); + applyFilter(store, "@host:sidebar"); + applyFilter(store, "", { record: true }); + await settle(); + expect(recorded).toEqual(["@status:>=400"]); + expect(store.get(canGoBackAtom)).toBe(true); + }); + + test("the box takes a filter from outside, but not one it holds already", () => { + const store = newStore(); + store.set(searchDraftAtom, "stripe "); + const version = store.get(searchDraftVersionAtom); + applyFilter(store, "stripe"); + expect(store.get(searchDraftVersionAtom)).toBe(version); + applyFilter(store, "@host:x"); + expect(store.get(searchDraftAtom)).toBe("@host:x"); + expect(store.get(searchDraftVersionAtom)).toBe(version + 1); + }); +}); diff --git a/apps/yaak-proxy/lib/navigation.ts b/apps/yaak-proxy/lib/navigation.ts new file mode 100644 index 000000000..d6b2b15c8 --- /dev/null +++ b/apps/yaak-proxy/lib/navigation.ts @@ -0,0 +1,335 @@ +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { selectedIdsFamily } from "@yaakapp-internal/ui"; +import { atom, useAtomValue, useStore } from "jotai"; +import { useEffect } from "react"; +import { + appliedFilterAtom, + canGoBackAtom, + canGoForwardAtom, + placeStackAtom, + revealFlowAtom, + SIDEBAR_TREE_ID, + selectedFlowAtom, +} from "./atoms"; +import { + addPlace, + currentPlace, + EMPTY_PLACE, + type Place, + type PlaceStack, + type PlaceTabs, + sameSelection, + stackFromJson, + stackOf, + stackToJson, + stackWithout, + stepPlace, + updatePlace, +} from "./places"; +import { rpc } from "./rpc"; +import { + ALL_ID, + APPS_ID, + DOMAINS_ID, + itemsById, + queryInText, + SAVED_ID, + selectionText, +} from "./sidebarTree"; +import { + flowsAtom, + searchWindowAtom, + sidebarTreeAtom, + sortedWindowAtom, + type Store, +} from "./store"; +import { command, subscribe } from "./tauri"; + +/** The search box's text: a draft, which the list follows only once it is applied */ +export const searchDraftAtom = atom(""); + +/** Bumped when the box takes its text from outside, such as Back or the sidebar */ +export const searchDraftVersionAtom = atom(0); + +/** + * How going to a place treats the history: a new place, the current one changed in step, or a + * step back or forward to one there already + */ +export type PlaceMove = "add" | "update" | "back" | "forward"; + +/** What a move changes; what it leaves out stays as it is */ +export interface PlaceChange { + filter?: string; + selection?: Iterable; + /** The tabs to open; left out, they stay with the same selection and reset with a new one */ + tabs?: PlaceTabs; + /** The selected flow, when the caller has it, so the card shows it without reading it */ + flow?: Flow; +} + +/** + * Goes to a place: the one place the filter, the selection and the card's tabs are set from. + * Back and Forward step along the history; anything else adds a place or changes the current + * one, as `move` says. Every argument is plain data, so a request from outside the window, such + * as a command line's, can arrive as an event and come here. False when there was nowhere to go. + */ +export function goToPlace(store: Store, move: PlaceMove, change: PlaceChange = {}): boolean { + const stack = store.get(placeStackAtom); + let next: PlaceStack | null; + if (move === "back" || move === "forward") { + next = stepPlace(stack, move === "back" ? -1 : 1); + } else { + const before = currentPlace(stack); + const selection = change.selection == null ? before.selection : new Set(change.selection); + const place: Place = { + filter: change.filter ?? before.filter, + selection, + tabs: change.tabs ?? (sameSelection(selection, before.selection) ? before.tabs : {}), + }; + next = move === "add" ? addPlace(stack, place) : updatePlace(stack, place); + } + if (next == null) return false; + store.set(placeStackAtom, next); + const stepping = move === "back" || move === "forward"; + showPlace(store, currentPlace(next), { + filter: stepping || change.filter != null, + reveal: stepping, + flow: change.flow, + }); + return true; +} + +/** + * Applies a filter at once, as a new place. `record` keeps it in the filter history, as applying + * it from the box with Enter, or from the history, does. + */ +export function applyFilter(store: Store, text: string, { record = false } = {}) { + goToPlace(store, "add", { filter: text }); + if (record && text.trim() !== "") { + rpc("record_filter", { filter: text }).catch((err) => + console.warn("Failed to record the filter", err), + ); + } +} + +/** The box, the card and the list take a place the stack has just moved to */ +function showPlace( + store: Store, + place: Place, + opts: { filter: boolean; reveal: boolean; flow?: Flow }, +) { + // The box takes the filter only where it doesn't hold it already, as after Enter, which + // would otherwise make it anew and lose its focus. + if (opts.filter && store.get(searchDraftAtom).trim() !== place.filter.trim()) { + store.set(searchDraftAtom, place.filter); + store.set(searchDraftVersionAtom, (v) => v + 1); + } + const [id] = place.selection; + if (id == null) { + store.set(selectedFlowAtom, null); + return; + } + const known = opts.flow?.id === id ? opts.flow : loadedFlow(store, id); + if (known != null) store.set(selectedFlowAtom, known); + else readSelected(store, id); + if (opts.reveal) store.set(revealFlowAtom, { id }); +} + +/** A flow the window holds already, in any of its lists */ +function loadedFlow(store: Store, id: string): Flow | null { + const selected = store.get(selectedFlowAtom); + if (selected?.id === id) return selected; + const lists = [ + store.get(flowsAtom), + store.get(searchWindowAtom)?.win.flows ?? [], + store.get(sortedWindowAtom)?.flows ?? [], + ]; + for (const flows of lists) { + const found = flows.find((f) => f.id === id); + if (found != null) return found; + } + return null; +} + +/** + * Reads a selected flow the window does not hold, as one gone back to can be. One that no longer + * exists, cleared or deleted, leaves every place that selects it, with no sign. + */ +function readSelected(store: Store, id: string) { + store.set(selectedFlowAtom, null); + rpc("get_flows", { ids: [id] }) + .then(([found]) => { + if (found == null) { + store.set(placeStackAtom, (s) => stackWithout(s, new Set([id]))); + return; + } + // The window has moved on meanwhile. + if (currentPlace(store.get(placeStackAtom)).selection.has(id)) { + store.set(selectedFlowAtom, found); + } + }) + .catch((err) => console.warn("Failed to read the selected flow", err)); +} + +/** Where the window's places are kept for a reload, for this session */ +const PLACES_KEY = "proxy_places"; +/** Where the filter last applied is kept for the next launch */ +const LAST_FILTER_KEY = "proxy_last_filter"; + +function read(storage: () => Storage, key: string): string | null { + try { + return storage().getItem(key); + } catch { + return null; + } +} + +function write(storage: () => Storage, key: string, value: string) { + try { + storage().setItem(key, value); + } catch { + // Not kept: the next reload starts from the last filter. + } +} + +/** + * Restores where the window was, before anything reads the filter: its places after a reload, + * or the last filter applied at launch. Keeps them as they change. + */ +export function syncNavigation(store: Store) { + const kept = stackFromJson(read(() => sessionStorage, PLACES_KEY)); + const lastFilter = read(() => localStorage, LAST_FILTER_KEY) ?? ""; + const stack = kept ?? stackOf({ ...EMPTY_PLACE, filter: lastFilter }); + store.set(placeStackAtom, stack); + showPlace(store, currentPlace(stack), { filter: true, reveal: true }); + store.sub(placeStackAtom, () => { + const now = store.get(placeStackAtom); + write(() => sessionStorage, PLACES_KEY, stackToJson(now)); + write(() => localStorage, LAST_FILTER_KEY, currentPlace(now).filter); + }); +} + +/** `GO_BACK_EVENT` and `GO_FORWARD_EVENT` in the app's `window_menu.rs` */ +const MENU_GO_BACK_EVENT = "menu_go_back"; +const MENU_GO_FORWARD_EVENT = "menu_go_forward"; + +/** The mouse's back and forward buttons */ +const MOUSE_BACK = 3; +const MOUSE_FORWARD = 4; + +/** + * Back and Forward from ⌘[ and ⌘], the mouse's side buttons and the app menu's Go items, which + * are enabled while there is somewhere to go + */ +export function useNavigationControls() { + const store = useStore(); + const canGoBack = useAtomValue(canGoBackAtom); + const canGoForward = useAtomValue(canGoForwardAtom); + + useEffect(() => { + command("set_go_menu", { back: canGoBack, forward: canGoForward }).catch(() => {}); + }, [canGoBack, canGoForward]); + + useEffect(() => { + // Before the editors, whose ⌘[ and ⌘] would indent the search box. + const onKeyDown = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return; + if (e.key !== "[" && e.key !== "]") return; + e.preventDefault(); + e.stopPropagation(); + goToPlace(store, e.key === "[" ? "back" : "forward"); + }; + const onMouse = (e: MouseEvent) => { + if (e.button !== MOUSE_BACK && e.button !== MOUSE_FORWARD) return; + e.preventDefault(); + if (e.type === "mouseup") goToPlace(store, e.button === MOUSE_BACK ? "back" : "forward"); + }; + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("mousedown", onMouse); + window.addEventListener("mouseup", onMouse); + const offBack = subscribe(MENU_GO_BACK_EVENT, () => goToPlace(store, "back")); + const offForward = subscribe(MENU_GO_FORWARD_EVENT, () => goToPlace(store, "forward")); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("mousedown", onMouse); + window.removeEventListener("mouseup", onMouse); + offBack(); + offForward(); + }; + }, [store]); +} + +/** When the person last clicked or pressed a key in the sidebar */ +let lastSidebarInput = 0; + +/** How soon after a click or key a change of the sidebar's selection counts as theirs */ +const SIDEBAR_INPUT_MS = 500; + +/** Notes a click or key in the sidebar, so the selection change it makes applies its filter */ +export function noteSidebarInput() { + lastSidebarInput = Date.now(); +} + +/** + * Keeps the sidebar's selection and the filter in step. Selecting rows applies their query at + * once, as a new place; a filter applied keeps a row selected while its query is still one of + * the filter's terms, and All traffic selected while there is none. + */ +export function syncSidebarSelection(store: Store) { + const selectedAtom = selectedIdsFamily(SIDEBAR_TREE_ID); + let syncing = false; + + function keepRows() { + if (syncing) return; + const text = store.get(appliedFilterAtom); + const items = itemsById(store.get(sidebarTreeAtom)); + const ids = store.get(selectedAtom); + const kept = ids.filter((id) => { + const item = items.get(id); + return item != null && item.query !== "" && queryInText(item.query, text); + }); + const next = text.trim() === "" ? [ALL_ID] : kept; + if (next.length === ids.length && next.every((id, i) => id === ids[i])) return; + syncing = true; + store.set(selectedAtom, next); + syncing = false; + } + + // The tree writes its selection again as it renders, the same rows in a new array, or its last + // row when the selection was emptied; only a change of rows right after a click or a key in + // the sidebar is the person selecting. + let lastRows = store.get(selectedAtom).join("\n"); + store.sub(selectedAtom, () => { + const ids = store.get(selectedAtom); + const key = ids.join("\n"); + if (key === lastRows) return; + lastRows = key; + if (syncing) return; + // Not the person's, such as the tree selecting its first row as it mounts over a filter + // restored at launch: the rows go back to what the filter says. + if (Date.now() - lastSidebarInput > SIDEBAR_INPUT_MS) { + keepRows(); + return; + } + // Group headings are not filters. + const rows = ids.filter((id) => id !== APPS_ID && id !== DOMAINS_ID && id !== SAVED_ID); + if (rows.length === 0) return; + const items = itemsById(store.get(sidebarTreeAtom)); + const selected = rows.flatMap((id) => { + const item = items.get(id); + return item == null ? [] : [item]; + }); + if (selected.length === 0) return; + const text = selectionText(selected); + if (text === store.get(appliedFilterAtom)) return; + syncing = true; + applyFilter(store, text); + syncing = false; + }); + store.sub(appliedFilterAtom, keepRows); + + // A filter restored at launch selects no row; none at all is All traffic. + if (store.get(selectedAtom).length === 0 && store.get(appliedFilterAtom).trim() === "") { + store.set(selectedAtom, [ALL_ID]); + } +} diff --git a/apps/yaak-proxy/lib/places.test.ts b/apps/yaak-proxy/lib/places.test.ts new file mode 100644 index 000000000..66ef4c9ea --- /dev/null +++ b/apps/yaak-proxy/lib/places.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + addPlace, + currentPlace, + EMPTY_PLACE, + MAX_PLACES, + type Place, + type PlaceStack, + stackFromJson, + stackOf, + stackToJson, + stackWithout, + stepPlace, + updatePlace, + withoutMissing, +} from "./places"; + +function at(filter: string, selected: string[] = [], tabs: Place["tabs"] = {}): Place { + return { filter, selection: new Set(selected), tabs }; +} + +function shown(stack: PlaceStack): string[] { + return stack.places.map((p) => [p.filter, ...p.selection].join(":")); +} + +describe("the place stack", () => { + test("adds a place on top, and steps back and forward along them", () => { + let stack = stackOf(EMPTY_PLACE); + stack = addPlace(stack, at("a")); + stack = addPlace(stack, at("a", ["f1"])); + expect(shown(stack)).toEqual(["", "a", "a:f1"]); + expect(stack.index).toBe(2); + + const back = stepPlace(stack, -1); + expect(back == null ? null : currentPlace(back)).toEqual(at("a")); + expect(stepPlace(stack, 1)).toBeNull(); + expect(stepPlace(stackOf(EMPTY_PLACE), -1)).toBeNull(); + }); + + test("updating changes the current place without adding one", () => { + let stack = addPlace(stackOf(EMPTY_PLACE), at("a", ["f1"])); + stack = updatePlace(stack, at("a", ["f2"])); + stack = updatePlace(stack, at("a", ["f2"], { response: "headers" })); + expect(shown(stack)).toEqual(["", "a:f2"]); + expect(currentPlace(stack).tabs).toEqual({ response: "headers" }); + // Nothing changed, nothing new. + expect(updatePlace(stack, at("a", ["f2"], { response: "headers" }))).toBe(stack); + }); + + test("a click, a run of arrow keys and a new filter leave two places behind", () => { + let stack = addPlace(stackOf(EMPTY_PLACE), at("before")); + stack = addPlace(stack, at("before", ["A"])); + for (let i = 1; i <= 50; i++) stack = updatePlace(stack, at("before", [`row${i}`])); + stack = updatePlace(stack, at("before", ["B"])); + stack = addPlace(stack, at("after", ["B"])); + expect(shown(stack)).toEqual(["", "before", "before:B", "after:B"]); + + const one = stepPlace(stack, -1); + expect(one == null ? null : currentPlace(one)).toEqual(at("before", ["B"])); + const two = one == null ? null : stepPlace(one, -1); + expect(two == null ? null : currentPlace(two)).toEqual(at("before")); + }); + + test("adding while back discards the places ahead", () => { + let stack = addPlace(addPlace(stackOf(EMPTY_PLACE), at("a")), at("b")); + stack = stepPlace(stack, -1) ?? stack; + stack = addPlace(stack, at("c")); + expect(shown(stack)).toEqual(["", "a", "c"]); + expect(stepPlace(stack, 1)).toBeNull(); + }); + + test("going where the window already is changes the current place, keeping those ahead", () => { + let stack = addPlace(addPlace(stackOf(EMPTY_PLACE), at("a", ["f1"])), at("b")); + stack = stepPlace(stack, -1) ?? stack; + stack = addPlace(stack, at("a", ["f1"], { request: "body" })); + expect(shown(stack)).toEqual(["", "a:f1", "b"]); + expect(currentPlace(stack).tabs).toEqual({ request: "body" }); + }); + + test("keeps the newest hundred", () => { + let stack = stackOf(EMPTY_PLACE); + for (let i = 0; i < MAX_PLACES + 20; i++) stack = addPlace(stack, at(`f${i}`)); + expect(stack.places.length).toBe(MAX_PLACES); + expect(stack.index).toBe(MAX_PLACES - 1); + expect(stack.places[0]?.filter).toBe("f20"); + expect(currentPlace(stack).filter).toBe(`f${MAX_PLACES + 19}`); + }); + + test("a flow that is gone leaves its places with their filters", () => { + const place = at("a", ["gone"], { response: "headers" }); + expect(withoutMissing(place, () => false)).toEqual(at("a")); + expect(withoutMissing(place, () => true)).toBe(place); + + const stack = addPlace(addPlace(stackOf(EMPTY_PLACE), place), at("b", ["kept"])); + const after = stackWithout(stack, new Set(["gone"])); + expect(shown(after)).toEqual(["", "a", "b:kept"]); + expect(after.index).toBe(stack.index); + expect(stackWithout(stack, new Set(["other"]))).toBe(stack); + }); +}); + +describe("the stack in storage", () => { + test("reads back what it wrote", () => { + const stack = addPlace(stackOf(at("a")), at("b", ["f1"], { request: "params" })); + const read = stackFromJson(stackToJson(stack)); + expect(read).toEqual(stack); + expect(read == null ? null : currentPlace(read).selection.has("f1")).toBe(true); + }); + + test("is nothing for anything else", () => { + expect(stackFromJson(null)).toBeNull(); + expect(stackFromJson("not json")).toBeNull(); + expect(stackFromJson(JSON.stringify({ places: [], index: 0 }))).toBeNull(); + expect(stackFromJson(JSON.stringify({ places: [{ filter: 1 }], index: 0 }))).toBeNull(); + }); + + test("keeps the index within its places", () => { + const json = JSON.stringify({ places: [{ filter: "a", selection: [], tabs: {} }], index: 7 }); + expect(stackFromJson(json)?.index).toBe(0); + }); +}); diff --git a/apps/yaak-proxy/lib/places.ts b/apps/yaak-proxy/lib/places.ts new file mode 100644 index 000000000..b66ba2594 --- /dev/null +++ b/apps/yaak-proxy/lib/places.ts @@ -0,0 +1,141 @@ +/** The tab open on each half of the detail card; a half left out opens on its own default */ +export interface PlaceTabs { + request?: string; + response?: string; +} + +/** + * Where the main window is: the filter applied, the flows selected and the card's open tabs. + * Sort, columns, layout and Hide System are preferences, not part of it. + */ +export interface Place { + filter: string; + /** The selected flows' ids: one at most for now */ + selection: ReadonlySet; + tabs: PlaceTabs; +} + +export const EMPTY_PLACE: Place = { filter: "", selection: new Set(), tabs: {} }; + +/** The places Back and Forward walk, and which of them is current */ +export interface PlaceStack { + places: Place[]; + index: number; +} + +/** Most places kept; adding one more forgets the oldest */ +export const MAX_PLACES = 100; + +export function stackOf(place: Place): PlaceStack { + return { places: [place], index: 0 }; +} + +export function currentPlace(stack: PlaceStack): Place { + return stack.places[stack.index] ?? EMPTY_PLACE; +} + +export function sameSelection(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false; + for (const id of a) if (!b.has(id)) return false; + return true; +} + +function sameTabs(a: PlaceTabs, b: PlaceTabs): boolean { + return a.request === b.request && a.response === b.response; +} + +/** The same filter and selection: going there again is not a new place */ +export function samePlace(a: Place, b: Place): boolean { + return a.filter === b.filter && sameSelection(a.selection, b.selection); +} + +/** + * A new place on top of the current one, dropping any ahead of it, as a deliberate jump makes. + * One where the window already is changes the current place instead. + */ +export function addPlace(stack: PlaceStack, place: Place): PlaceStack { + if (samePlace(currentPlace(stack), place)) return updatePlace(stack, place); + const places = [...stack.places.slice(0, stack.index + 1), place].slice(-MAX_PLACES); + return { places, index: places.length - 1 }; +} + +/** The current place changed in step, as moving with the arrow keys or switching a tab does */ +export function updatePlace(stack: PlaceStack, place: Place): PlaceStack { + const current = currentPlace(stack); + if (samePlace(current, place) && sameTabs(current.tabs, place.tabs)) return stack; + const places = [...stack.places]; + places[stack.index] = place; + return { places, index: stack.index }; +} + +/** One place back, -1, or forward, 1, or null with none there */ +export function stepPlace(stack: PlaceStack, delta: -1 | 1): PlaceStack | null { + const index = stack.index + delta; + if (index < 0 || index >= stack.places.length) return null; + return { places: stack.places, index }; +} + +/** The place without the flows `exists` says are gone, as one restored after a clear is */ +export function withoutMissing(place: Place, exists: (id: string) => boolean): Place { + const kept = [...place.selection].filter(exists); + if (kept.length === place.selection.size) return place; + return { ...place, selection: new Set(kept), tabs: kept.length === 0 ? {} : place.tabs }; +} + +/** The place without some flows, wherever it is in the stack, as one deleted leaves them */ +export function stackWithout(stack: PlaceStack, ids: ReadonlySet): PlaceStack { + let changed = false; + const places = stack.places.map((p) => { + const next = withoutMissing(p, (id) => !ids.has(id)); + if (next !== p) changed = true; + return next; + }); + return changed ? { places, index: stack.index } : stack; +} + +interface StoredPlace { + filter: string; + selection: string[]; + tabs: PlaceTabs; +} + +function storedPlace(place: Place): StoredPlace { + return { filter: place.filter, selection: [...place.selection], tabs: place.tabs }; +} + +function placeFrom(value: unknown): Place | null { + if (value == null || typeof value !== "object") return null; + const v = value as Partial; + if (typeof v.filter !== "string" || !Array.isArray(v.selection)) return null; + const tabs: PlaceTabs = {}; + const t = (v.tabs ?? {}) as Record; + if (typeof t.request === "string") tabs.request = t.request; + if (typeof t.response === "string") tabs.response = t.response; + const selection = v.selection.filter((id): id is string => typeof id === "string"); + return { filter: v.filter, selection: new Set(selection), tabs }; +} + +/** The stack as kept in storage */ +export function stackToJson(stack: PlaceStack): string { + return JSON.stringify({ places: stack.places.map(storedPlace), index: stack.index }); +} + +/** A stack read from storage, or null for anything that isn't one */ +export function stackFromJson(json: string | null): PlaceStack | null { + if (json == null) return null; + try { + const value = JSON.parse(json) as { places?: unknown; index?: unknown }; + if (!Array.isArray(value.places) || typeof value.index !== "number") return null; + const places: Place[] = []; + for (const p of value.places.slice(0, MAX_PLACES)) { + const place = placeFrom(p); + if (place == null) return null; + places.push(place); + } + if (places.length === 0) return null; + const index = Math.max(0, Math.min(places.length - 1, Math.floor(value.index))); + return { places, index }; + } catch { + return null; + } +} diff --git a/apps/yaak-proxy/lib/store.ts b/apps/yaak-proxy/lib/store.ts index 997372718..23b5afd62 100644 --- a/apps/yaak-proxy/lib/store.ts +++ b/apps/yaak-proxy/lib/store.ts @@ -6,10 +6,9 @@ import type { SavedQuery, SortValue, } from "@yaakapp-internal/proxy-lib"; -import { selectedIdsFamily } from "@yaakapp-internal/ui"; import { atom, type createStore } from "jotai"; import { atomWithStorage } from "jotai/utils"; -import { followingAtom, SIDEBAR_TREE_ID, selectedFlowAtom } from "./atoms"; +import { appliedFilterAtom, dropSelectedAtom, followingAtom, selectedFlowAtom } from "./atoms"; import { type ColumnPrefs, defaultColumnPrefs, @@ -31,19 +30,9 @@ import { import { activeQuery, anyCountsBackFromNow, countsBackFromNow, type FlowQuery } from "./query"; import { listen, rpc } from "./rpc"; import { changesFor, FlowEvents } from "./searchChanges"; -import { - ALL_ID, - APPS_ID, - buildSidebarTree, - DOMAINS_ID, - itemsById, - queryInText, - SAVED_ID, - type SavedRow, - selectionText, -} from "./sidebarTree"; +import { buildSidebarTree, type SavedRow } from "./sidebarTree"; -type Store = ReturnType; +export type Store = ReturnType; /** How long new flows gather before they reach the list, so a burst renders once */ const FLUSH_MS = 100; @@ -76,7 +65,7 @@ export const flowsLoadedAtom = atom(false); * does not re-render what reads it. */ export const noFlowsAtom = atom( - (get) => get(flowsLoadedAtom) && get(flowTotalAtom) === 0 && get(searchTextAtom).trim() === "", + (get) => get(flowsLoadedAtom) && get(flowTotalAtom) === 0 && get(appliedFilterAtom).trim() === "", ); /** The system proxy has been on at some point since this install, which first run waits for */ @@ -87,23 +76,14 @@ export const systemProxyEverOnAtom = atomWithStorage("system_proxy_ever_on", fal /** Some flow held names the app that sent it, which only some platforms can tell */ export const hasAppsAtom = atom((get) => get(flowsAtom).some((f) => f.clientName != null)); -/** The search box's text as typed, which the sidebar's selection writes too */ -export const searchTextAtom = atom(""); - -/** Bumped when the text changes from outside the box, such as the sidebar, so the box takes it */ -export const searchTextVersionAtom = atom(0); - -/** The search box's text once typing settles */ -export const searchTermsAtom = atom(""); - /** System's flows are left out of All traffic, by the eye on System's row */ export const hideSystemAtom = atomWithStorage("sidebar_hide_system", false, undefined, { getOnInit: true, }); -/** The search the list shows, from the search box's settled text, or null for all traffic */ +/** The search the list shows, from the filter applied, or null for all traffic */ export const activeQueryAtom = atom((get) => - activeQuery(get(searchTermsAtom), get(hideSystemAtom)), + activeQuery(get(appliedFilterAtom), get(hideSystemAtom)), ); /** The saved searches, in their order */ @@ -123,13 +103,6 @@ const savedRowsAtom = atom((get) => { })); }); -/** Writes `text` into the search box at once, as the sidebar does, without waiting for typing */ -export const setSearchTextAtom = atom(null, (_get, set, text: string) => { - set(searchTextAtom, text); - set(searchTermsAtom, text); - set(searchTextVersionAtom, (v) => v + 1); -}); - /** Flows All traffic covers in the database: every one, or all but System's while hidden */ export const allTrafficTotalAtom = atom((get) => get(hideSystemAtom) ? get(nonSystemTotalAtom) : get(flowTotalAtom), @@ -454,9 +427,8 @@ export function syncFlows(store: Store) { applySortedChangesTo(store, changes); const selected = store.get(selectedFlowAtom); const change = changes.findLast((c) => c.model.id === selected?.id); - if (change != null) { - store.set(selectedFlowAtom, change.change.type === "delete" ? null : change.model); - } + if (change?.change.type === "delete") store.set(dropSelectedAtom, new Set([change.model.id])); + else if (change != null) store.set(selectedFlowAtom, change.model); if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS); } @@ -573,7 +545,7 @@ export function syncFlows(store: Store) { : s.flows; return { ...s, flows, buffered }; }); - if (removed.has(store.get(selectedFlowAtom)?.id ?? "")) store.set(selectedFlowAtom, null); + store.set(dropSelectedAtom, removed); if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS); }); @@ -805,7 +777,7 @@ const emptyFlowsAtom = atom(null, (_get, set) => { set(sortedWindowAtom, (s) => s == null ? s : { ...s, flows: [], boundary: null, cursor: null, buffered: [] }, ); - set(selectedFlowAtom, null); + set(dropSelectedAtom); set(followingAtom, true); }); @@ -824,71 +796,6 @@ export const clearFlowsAtom = atom(null, async (_get, set) => { } }); -/** When the person last clicked or pressed a key in the sidebar */ -let lastSidebarInput = 0; - -/** How soon after a click or key a change of the sidebar's selection counts as theirs */ -const SIDEBAR_INPUT_MS = 500; - -/** Notes a click or key in the sidebar, so the selection change it makes writes the search */ -export function noteSidebarInput() { - lastSidebarInput = Date.now(); -} - -/** - * Keeps the sidebar's selection and the search box in step. Selecting rows writes their query - * into the box; typing keeps a row selected while its query is still one of the box's terms, - * and All traffic selected while the box is empty. - */ -export function syncSidebarSelection(store: Store) { - const selectedAtom = selectedIdsFamily(SIDEBAR_TREE_ID); - let syncing = false; - - // The tree writes its selection again as it renders, the same rows in a new array, or its last - // row when the selection was emptied; only a change of rows right after a click or a key in - // the sidebar is the person selecting. - let lastRows = store.get(selectedAtom).join("\n"); - store.sub(selectedAtom, () => { - const ids = store.get(selectedAtom); - const key = ids.join("\n"); - if (key === lastRows) return; - lastRows = key; - if (syncing || Date.now() - lastSidebarInput > SIDEBAR_INPUT_MS) return; - // Group headings are not filters. - const rows = ids.filter((id) => id !== APPS_ID && id !== DOMAINS_ID && id !== SAVED_ID); - if (rows.length === 0) return; - const items = itemsById(store.get(sidebarTreeAtom)); - const selected = rows.flatMap((id) => { - const item = items.get(id); - return item == null ? [] : [item]; - }); - if (selected.length === 0) return; - const text = selectionText(selected); - if (text === store.get(searchTextAtom)) return; - syncing = true; - store.set(setSearchTextAtom, text); - syncing = false; - }); - - store.sub(searchTextAtom, () => { - if (syncing) return; - const text = store.get(searchTextAtom); - const items = itemsById(store.get(sidebarTreeAtom)); - const ids = store.get(selectedAtom); - const kept = ids.filter((id) => { - const item = items.get(id); - return item != null && item.query !== "" && queryInText(item.query, text); - }); - const next = text.trim() === "" ? [ALL_ID] : kept; - if (next.length === ids.length && next.every((id, i) => id === ids[i])) return; - syncing = true; - store.set(selectedAtom, next); - syncing = false; - }); - - if (store.get(selectedAtom).length === 0) store.set(selectedAtom, [ALL_ID]); -} - /** * Keeps `proxyStateAtom` current: on each state change, and polled while the engine is not * running here, so another process taking it or letting it go shows. diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx index a729936eb..1d4c7cd9b 100644 --- a/apps/yaak-proxy/main.tsx +++ b/apps/yaak-proxy/main.tsx @@ -6,13 +6,16 @@ import { type ReactNode, StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { ProxyLayout } from "./components/ProxyLayout"; import { useHotkeyContextValue } from "./lib/hotkeys"; -import { syncFlows, syncProxyState, syncSidebarSelection } from "./lib/store"; +import { syncNavigation, syncSidebarSelection } from "./lib/navigation"; +import { syncFlows, syncProxyState } from "./lib/store"; import "./main.css"; const queryClient = new QueryClient(); const jotaiStore = createStore(); syncProxyState(jotaiStore); +// Before the flows load, so they load for the filter it restores +syncNavigation(jotaiStore); syncFlows(jotaiStore); syncSidebarSelection(jotaiStore); diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts index eeb878d1c..7f5b40806 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_models.ts @@ -1,6 +1,16 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ModelChangeEvent } from "./ModelChangeEvent"; +export type FilterHistoryEntry = { +/** + * The filter, as the search box took it. + */ +filter: string, +/** + * When it was last applied. + */ +appliedAt: string, }; + /** * One proxied request and its response. The bodies are in `blobs.sqlite`, not the row; a * stream's are its messages, and read back from them (`bodies::from_messages`). diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts index 266575b86..d5226a8b4 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Flow, FlowBody, FlowMessage, GrpcSchema, ModelPayload, SavedQuery } from "./gen_models"; +import type { FilterHistoryEntry, Flow, FlowBody, FlowMessage, GrpcSchema, ModelPayload, SavedQuery } from "./gen_models"; export type ActionInvocation = { "scope": "global", action: GlobalAction, }; @@ -35,6 +35,8 @@ export type CaStatusRequest = Record; export type CaTrustRequest = Record; +export type ClearFilterHistoryRequest = Record; + export type ClearFlowsRequest = Record; /** @@ -115,6 +117,8 @@ entry: string, */ builtIn: boolean, }; +export type DeleteFilterHistoryRequest = { filter: string, }; + export type DeleteSavedQueryRequest = { id: string, }; export type DropReason = "hop_by_hop" | "set_by_sending" | "body_decoded"; @@ -149,6 +153,8 @@ language?: FilterLanguage, }; export type FilterBodyResponse = { content: string, error: string | null, }; +export type FilterHistoryChangedPayload = Record; + export type FilterLanguage = "jsonpath" | "xpath"; /** @@ -239,6 +245,11 @@ decodeError?: string, */ note?: string, }; +/** + * Flows by id, for showing ones the list has not loaded. Those cleared or deleted are left out. + */ +export type GetFlowsRequest = { ids: Array, }; + export type GetMessagePayloadRequest = { flowId: string, seq: number, }; export type GetMessagePayloadResponse = { @@ -333,6 +344,8 @@ export type ListActionsRequest = Record; export type ListActionsResponse = { actions: Array<[ActionInvocation, ActionMetadata]>, }; +export type ListFilterHistoryRequest = Record; + export type ListFlowsResponse = { /** * Newest first. @@ -459,6 +472,11 @@ export type QueryValuesRequest = { */ path: string, }; +/** + * A filter applied from the search box, or from the history itself, put at its top. + */ +export type RecordFilterRequest = { filter: string, }; + export type ReflectGrpcHostRequest = { /** * `host` or `host:port`, over TLS on 443 unless a port is given, or either after @@ -472,9 +490,9 @@ export type RenameSavedQueryRequest = { id: string, name: string, }; export type ResetCapturedDataRequest = Record; -export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, }; +export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, filter_history_changed: FilterHistoryChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, }; -export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array], match_flows: [MatchFlowsRequest, Array], count_saved_queries: [CountSavedQueriesRequest, Array], }; +export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], list_grpc_schemas: [ListGrpcSchemasRequest, Array], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array], match_flows: [MatchFlowsRequest, Array], get_flows: [GetFlowsRequest, Array], count_saved_queries: [CountSavedQueriesRequest, Array], }; export type SaveFlowBodyRequest = { flowId: string, which: FlowBody, /** diff --git a/crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql b/crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql new file mode 100644 index 000000000..62d18ea27 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql @@ -0,0 +1,6 @@ +-- Filters applied from the search box, each once, most recently applied first. +CREATE TABLE filter_history +( + filter TEXT NOT NULL PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL +); diff --git a/crates-proxy/yaak-proxy-lib/src/db.rs b/crates-proxy/yaak-proxy-lib/src/db.rs index cbb0d171f..088d7113d 100644 --- a/crates-proxy/yaak-proxy-lib/src/db.rs +++ b/crates-proxy/yaak-proxy-lib/src/db.rs @@ -519,6 +519,7 @@ mod tests { tables, vec![ "_sqlx_migrations", + "filter_history", "flow_clear_mark", "flow_messages", "flow_sweep_pending", @@ -615,7 +616,8 @@ mod tests { assert_eq!( &applied[6..], [ - "00000007", "00000008", "00000009", "00000010", "00000011", "00000012", "00000013" + "00000007", "00000008", "00000009", "00000010", "00000011", "00000012", "00000013", + "00000014" ] ); let mark_columns = columns(&conn, "flow_clear_mark"); diff --git a/crates-proxy/yaak-proxy-lib/src/filter_history.rs b/crates-proxy/yaak-proxy-lib/src/filter_history.rs new file mode 100644 index 000000000..4c4f473b9 --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/filter_history.rs @@ -0,0 +1,152 @@ +//! Filters applied from the search box, most recent first, which its magnifier lists. + +use crate::db::ProxyQueryManager; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// How many filters are kept; applying one more forgets the oldest. +pub const MAX_FILTERS: usize = 50; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +pub struct FilterHistoryEntry { + /// The filter, as the search box took it. + pub filter: String, + /// When it was last applied. + pub applied_at: String, +} + +fn failed(what: &str, e: impl std::fmt::Display) -> String { + format!("Failed to {what} the filter history: {e}") +} + +/// Every filter kept, most recently applied first. Empty for a database from before the +/// history, read without migrating. +pub fn list(db: &ProxyQueryManager) -> Result, String> { + db.with_conn(|c| { + let conn = c.conn().resolve(); + // Applying one again inserts it anew, so the newest row is the latest applied. + let Ok(mut stmt) = conn.prepare( + "SELECT filter, applied_at FROM filter_history ORDER BY rowid DESC LIMIT ?", + ) else { + return Ok(Vec::new()); + }; + stmt.query_map([MAX_FILTERS as i64], |row| { + Ok(FilterHistoryEntry { filter: row.get(0)?, applied_at: row.get(1)? }) + }) + .and_then(|rows| rows.collect::>>()) + .map_err(|e| failed("list", e)) + }) +} + +/// Puts `filter` at the top, once, and forgets all but the newest [`MAX_FILTERS`]. A blank +/// filter is not kept. Returns whether it was recorded. +pub fn record(db: &ProxyQueryManager, filter: &str) -> Result { + let filter = filter.trim(); + if filter.is_empty() { + return Ok(false); + } + db.with_tx(|c| { + let conn = c.conn().resolve(); + conn.execute("DELETE FROM filter_history WHERE filter = ?", [filter])?; + conn.execute("INSERT INTO filter_history (filter) VALUES (?)", [filter])?; + conn.execute( + "DELETE FROM filter_history WHERE rowid NOT IN \ + (SELECT rowid FROM filter_history ORDER BY rowid DESC LIMIT ?)", + [MAX_FILTERS as i64], + )?; + Ok::<_, yaak_database::Error>(true) + }) + .map_err(|e| failed("record", e)) +} + +/// Forgets one filter. False when it was not kept. +pub fn delete(db: &ProxyQueryManager, filter: &str) -> Result { + db.with_tx(|c| { + c.conn() + .resolve() + .execute("DELETE FROM filter_history WHERE filter = ?", [filter.trim()]) + .map(|n| n > 0) + .map_err(yaak_database::Error::from) + }) + .map_err(|e| failed("remove from", e)) +} + +/// Forgets every filter. Returns how many there were. +pub fn clear(db: &ProxyQueryManager) -> Result { + db.with_tx(|c| { + c.conn() + .resolve() + .execute("DELETE FROM filter_history", []) + .map_err(yaak_database::Error::from) + }) + .map_err(|e| failed("clear", e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn open(dir: &tempfile::TempDir) -> ProxyQueryManager { + ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap() + } + + fn filters(db: &ProxyQueryManager) -> Vec { + list(db).unwrap().into_iter().map(|e| e.filter).collect() + } + + #[test] + fn records_once_each_with_the_latest_first() { + let dir = tempfile::tempdir().unwrap(); + let db = open(&dir); + assert_eq!(filters(&db), Vec::::new()); + + assert!(record(&db, " @status:>=400 ").unwrap()); + assert!(record(&db, "stripe").unwrap()); + assert!(record(&db, "@method:post").unwrap()); + assert_eq!(filters(&db), ["@method:post", "stripe", "@status:>=400"]); + + // Applied again, it moves to the top rather than being listed twice. + assert!(record(&db, "@status:>=400").unwrap()); + assert_eq!(filters(&db), ["@status:>=400", "@method:post", "stripe"]); + + // Nothing to keep for a blank one. + assert!(!record(&db, " ").unwrap()); + assert_eq!(filters(&db).len(), 3); + assert!(!list(&db).unwrap()[0].applied_at.is_empty()); + } + + #[test] + fn keeps_the_newest_fifty() { + let dir = tempfile::tempdir().unwrap(); + let db = open(&dir); + for i in 0..MAX_FILTERS + 5 { + record(&db, &format!("f{i}")).unwrap(); + } + let kept = filters(&db); + assert_eq!(kept.len(), MAX_FILTERS); + assert_eq!(kept.first().map(String::as_str), Some("f54")); + assert_eq!(kept.last().map(String::as_str), Some("f5")); + + // One applied again from the bottom is kept, and none other is lost for it. + record(&db, "f5").unwrap(); + let kept = filters(&db); + assert_eq!((kept.len(), kept[0].as_str()), (MAX_FILTERS, "f5")); + assert!(kept.contains(&"f6".to_string())); + } + + #[test] + fn removes_one_or_all() { + let dir = tempfile::tempdir().unwrap(); + let db = open(&dir); + for f in ["a", "b", "c"] { + record(&db, f).unwrap(); + } + assert!(delete(&db, "b").unwrap()); + assert!(!delete(&db, "b").unwrap()); + assert_eq!(filters(&db), ["c", "a"]); + assert_eq!(clear(&db).unwrap(), 2); + assert_eq!(filters(&db), Vec::::new()); + } +} diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs index d5cb19938..8d34ec8e1 100644 --- a/crates-proxy/yaak-proxy-lib/src/lib.rs +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -7,6 +7,7 @@ pub mod control; pub mod convert; pub mod db; mod exit_gate; +pub mod filter_history; pub mod flows; pub mod grpc; pub mod har; @@ -1745,6 +1746,38 @@ pub struct CountSavedQueriesRequest { #[ts(export, export_to = "gen_rpc.ts")] pub struct SavedQueriesChangedPayload {} +/// Flows by id, for showing ones the list has not loaded. Those cleared or deleted are left out. +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct GetFlowsRequest { + pub ids: Vec, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ListFilterHistoryRequest {} + +/// A filter applied from the search box, or from the history itself, put at its top. +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct RecordFilterRequest { + pub filter: String, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct DeleteFilterHistoryRequest { + pub filter: String, +} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ClearFilterHistoryRequest {} + +#[derive(Serialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct FilterHistoryChangedPayload {} + #[derive(Deserialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] pub struct ClearFlowsRequest {} @@ -2761,6 +2794,47 @@ fn delete_saved_query(ctx: &ProxyCtx, req: DeleteSavedQueryRequest) -> Result Result, RpcError> { + blocking(move || { + let db = ctx.db()?; + Ok(req.ids.iter().filter_map(|id| flows::find(&db, id)).collect()) + }) + .await +} + +fn list_filter_history( + ctx: &ProxyCtx, + _req: ListFilterHistoryRequest, +) -> Result, RpcError> { + filter_history::list(&ctx.db()?).map_err(rpc_error) +} + +fn record_filter(ctx: &ProxyCtx, req: RecordFilterRequest) -> Result { + let recorded = filter_history::record(&ctx.db()?, &req.filter).map_err(rpc_error)?; + if recorded { + ctx.events.emit("filter_history_changed", &FilterHistoryChangedPayload {}); + } + Ok(recorded) +} + +fn delete_filter_history( + ctx: &ProxyCtx, + req: DeleteFilterHistoryRequest, +) -> Result { + let deleted = filter_history::delete(&ctx.db()?, &req.filter).map_err(rpc_error)?; + ctx.events.emit("filter_history_changed", &FilterHistoryChangedPayload {}); + Ok(deleted) +} + +fn clear_filter_history( + ctx: &ProxyCtx, + _req: ClearFilterHistoryRequest, +) -> Result { + let cleared = filter_history::clear(&ctx.db()?).map_err(rpc_error)?; + ctx.events.emit("filter_history_changed", &FilterHistoryChangedPayload {}); + Ok(cleared) +} + async fn count_saved_queries( ctx: ProxyCtx, req: CountSavedQueriesRequest, @@ -3644,6 +3718,10 @@ define_rpc! { create_saved_query(CreateSavedQueryRequest) -> saved_queries::SavedQuery, rename_saved_query(RenameSavedQueryRequest) -> saved_queries::SavedQuery, delete_saved_query(DeleteSavedQueryRequest) -> bool, + list_filter_history(ListFilterHistoryRequest) -> Vec, + record_filter(RecordFilterRequest) -> bool, + delete_filter_history(DeleteFilterHistoryRequest) -> bool, + clear_filter_history(ClearFilterHistoryRequest) -> usize, list_grpc_schemas(ListGrpcSchemasRequest) -> Vec, list_messages(ListMessagesRequest) -> ListMessagesResponse, remove_grpc_schema(RemoveGrpcSchemaRequest) -> bool, @@ -3684,10 +3762,12 @@ define_rpc! { open_terminal(OpenTerminalRequest) -> bool, query_values(QueryValuesRequest) -> Vec, match_flows(MatchFlowsRequest) -> Vec, + get_flows(GetFlowsRequest) -> Vec, count_saved_queries(CountSavedQueriesRequest) -> Vec, } events { decrypt_list_changed(DecryptListChangedPayload), + filter_history_changed(FilterHistoryChangedPayload), flows_cleared(FlowsClearedPayload), flows_removed(FlowsRemovedPayload), message_write(MessagePayload), @@ -4464,6 +4544,55 @@ mod tests { } } + /// A place the app goes back to reads its flow by id, which is gone once cleared. + #[tokio::test] + async fn get_flows_leaves_out_missing_and_cleared_flows() { + let dir = tempfile::tempdir().unwrap(); + let (events, _rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap(); + flow_with_response(&ctx, "fl_a", "text/plain", b"one"); + flow_with_response(&ctx, "fl_b", "text/plain", b"two"); + let ids = |flows: Vec| flows.into_iter().map(|f| f.id).collect::>(); + + let req = GetFlowsRequest { ids: vec!["fl_b".into(), "fl_gone".into(), "fl_a".into()] }; + assert_eq!(ids(get_flows(ctx.clone(), req).await.unwrap()), ["fl_b", "fl_a"]); + + flows::clear(&ctx.db().unwrap()).unwrap(); + let req = GetFlowsRequest { ids: vec!["fl_a".into()] }; + assert!(get_flows(ctx.clone(), req).await.unwrap().is_empty()); + } + + /// Filters applied are listed newest first, each once, and the app hears of each change. + #[test] + fn filter_history_commands_record_list_and_forget() { + let dir = tempfile::tempdir().unwrap(); + let (events, rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap(); + let heard = || rx.try_iter().filter(|e| e.event == "filter_history_changed").count(); + let listed = || { + list_filter_history(&ctx, ListFilterHistoryRequest {}) + .unwrap() + .into_iter() + .map(|e| e.filter) + .collect::>() + }; + heard(); + + for filter in ["stripe", "@status:>=400", "stripe", " "] { + record_filter(&ctx, RecordFilterRequest { filter: filter.into() }).unwrap(); + } + assert_eq!(listed(), ["stripe", "@status:>=400"]); + // A blank filter is not kept, and changes nothing to hear of. + assert_eq!(heard(), 3); + + let req = DeleteFilterHistoryRequest { filter: "stripe".into() }; + assert!(delete_filter_history(&ctx, req).unwrap()); + assert_eq!(listed(), ["@status:>=400"]); + assert_eq!(clear_filter_history(&ctx, ClearFilterHistoryRequest {}).unwrap(), 1); + assert!(listed().is_empty()); + assert_eq!(heard(), 2); + } + #[tokio::test] async fn the_app_still_hears_why_a_body_failed_to_decode() { let dir = tempfile::tempdir().unwrap(); @@ -5061,6 +5190,7 @@ mod tests { flow_with_response(&ctx, "fl_a", "text/plain", b"one"); flow_with_response(&ctx, "fl_b", "text/plain", b"two"); saved_queries::create(&ctx.db().unwrap(), "Errors", "@status:>=400").unwrap(); + filter_history::record(&ctx.db().unwrap(), "@host:api.stripe.com").unwrap(); assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), 2); } if let Some(settings) = settings { @@ -5070,7 +5200,8 @@ mod tests { ProxyCtx::new(dir, events, keys.clone()).unwrap() } - /// By default a launch starts with no traffic, bodies included, and keeps saved searches. + /// By default a launch starts with no traffic, bodies included, and keeps saved searches + /// and the filter history. #[test] fn a_launch_clears_captured_traffic_by_default() { let dir = tempfile::tempdir().unwrap(); @@ -5081,6 +5212,11 @@ mod tests { assert_eq!(blobs_of(&ctx, "fl_a", 0), 0); let saved = saved_queries::list(&db).unwrap(); assert_eq!(saved.iter().map(|s| s.name.as_str()).collect::>(), ["Errors"]); + let history = filter_history::list(&db).unwrap(); + assert_eq!( + history.iter().map(|h| h.filter.as_str()).collect::>(), + ["@host:api.stripe.com"] + ); // New traffic is written as before. flow_with_response(&ctx, "fl_c", "text/plain", b"three"); assert_eq!(flows::count(&db).unwrap(), 1); diff --git a/crates-tauri/yaak-app-proxy/src/lib.rs b/crates-tauri/yaak-app-proxy/src/lib.rs index fcebddd1e..1f226c46b 100644 --- a/crates-tauri/yaak-app-proxy/src/lib.rs +++ b/crates-tauri/yaak-app-proxy/src/lib.rs @@ -41,6 +41,16 @@ fn setup_window_menu(win: &WebviewWindow) { error!("Failed to emit the HAR export: {e}"); } } + window_menu::GO_BACK_ID | window_menu::GO_FORWARD_ID => { + let event = if event_id == window_menu::GO_BACK_ID { + window_menu::GO_BACK_EVENT + } else { + window_menu::GO_FORWARD_EVENT + }; + if let Err(e) = webview_window.emit(event, ()) { + error!("Failed to emit {event}: {e}"); + } + } "dev.refresh" => webview_window.eval("location.reload()").unwrap(), "dev.toggle_devtools" => { if webview_window.is_devtools_open() { @@ -82,6 +92,12 @@ async fn rpc( router.dispatch(&cmd, payload, &ctx).await.map_err(|e| e.message) } +/// Go's Back and Forward, enabled as the window has somewhere to go. +#[tauri::command] +fn set_go_menu(app: tauri::AppHandle, back: bool, forward: bool) { + window_menu::set_go_enabled(&app, back, forward); +} + pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_os::init()) @@ -135,7 +151,7 @@ pub fn run() { Ok(()) }) - .invoke_handler(tauri::generate_handler![rpc]) + .invoke_handler(tauri::generate_handler![rpc, set_go_menu]) .build(tauri::generate_context!()) .expect("error while building yaak proxy tauri application") .run(|app_handle, event| { diff --git a/crates-tauri/yaak-app-proxy/src/window_menu.rs b/crates-tauri/yaak-app-proxy/src/window_menu.rs index c09faed14..6fbea22ff 100644 --- a/crates-tauri/yaak-app-proxy/src/window_menu.rs +++ b/crates-tauri/yaak-app-proxy/src/window_menu.rs @@ -11,6 +11,37 @@ pub const EXPORT_HAR_ID: &str = "file.export_har"; /// The event the window hears File's Export All Flows as HAR by. pub const EXPORT_HAR_EVENT: &str = "menu_export_har"; +/// Go's Back and Forward, which the window carries out along its own places, and enables while +/// there is somewhere to go. +pub const GO_BACK_ID: &str = "go.back"; +pub const GO_FORWARD_ID: &str = "go.forward"; + +/// The events the window hears Go's Back and Forward by. +pub const GO_BACK_EVENT: &str = "menu_go_back"; +pub const GO_FORWARD_EVENT: &str = "menu_go_forward"; + +/// Enables Go's Back and Forward as the window has somewhere to go. +pub fn set_go_enabled(app_handle: &AppHandle, back: bool, forward: bool) { + let Some(menu) = app_handle.menu() else { + return; + }; + let Ok(items) = menu.items() else { + return; + }; + for (id, enabled) in [(GO_BACK_ID, back), (GO_FORWARD_ID, forward)] { + let item = items + .iter() + .filter_map(|kind| kind.as_submenu()) + .find_map(|submenu| submenu.get(id)) + .and_then(|kind| kind.as_menuitem().cloned()); + if let Some(item) = item + && let Err(e) = item.set_enabled(enabled) + { + log::warn!("Failed to enable {id}: {e}"); + } + } +} + pub fn app_menu(app_handle: &AppHandle) -> tauri::Result> { let pkg_info = app_handle.package_info(); let config = app_handle.config(); @@ -124,6 +155,22 @@ pub fn app_menu(app_handle: &AppHandle) -> tauri::Result> &PredefinedMenuItem::fullscreen(app_handle, None)?, ], )?, + // Disabled until the window says there is somewhere to go. + &Submenu::with_items( + app_handle, + "Go", + true, + &[ + &MenuItemBuilder::with_id(GO_BACK_ID, "Back") + .accelerator("CmdOrCtrl+[") + .enabled(false) + .build(app_handle)?, + &MenuItemBuilder::with_id(GO_FORWARD_ID, "Forward") + .accelerator("CmdOrCtrl+]") + .enabled(false) + .build(app_handle)?, + ], + )?, &window_menu, &help_menu, #[cfg(dev)] diff --git a/packages/ui/src/components/Dropdown.tsx b/packages/ui/src/components/Dropdown.tsx index de81cf601..f0d7a76cc 100644 --- a/packages/ui/src/components/Dropdown.tsx +++ b/packages/ui/src/components/Dropdown.tsx @@ -78,8 +78,18 @@ export type DropdownItemDefault = { */ submenuTrigger?: "row" | "button"; icon?: IconProps["icon"]; + /** Buttons over the row's right end, shown while it is hovered, each acting without selecting it */ + actions?: DropdownItemAction[]; }; +export interface DropdownItemAction { + icon: IconProps["icon"]; + title: string; + onSelect: () => void; + /** Closes the menu after, as one opening a dialog does; the menu stays open otherwise */ + closeMenu?: boolean; +} + export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent; export interface DropdownProps { @@ -819,6 +829,7 @@ const Menu = forwardRef setActiveSubmenu({ item, parent: el })} + onCloseAll={handleCloseAll} // oxlint-disable-next-line no-array-index-key -- It's fine key={i} item={item} @@ -891,10 +902,15 @@ interface MenuItemProps { onFocus: (item: DropdownItemDefault) => void; onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void; onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void; + /** Closes the whole menu, for an action that asks to */ + onCloseAll?: () => void; focused: boolean; isParentOfActiveSubmenu?: boolean; } +/** Room the row leaves at its right for the buttons over it, by how many there are */ +const ROW_BUTTONS_PADDING = ["", "pr-8", "pr-14", "pr-20"]; + function MenuItem({ className, focused, @@ -903,6 +919,7 @@ function MenuItem({ item, onSelect, onOpenSubmenu, + onCloseAll, isParentOfActiveSubmenu, ...props }: MenuItemProps) { @@ -939,6 +956,9 @@ function MenuItem({ }; const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button"; + const actions = item.actions ?? []; + // Buttons over the row: its actions, then its submenu's trigger + const overlaid = actions.length + (hasButtonSubmenu ? 1 : 0); const rightSlot = item.submenu && !hasButtonSubmenu ? ( @@ -952,8 +972,8 @@ function MenuItem({ ref={initRef} size="sm" tabIndex={-1} - onMouseEnter={hasButtonSubmenu ? undefined : handleMouseEnter} - onMouseLeave={hasButtonSubmenu ? undefined : (e) => e.currentTarget.blur()} + onMouseEnter={overlaid > 0 ? undefined : handleMouseEnter} + onMouseLeave={overlaid > 0 ? undefined : (e) => e.currentTarget.blur()} disabled={item.disabled} onFocus={handleFocus} onClick={handleClick} @@ -974,7 +994,7 @@ function MenuItem({ "min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap", "focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1", isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm", - hasButtonSubmenu && "pr-8", + ROW_BUTTONS_PADDING[overlaid], item.color === "danger" && "text-danger!", item.color === "primary" && "text-primary!", item.color === "success" && "text-success!", @@ -988,13 +1008,13 @@ function MenuItem({ ); - if (!hasButtonSubmenu) { + if (overlaid === 0) { return button; } - // The submenu trigger overlays the row as a sibling (not a child) because the row is - // itself a button and buttons cannot nest. Hover handling lives on this wrapper so the - // row keeps its focus highlight while the mouse is over the trigger. + // The actions and submenu trigger overlay the row as siblings (not children) because the row + // is itself a button and buttons cannot nest. Hover handling lives on this wrapper so the + // row keeps its focus highlight while the mouse is over them. return (
- { - // Prevent the trigger from stealing focus, which would unhighlight the row - e.preventDefault(); - }} - onClick={(e) => { - e.stopPropagation(); - onOpenSubmenu(item, e.currentTarget); - }} - /> + {actions.map((action) => ( + e.preventDefault()} + onClick={(e) => { + e.stopPropagation(); + action.onSelect(); + if (action.closeMenu) onCloseAll?.(); + }} + /> + ))} + {hasButtonSubmenu && ( + { + // Prevent the trigger from stealing focus, which would unhighlight the row + e.preventDefault(); + }} + onClick={(e) => { + e.stopPropagation(); + onOpenSubmenu(item, e.currentTarget); + }} + /> + )}
); diff --git a/packages/ui/src/components/editor/query/extension.ts b/packages/ui/src/components/editor/query/extension.ts index e273af6a9..ba8adc394 100644 --- a/packages/ui/src/components/editor/query/extension.ts +++ b/packages/ui/src/components/editor/query/extension.ts @@ -34,6 +34,11 @@ export interface QueryFilterOptions { * searches. `typed` is the word, and the completions replace it. */ moreCompletions?: (typed: string) => Completion[]; + /** + * Completions above the engine's while the box is empty, such as recent searches, each + * replacing the whole text + */ + leadCompletions?: () => Completion[]; /** The terms of a query the search cannot run, underlined as typing settles */ problems?: (text: string) => Promise; /** @@ -139,6 +144,7 @@ function completionSource({ values, loadValues, moreCompletions, + leadCompletions, fieldsOnly, }: QueryFilterOptions) { return async (ctx: CompletionContext): Promise => { @@ -179,11 +185,14 @@ function completionSource({ if (from === word.from) options.push(...more); } } - if (options.length === 0) return null; - // Field names pop up for something typed; on an empty spot, only when asked for const onlyFields = options.every((o) => o.type === "field"); - if (onlyFields && from === to && !ctx.explicit) return null; + if (onlyFields && from === to && !ctx.explicit) options.length = 0; + + // In an empty box, both they and the leading ones start at its start + const lead = text === "" ? (leadCompletions?.() ?? []) : []; + options.unshift(...lead); + if (options.length === 0) return null; // The engine has already filtered and ranked the options return { from, to, options, filter: false }; diff --git a/packages/ui/src/components/http/HttpRequestPaneView.tsx b/packages/ui/src/components/http/HttpRequestPaneView.tsx index 56a6fd619..0121f66ff 100644 --- a/packages/ui/src/components/http/HttpRequestPaneView.tsx +++ b/packages/ui/src/components/http/HttpRequestPaneView.tsx @@ -116,6 +116,8 @@ export interface HttpRequestPaneViewProps { * request doesn't carry to the next */ defaultTab?: string; + /** The tab to show over any remembered, while this request offers it */ + activeTab?: string; /** A tab was chosen */ onTabChange?: (tab: string) => void; } @@ -168,6 +170,7 @@ export function HttpRequestPaneView({ urlLine, dimEmptyTabs = false, defaultTab, + activeTab, onTabChange, }: HttpRequestPaneViewProps) { const { ErrorBoundary } = useEditorHost(); @@ -291,6 +294,7 @@ export function HttpRequestPaneView({ storageKey={tabsStorageKey} activeTabKey={request.id} defaultValue={defaultTab} + value={activeTab} keyedDefault={defaultTab != null} onChangeValue={onTabChange} > diff --git a/packages/ui/src/components/http/HttpResponsePaneView.tsx b/packages/ui/src/components/http/HttpResponsePaneView.tsx index 7d0d10537..8ff7b77a1 100644 --- a/packages/ui/src/components/http/HttpResponsePaneView.tsx +++ b/packages/ui/src/components/http/HttpResponsePaneView.tsx @@ -83,6 +83,8 @@ export interface HttpResponsePaneViewProps { * doesn't carry to the next */ defaultTab?: string; + /** The tab to show over any remembered, while this response offers it */ + activeTab?: string; /** A tab was chosen */ onTabChange?: (tab: string) => void; } @@ -115,6 +117,7 @@ export function HttpResponsePaneView({ bare = false, dimEmptyTabs = false, defaultTab, + activeTab, onTabChange, }: HttpResponsePaneViewProps) { const { ErrorBoundary } = useEditorHost(); @@ -269,6 +272,7 @@ export function HttpResponsePaneView({ storageKey={tabsStorageKey} activeTabKey={activeTabKey} defaultValue={defaultTab} + value={activeTab} keyedDefault={defaultTab != null} onChangeValue={onTabChange} > diff --git a/packages/ui/src/components/tabs/Tabs.tsx b/packages/ui/src/components/tabs/Tabs.tsx index 541117c8b..72381ea69 100644 --- a/packages/ui/src/components/tabs/Tabs.tsx +++ b/packages/ui/src/components/tabs/Tabs.tsx @@ -79,6 +79,11 @@ export interface TabsProps { label: string; /** Default tab value. If not provided, defaults to first tab. */ defaultValue?: string; + /** + * The tab shown, over the one stored or last chosen, while it is offered. Choosing another + * still calls `onChangeValue`, for the owner to pass it back. + */ + value?: string; /** Called when active tab changes */ onChangeValue?: (value: string) => void; tabs: TabItem[]; @@ -103,6 +108,7 @@ export interface TabsProps { export const Tabs = forwardRef(function Tabs( { defaultValue, + value: controlledValue, onChangeValue: onChangeValueProp, label, children, @@ -146,7 +152,12 @@ export const Tabs = forwardRef(function Tabs( keyedDefault && internal?.key !== activeTabKey ? undefined : internal?.value; const offered = (v: string | undefined) => v != null && (!keyedDefault || originalTabs.some((t) => t.value === v)); - const value = [storedActiveTab, internalValue, defaultValue].find(offered) ?? originalTabs[0]?.value; + const controlled = originalTabs.some((t) => t.value === controlledValue) + ? controlledValue + : undefined; + const value = + [controlled, storedActiveTab, internalValue, defaultValue].find(offered) ?? + originalTabs[0]?.value; // Helper to normalize storage (handle migration from old format) const normalizeStorage = useCallback( diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index a004464d3..57be17b70 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -15,6 +15,7 @@ export { DropMarker } from "./components/DropMarker"; export type { ContextMenuProps, DropdownItem, + DropdownItemAction, DropdownItemContent, DropdownItemDefault, DropdownItemSeparator, From cc83c7cd1e3e29d25d1ceef9ede315b01a597afd Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 10:48:34 -0700 Subject: [PATCH 2/4] Keep filters out of the web view's storage, and let a newer selection win over a late read Filters can hold captured values, so neither the reload's places nor the launch's last filter go into web view storage: the engine holds the places in memory while the app runs, and proxy.db's last_filter row keeps the last filter. A read of a selected flow that answers after the window shows a newer copy no longer replaces it. The pane and stream harnesses select through goToPlace, so switching a tab no longer empties their card. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/harness/FlowPanesHarness.tsx | 9 ++- apps/yaak-proxy/harness/LockedHarness.tsx | 9 ++- apps/yaak-proxy/harness/MessagesHarness.tsx | 11 +-- apps/yaak-proxy/harness/backend.ts | 12 +++ apps/yaak-proxy/lib/navigation.test.ts | 67 +++++++++++++++- apps/yaak-proxy/lib/navigation.ts | 80 +++++++++++-------- apps/yaak-proxy/main.tsx | 2 +- .../yaak-proxy-lib/bindings/gen_rpc.ts | 16 +++- .../migrations/00000014_filter_history.sql | 7 ++ crates-proxy/yaak-proxy-lib/src/db.rs | 1 + .../yaak-proxy-lib/src/filter_history.rs | 46 +++++++++++ crates-proxy/yaak-proxy-lib/src/lib.rs | 71 ++++++++++++++++ 12 files changed, 281 insertions(+), 50 deletions(-) diff --git a/apps/yaak-proxy/harness/FlowPanesHarness.tsx b/apps/yaak-proxy/harness/FlowPanesHarness.tsx index e24dcada3..75889c098 100644 --- a/apps/yaak-proxy/harness/FlowPanesHarness.tsx +++ b/apps/yaak-proxy/harness/FlowPanesHarness.tsx @@ -1,10 +1,10 @@ -import { useSetAtom } from "jotai"; +import { useSetAtom, useStore } from "jotai"; import { useEffect, useLayoutEffect, useState } from "react"; import { AddColumnDialog } from "../components/AddColumnDialog"; import { FlowDetail } from "../components/FlowDetail"; import { FlowList } from "../components/FlowList"; -import { selectedFlowAtom } from "../lib/atoms"; import { browserTabMemory, learnedTabKey, flowShape } from "../lib/defaultTab"; +import { goToPlace } from "../lib/navigation"; import { flowWindowAtom } from "../lib/store"; import { type FlowFixture, fixtures } from "./fixtures"; @@ -42,7 +42,7 @@ export function FlowPanesHarness() { function FixtureCard({ fixture }: { fixture: FlowFixture }) { const params = new URLSearchParams(location.search); - const setSelected = useSetAtom(selectedFlowAtom); + const store = useStore(); const setWindow = useSetAtom(flowWindowAtom); const [ready, setReady] = useState(false); useLayoutEffect(() => { @@ -52,7 +52,8 @@ function FixtureCard({ fixture }: { fixture: FlowFixture }) { browserTabMemory()?.setItem(learnedTabKey("response", flowShape(fixture.flow)), tab); } setWindow({ flows, hasOlder: false, total: flows.length }); - setSelected(flows.find((f) => f.id === fixture.flow.id) ?? fixture.flow); + const shown = flows.find((f) => f.id === fixture.flow.id) ?? fixture.flow; + goToPlace(store, "update", { selection: [shown.id], flow: shown }); setReady(true); // oxlint-disable-next-line react-hooks/exhaustive-deps -- once per fixture }, [fixture]); diff --git a/apps/yaak-proxy/harness/LockedHarness.tsx b/apps/yaak-proxy/harness/LockedHarness.tsx index cf948763e..3612813c5 100644 --- a/apps/yaak-proxy/harness/LockedHarness.tsx +++ b/apps/yaak-proxy/harness/LockedHarness.tsx @@ -82,10 +82,11 @@ export function LockedHarness() { const [synced, setSynced] = useState(false); useEffect(() => { syncProxyState(store); - syncNavigation(store); - syncFlows(store); - syncSidebarSelection(store); - setSynced(true); + void syncNavigation(store).then(() => { + syncFlows(store); + syncSidebarSelection(store); + setSynced(true); + }); }, [store]); if (!synced) return null; return ( diff --git a/apps/yaak-proxy/harness/MessagesHarness.tsx b/apps/yaak-proxy/harness/MessagesHarness.tsx index 1d980d0c6..84f1148fe 100644 --- a/apps/yaak-proxy/harness/MessagesHarness.tsx +++ b/apps/yaak-proxy/harness/MessagesHarness.tsx @@ -1,11 +1,11 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; -import { useSetAtom } from "jotai"; +import { useSetAtom, useStore } from "jotai"; import { useLayoutEffect, useMemo, useState } from "react"; import { FlowDetail } from "../components/FlowDetail"; import { FlowList } from "../components/FlowList"; import { ProxyEditorHost } from "../components/ProxyEditorHost"; import { MessageSourceContext } from "../hooks/useFlowMessages"; -import { selectedFlowAtom } from "../lib/atoms"; +import { goToPlace } from "../lib/navigation"; import { flowWindowAtom } from "../lib/store"; import { fixtureSource, type StreamFixture, streamFixtures } from "./streamFixtures"; @@ -51,15 +51,16 @@ function listedFlows(shown: Flow): Flow[] { function StreamDetail({ fixture }: { fixture: StreamFixture }) { const source = useMemo(() => fixtureSource(fixture), [fixture]); - const setSelected = useSetAtom(selectedFlowAtom); + const store = useStore(); const setWindow = useSetAtom(flowWindowAtom); const [flow, setFlow] = useState(fixture.flow); const withList = new URLSearchParams(location.search).has("list"); useLayoutEffect(() => { const flows = listedFlows(flow); setWindow({ flows, hasOlder: false, total: flows.length }); - setSelected(flows.find((f) => f.id === flow.id) ?? flow); - }, [flow, setSelected, setWindow]); + const shown = flows.find((f) => f.id === flow.id) ?? flow; + goToPlace(store, "update", { selection: [shown.id], flow: shown }); + }, [flow, store, setWindow]); // The count on the flow follows its messages, as the engine writes it useLayoutEffect( () => source.subscribe(() => setFlow((f) => ({ ...f, messageCount: f.messageCount + 1 }))), diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index 48c5d3dbc..c87286c53 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -72,6 +72,7 @@ function emit(event: string, payload: unknown = {}) { /** Filters applied, newest first, and saved searches, kept across reloads as the database is */ const HISTORY_KEY = "harness_filter_history"; const SAVED_KEY = "harness_saved_queries"; +const LAST_FILTER_KEY = "harness_last_filter"; function stored(key: string): T[] { try { @@ -146,6 +147,17 @@ function answer(cmd: string, payload: unknown): unknown { store(HISTORY_KEY, []); emit("filter_history_changed"); return 0; + // Held by the engine for as long as the app runs; the tab's session here + case "get_window_places": + return sessionStorage.getItem("harness_window_places"); + case "set_window_places": + sessionStorage.setItem("harness_window_places", String(req.places)); + return true; + case "get_last_filter": + return localStorage.getItem(LAST_FILTER_KEY) ?? ""; + case "set_last_filter": + localStorage.setItem(LAST_FILTER_KEY, String(req.filter)); + return true; case "list_saved_queries": return stored(SAVED_KEY); case "create_saved_query": { diff --git a/apps/yaak-proxy/lib/navigation.test.ts b/apps/yaak-proxy/lib/navigation.test.ts index 39d295b07..591e30f66 100644 --- a/apps/yaak-proxy/lib/navigation.test.ts +++ b/apps/yaak-proxy/lib/navigation.test.ts @@ -14,6 +14,8 @@ import { EMPTY_PLACE, stackOf } from "./places"; const existing = new Set(); const recorded: string[] = []; +let lastFilter = ""; +let windowPlaces: string | null = null; vi.mock("./rpc", () => ({ rpc: vi.fn(async (cmd: string, payload: { ids?: string[]; filter?: string }) => { @@ -21,6 +23,10 @@ vi.mock("./rpc", () => ({ return (payload.ids ?? []).filter((id) => existing.has(id)).map((id) => flow(id)); } if (cmd === "record_filter") recorded.push(payload.filter ?? ""); + if (cmd === "get_last_filter") return lastFilter; + if (cmd === "get_window_places") return windowPlaces; + if (cmd === "set_window_places") windowPlaces = (payload as { places: string }).places; + if (cmd === "set_last_filter") lastFilter = payload.filter ?? ""; return true; }), listen: vi.fn(() => () => {}), @@ -29,7 +35,7 @@ vi.mock("./tauri", () => ({ command: vi.fn(async () => {}), subscribe: vi.fn(() // The package's index reaches for the window as it loads; the sidebar's selection is all used. vi.mock("@yaakapp-internal/ui", () => ({ selectedIdsFamily: () => atom([]) })); -const { applyFilter, goToPlace, searchDraftAtom, searchDraftVersionAtom } = +const { applyFilter, goToPlace, searchDraftAtom, searchDraftVersionAtom, syncNavigation } = await import("./navigation"); function flow(id: string): Flow { @@ -47,6 +53,65 @@ const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); beforeEach(() => { existing.clear(); recorded.length = 0; + lastFilter = ""; + windowPlaces = null; +}); + +describe("syncNavigation", () => { + test("a launch restores the last filter applied, and keeps each one applied after", async () => { + lastFilter = "@host:*.github.com"; + const store = createStore(); + await syncNavigation(store); + await settle(); + expect(store.get(appliedFilterAtom)).toBe("@host:*.github.com"); + expect(store.get(searchDraftAtom)).toBe("@host:*.github.com"); + expect(store.get(canGoBackAtom)).toBe(false); + + applyFilter(store, "stripe"); + await settle(); + expect(lastFilter).toBe("stripe"); + }); + + test("a launch the person has already moved on from keeps where they are", async () => { + lastFilter = "old"; + const store = createStore(); + await syncNavigation(store); + applyFilter(store, "new"); + await settle(); + expect(store.get(appliedFilterAtom)).toBe("new"); + }); + + test("a reload takes back the places, the tabs and the selection, not the last filter", async () => { + existing.add("B"); + lastFilter = "elsewhere"; + const before = createStore(); + await syncNavigation(before); + applyFilter(before, "x"); + goToPlace(before, "add", { selection: ["B"], flow: flow("B") }); + goToPlace(before, "update", { tabs: { response: "headers" } }); + await settle(); + + const after = createStore(); + await syncNavigation(after); + await settle(); + expect(after.get(appliedFilterAtom)).toBe("x"); + expect(after.get(placeAtom).tabs).toEqual({ response: "headers" }); + expect(after.get(selectedFlowAtom)?.id).toBe("B"); + expect(goToPlace(after, "back")).toBe(true); + }); + + test("a late read of the selected flow leaves a newer one alone", async () => { + existing.add("A"); + const store = newStore(); + goToPlace(store, "add", { selection: ["A"], flow: flow("A") }); + goToPlace(store, "add", { selection: ["B"], flow: flow("B") }); + store.set(selectedFlowAtom, null); + goToPlace(store, "back"); + const newer = { ...flow("A"), status: 200 } as Flow; + goToPlace(store, "update", { selection: ["A"], flow: newer }); + await settle(); + expect(store.get(selectedFlowAtom)).toBe(newer); + }); }); describe("goToPlace", () => { diff --git a/apps/yaak-proxy/lib/navigation.ts b/apps/yaak-proxy/lib/navigation.ts index d6b2b15c8..bd7fdbb9d 100644 --- a/apps/yaak-proxy/lib/navigation.ts +++ b/apps/yaak-proxy/lib/navigation.ts @@ -163,49 +163,61 @@ function readSelected(store: Store, id: string) { store.set(placeStackAtom, (s) => stackWithout(s, new Set([id]))); return; } - // The window has moved on meanwhile. - if (currentPlace(store.get(placeStackAtom)).selection.has(id)) { - store.set(selectedFlowAtom, found); - } + // The window has moved on meanwhile, or shows the flow already, as it was since. + const current = currentPlace(store.get(placeStackAtom)).selection.has(id); + if (current && store.get(selectedFlowAtom) == null) store.set(selectedFlowAtom, found); }) .catch((err) => console.warn("Failed to read the selected flow", err)); } -/** Where the window's places are kept for a reload, for this session */ -const PLACES_KEY = "proxy_places"; -/** Where the filter last applied is kept for the next launch */ -const LAST_FILTER_KEY = "proxy_last_filter"; - -function read(storage: () => Storage, key: string): string | null { - try { - return storage().getItem(key); - } catch { - return null; - } -} - -function write(storage: () => Storage, key: string, value: string) { - try { - storage().setItem(key, value); - } catch { - // Not kept: the next reload starts from the last filter. - } -} - /** - * Restores where the window was, before anything reads the filter: its places after a reload, - * or the last filter applied at launch. Keeps them as they change. + * Restores where the window was, before the flows load for its filter: its places after a + * reload, which the engine holds in memory while the app runs, or at launch the last filter + * applied, which the encrypted database keeps. Filters can hold captured values, so neither is + * kept in the web view's storage. Keeps them as they change. */ -export function syncNavigation(store: Store) { - const kept = stackFromJson(read(() => sessionStorage, PLACES_KEY)); - const lastFilter = read(() => localStorage, LAST_FILTER_KEY) ?? ""; - const stack = kept ?? stackOf({ ...EMPTY_PLACE, filter: lastFilter }); +export async function syncNavigation(store: Store) { + const kept = stackFromJson(await rpc("get_window_places", {}).catch(() => null)); + const stack = kept ?? stackOf(EMPTY_PLACE); store.set(placeStackAtom, stack); showPlace(store, currentPlace(stack), { filter: true, reveal: true }); + if (kept == null) { + // Unread while traffic is locked, as the database is. + rpc("get_last_filter", {}) + .then((filter) => { + // Only over the window as it opened, not where it has been taken since. + const now = store.get(placeStackAtom); + if (filter === "" || now.places.length > 1 || currentPlace(now).filter !== "") return; + goToPlace(store, "update", { filter }); + }) + .catch(() => {}); + } + + // One write at a time, the latest places after it, so they arrive in order. + let pending: string | null = null; + let writing = false; + const write = () => { + if (writing || pending == null) return; + const places = pending; + pending = null; + writing = true; + rpc("set_window_places", { places }) + .catch(() => {}) + .finally(() => { + writing = false; + write(); + }); + }; store.sub(placeStackAtom, () => { - const now = store.get(placeStackAtom); - write(() => sessionStorage, PLACES_KEY, stackToJson(now)); - write(() => localStorage, LAST_FILTER_KEY, currentPlace(now).filter); + pending = stackToJson(store.get(placeStackAtom)); + write(); + }); + let lastFilter = store.get(appliedFilterAtom); + store.sub(appliedFilterAtom, () => { + const filter = store.get(appliedFilterAtom); + if (filter === lastFilter) return; + lastFilter = filter; + rpc("set_last_filter", { filter }).catch(() => {}); }); } diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx index 1d4c7cd9b..f4a86dd70 100644 --- a/apps/yaak-proxy/main.tsx +++ b/apps/yaak-proxy/main.tsx @@ -15,7 +15,7 @@ const jotaiStore = createStore(); syncProxyState(jotaiStore); // Before the flows load, so they load for the filter it restores -syncNavigation(jotaiStore); +await syncNavigation(jotaiStore); syncFlows(jotaiStore); syncSidebarSelection(jotaiStore); diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts index d5226a8b4..41f542d20 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts @@ -250,6 +250,8 @@ note?: string, }; */ export type GetFlowsRequest = { ids: Array, }; +export type GetLastFilterRequest = Record; + export type GetMessagePayloadRequest = { flowId: string, seq: number, }; export type GetMessagePayloadResponse = { @@ -326,6 +328,8 @@ export type GetStorageSizeResponse = { */ bytes: number, }; +export type GetWindowPlacesRequest = Record; + export type GlobalAction = "recording_toggle" | "flows_clear"; export type HelperRegisterRequest = Record; @@ -492,7 +496,7 @@ export type ResetCapturedDataRequest = Record; export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, filter_history_changed: FilterHistoryChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, }; -export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], list_grpc_schemas: [ListGrpcSchemasRequest, Array], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array], match_flows: [MatchFlowsRequest, Array], get_flows: [GetFlowsRequest, Array], count_saved_queries: [CountSavedQueriesRequest, Array], }; +export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], get_last_filter: [GetLastFilterRequest, string], set_last_filter: [SetLastFilterRequest, boolean], get_window_places: [GetWindowPlacesRequest, string | null], set_window_places: [SetWindowPlacesRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array], match_flows: [MatchFlowsRequest, Array], get_flows: [GetFlowsRequest, Array], count_saved_queries: [CountSavedQueriesRequest, Array], }; export type SaveFlowBodyRequest = { flowId: string, which: FlowBody, /** @@ -606,6 +610,11 @@ export type SetKeepTrafficRequest = { */ bytes: number | null, }; +/** + * The filter the window applied, kept in the encrypted database for the next launch. + */ +export type SetLastFilterRequest = { filter: string, }; + export type SetPortRequest = { port: number, }; export type SetRecordingRequest = { enabled: boolean, }; @@ -614,6 +623,11 @@ export type SetSystemProxyAtLaunchRequest = { enabled: boolean, }; export type SetTrafficRequest = { traffic: TrafficRetention, }; +/** + * The window's places, opaque to the engine, kept in memory for a reload of the window. + */ +export type SetWindowPlacesRequest = { places: string, }; + /** * A column the list sorts by. */ diff --git a/crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql b/crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql index 62d18ea27..00b369bb9 100644 --- a/crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql +++ b/crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql @@ -4,3 +4,10 @@ CREATE TABLE filter_history filter TEXT NOT NULL PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ); + +-- The filter the window last applied, however it was applied, which the next launch restores. +CREATE TABLE last_filter +( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), + filter TEXT NOT NULL +); diff --git a/crates-proxy/yaak-proxy-lib/src/db.rs b/crates-proxy/yaak-proxy-lib/src/db.rs index 088d7113d..545cb1e84 100644 --- a/crates-proxy/yaak-proxy-lib/src/db.rs +++ b/crates-proxy/yaak-proxy-lib/src/db.rs @@ -525,6 +525,7 @@ mod tests { "flow_sweep_pending", "flows", "grpc_schemas", + "last_filter", "saved_queries" ] ); diff --git a/crates-proxy/yaak-proxy-lib/src/filter_history.rs b/crates-proxy/yaak-proxy-lib/src/filter_history.rs index 4c4f473b9..08d7c8c79 100644 --- a/crates-proxy/yaak-proxy-lib/src/filter_history.rs +++ b/crates-proxy/yaak-proxy-lib/src/filter_history.rs @@ -84,6 +84,38 @@ pub fn clear(db: &ProxyQueryManager) -> Result { .map_err(|e| failed("clear", e)) } +/// The filter the window last applied, in any way, or empty for none. Empty for a database from +/// before it, read without migrating. +pub fn last(db: &ProxyQueryManager) -> Result { + db.with_conn(|c| { + let conn = c.conn().resolve(); + let Ok(mut stmt) = conn.prepare("SELECT filter FROM last_filter WHERE id = 1") else { + return Ok(String::new()); + }; + match stmt.query_row([], |row| row.get(0)) { + Ok(filter) => Ok(filter), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(String::new()), + Err(e) => Err(failed("read the last filter from", e)), + } + }) +} + +/// Keeps the filter the window applied, for the next launch to restore. +pub fn set_last(db: &ProxyQueryManager, filter: &str) -> Result<(), String> { + db.with_tx(|c| { + c.conn() + .resolve() + .execute( + "INSERT INTO last_filter (id, filter) VALUES (1, ?) \ + ON CONFLICT (id) DO UPDATE SET filter = excluded.filter", + [filter], + ) + .map(|_| ()) + .map_err(yaak_database::Error::from) + }) + .map_err(|e| failed("keep the last filter in", e)) +} + #[cfg(test)] mod tests { use super::*; @@ -136,6 +168,20 @@ mod tests { assert!(kept.contains(&"f6".to_string())); } + #[test] + fn keeps_the_last_filter_applied() { + let dir = tempfile::tempdir().unwrap(); + let db = open(&dir); + assert_eq!(last(&db).unwrap(), ""); + set_last(&db, "@host:*.github.com").unwrap(); + set_last(&db, "stripe").unwrap(); + assert_eq!(last(&db).unwrap(), "stripe"); + set_last(&db, "").unwrap(); + assert_eq!(last(&db).unwrap(), ""); + // Apart from the history, which only Enter writes. + assert_eq!(filters(&db), Vec::::new()); + } + #[test] fn removes_one_or_all() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs index 8d34ec8e1..8a6298ec9 100644 --- a/crates-proxy/yaak-proxy-lib/src/lib.rs +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -110,6 +110,9 @@ pub struct ProxyCtx { /// Captured traffic is to be emptied as the databases are first unlocked, this launch /// being set to clear it. Taken once it is. clear_at_launch: Arc, + /// The window's places, as it last sent them, for a reload of it to take back. Held in + /// memory only, as filters can hold captured values, and gone when the app quits. + window_places: Arc>>, /// `traffic.lock`, locked shared for as long as this process has the databases open, so /// another launch can't delete them from under it: clearing them takes it alone. traffic_lock: Arc>>, @@ -167,6 +170,7 @@ impl ProxyCtx { resets: Arc::new(AtomicU64::new(0)), sweeper: Default::default(), clear_at_launch: Arc::new(AtomicBool::new(false)), + window_places: Arc::new(Mutex::new(None)), traffic_lock: Arc::new(Mutex::new(None)), system_proxy_intent: Arc::new(Mutex::new(())), watching_system_proxy: Arc::new(AtomicBool::new(false)), @@ -1778,6 +1782,28 @@ pub struct ClearFilterHistoryRequest {} #[ts(export, export_to = "gen_rpc.ts")] pub struct FilterHistoryChangedPayload {} +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct GetLastFilterRequest {} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct GetWindowPlacesRequest {} + +/// The window's places, opaque to the engine, kept in memory for a reload of the window. +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SetWindowPlacesRequest { + pub places: String, +} + +/// The filter the window applied, kept in the encrypted database for the next launch. +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SetLastFilterRequest { + pub filter: String, +} + #[derive(Deserialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] pub struct ClearFlowsRequest {} @@ -2809,6 +2835,27 @@ fn list_filter_history( filter_history::list(&ctx.db()?).map_err(rpc_error) } +fn get_window_places( + ctx: &ProxyCtx, + _req: GetWindowPlacesRequest, +) -> Result, RpcError> { + Ok(ctx.window_places.lock().unwrap_or_else(PoisonError::into_inner).clone()) +} + +fn set_window_places(ctx: &ProxyCtx, req: SetWindowPlacesRequest) -> Result { + *ctx.window_places.lock().unwrap_or_else(PoisonError::into_inner) = Some(req.places); + Ok(true) +} + +fn get_last_filter(ctx: &ProxyCtx, _req: GetLastFilterRequest) -> Result { + filter_history::last(&ctx.db()?).map_err(rpc_error) +} + +fn set_last_filter(ctx: &ProxyCtx, req: SetLastFilterRequest) -> Result { + filter_history::set_last(&ctx.db()?, &req.filter).map_err(rpc_error)?; + Ok(true) +} + fn record_filter(ctx: &ProxyCtx, req: RecordFilterRequest) -> Result { let recorded = filter_history::record(&ctx.db()?, &req.filter).map_err(rpc_error)?; if recorded { @@ -3722,6 +3769,10 @@ define_rpc! { record_filter(RecordFilterRequest) -> bool, delete_filter_history(DeleteFilterHistoryRequest) -> bool, clear_filter_history(ClearFilterHistoryRequest) -> usize, + get_last_filter(GetLastFilterRequest) -> String, + set_last_filter(SetLastFilterRequest) -> bool, + get_window_places(GetWindowPlacesRequest) -> Option, + set_window_places(SetWindowPlacesRequest) -> bool, list_grpc_schemas(ListGrpcSchemasRequest) -> Vec, list_messages(ListMessagesRequest) -> ListMessagesResponse, remove_grpc_schema(RemoveGrpcSchemaRequest) -> bool, @@ -4562,6 +4613,24 @@ mod tests { assert!(get_flows(ctx.clone(), req).await.unwrap().is_empty()); } + /// The window's places are held for a reload of it, in memory, and none at launch. + #[test] + fn window_places_are_held_in_memory_for_a_reload() { + let dir = tempfile::tempdir().unwrap(); + let (events, _rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap(); + assert_eq!(get_window_places(&ctx, GetWindowPlacesRequest {}).unwrap(), None); + let places = r#"{"places":[{"filter":"stripe","selection":[],"tabs":{}}],"index":0}"#; + set_window_places(&ctx, SetWindowPlacesRequest { places: places.into() }).unwrap(); + let held = get_window_places(&ctx.clone(), GetWindowPlacesRequest {}).unwrap(); + assert_eq!(held.as_deref(), Some(places)); + drop(ctx); + + let (events, _rx) = RpcEventEmitter::new(); + let next = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap(); + assert_eq!(get_window_places(&next, GetWindowPlacesRequest {}).unwrap(), None); + } + /// Filters applied are listed newest first, each once, and the app hears of each change. #[test] fn filter_history_commands_record_list_and_forget() { @@ -5191,6 +5260,7 @@ mod tests { flow_with_response(&ctx, "fl_b", "text/plain", b"two"); saved_queries::create(&ctx.db().unwrap(), "Errors", "@status:>=400").unwrap(); filter_history::record(&ctx.db().unwrap(), "@host:api.stripe.com").unwrap(); + filter_history::set_last(&ctx.db().unwrap(), "@status:>=400").unwrap(); assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), 2); } if let Some(settings) = settings { @@ -5217,6 +5287,7 @@ mod tests { history.iter().map(|h| h.filter.as_str()).collect::>(), ["@host:api.stripe.com"] ); + assert_eq!(filter_history::last(&db).unwrap(), "@status:>=400"); // New traffic is written as before. flow_with_response(&ctx, "fl_c", "text/plain", b"three"); assert_eq!(flows::count(&db).unwrap(), 1); From e0b435c416dd6cdf822625f9c14b6f2000214c6e Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 11:01:54 -0700 Subject: [PATCH 3/4] Keep a multi-row sidebar selection through new traffic, and restore rows on Back The shared Tree reset its selection to the last focused row after every render, so several selected rows collapsed to one whenever the tree rendered again, as new traffic changing the counts makes it. It now leaves the selection alone while that row is still in view and selected. The proxy's sidebar sync also makes All traffic exclusive, groups a saved search's own OR in parentheses beside other rows, applies System's heading query, and on Back or Forward selects the rows whose queries make up the restored filter, compared by shape so AND and OR stay apart. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/harness/backend.ts | 5 ++ apps/yaak-proxy/lib/navigation.test.ts | 87 ++++++++++++++++++- apps/yaak-proxy/lib/navigation.ts | 41 +++++---- apps/yaak-proxy/lib/query.test.ts | 18 ++++ apps/yaak-proxy/lib/query.ts | 42 ++++++++- apps/yaak-proxy/lib/sidebarTree.test.ts | 49 +++++++++++ apps/yaak-proxy/lib/sidebarTree.ts | 52 ++++++++++- packages/ui/src/components/tree/Tree.tsx | 15 +++- .../ui/src/components/tree/common.test.ts | 19 ++++ packages/ui/src/components/tree/common.ts | 14 +++ 10 files changed, 313 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/components/tree/common.test.ts diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index c87286c53..646ece45c 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -14,6 +14,8 @@ import { appIconPng, fixtures } from "./fixtures"; export function installFixtureBackend() { const internals = (window as unknown as { __TAURI_INTERNALS__: Record }) .__TAURI_INTERNALS__; + // For checking the window as traffic arrives: `__harnessEmit("model_write", payload)` + (window as unknown as { __harnessEmit: typeof emit }).__harnessEmit = emit; internals.transformCallback = (callback: (event: unknown) => void) => { const id = callbacks.size + 1; callbacks.set(id, callback); @@ -99,6 +101,9 @@ function matches(flow: Flow, query: string): boolean { } })(); const text = `${flow.method} ${flow.url} ${flow.status ?? ""}`.toLowerCase(); + // `OR` between groups, as the sidebar writes several rows + const groups = query.replace(/[()]/g, " ").split(/\s+OR\s+/); + if (groups.length > 1) return groups.some((g) => matches(flow, g)); return query .toLowerCase() .split(/\s+/) diff --git a/apps/yaak-proxy/lib/navigation.test.ts b/apps/yaak-proxy/lib/navigation.test.ts index 591e30f66..228b7678d 100644 --- a/apps/yaak-proxy/lib/navigation.test.ts +++ b/apps/yaak-proxy/lib/navigation.test.ts @@ -33,10 +33,20 @@ vi.mock("./rpc", () => ({ })); vi.mock("./tauri", () => ({ command: vi.fn(async () => {}), subscribe: vi.fn(() => () => {}) })); // The package's index reaches for the window as it loads; the sidebar's selection is all used. -vi.mock("@yaakapp-internal/ui", () => ({ selectedIdsFamily: () => atom([]) })); +const sidebarSelection = vi.hoisted(() => ({ atom: null as unknown })); +vi.mock("@yaakapp-internal/ui", () => ({ selectedIdsFamily: () => sidebarSelection.atom })); -const { applyFilter, goToPlace, searchDraftAtom, searchDraftVersionAtom, syncNavigation } = - await import("./navigation"); +const { + applyFilter, + goToPlace, + noteSidebarInput, + searchDraftAtom, + searchDraftVersionAtom, + syncNavigation, + syncSidebarSelection, +} = await import("./navigation"); +const { sidebarFlowsAtom, sidebarTreeAtom } = await import("./store"); +const { itemsById } = await import("./sidebarTree"); function flow(id: string): Flow { return { id } as Flow; @@ -51,6 +61,7 @@ function newStore() { const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); beforeEach(() => { + sidebarSelection.atom = atom([]); existing.clear(); recorded.length = 0; lastFilter = ""; @@ -211,3 +222,73 @@ describe("goToPlace", () => { expect(store.get(searchDraftVersionAtom)).toBe(version + 1); }); }); + +describe("syncSidebarSelection", () => { + function sidebar() { + const store = newStore(); + const flows = ["https://a.example.com/x", "https://api.github.com/y", "https://api.stripe.com"]; + store.set( + sidebarFlowsAtom, + flows.map((url, i) => ({ id: `f${i}`, url, method: "GET" }) as Flow), + ); + syncSidebarSelection(store); + const selected = sidebarSelection.atom as ReturnType>; + const idOf = (label: string) => + [...itemsById(store.get(sidebarTreeAtom)).values()].find((i) => i.label === label)?.id ?? ""; + // As a click in the sidebar does + const choose = (ids: string[]) => { + noteSidebarInput(); + store.set(selected, ids); + }; + return { store, selected, idOf, choose }; + } + + test("rows chosen together apply as one filter and stay selected", () => { + const { store, selected, idOf, choose } = sidebar(); + const [example, github] = [idOf("example.com"), idOf("github.com")]; + choose([example]); + choose([example, github]); + expect(store.get(appliedFilterAtom)).toBe("@host:*.example.com OR @host:*.github.com"); + expect(store.get(selected)).toEqual([example, github]); + }); + + test("System's heading applies its query; the other headings apply nothing", () => { + const { store, idOf, choose } = sidebar(); + store.set(sidebarFlowsAtom, [ + { + id: "s", + url: "https://ocsp.apple.com/x", + method: "GET", + clientName: "trustd", + clientPath: "/usr/libexec/trustd", + } as Flow, + ]); + choose([idOf("System")]); + expect(store.get(appliedFilterAtom)).toBe("@is:system"); + choose([idOf("Domains")]); + expect(store.get(appliedFilterAtom)).toBe("@is:system"); + }); + + test("All traffic is exclusive either way round", () => { + const { store, selected, idOf, choose } = sidebar(); + const [all, github] = [idOf("All traffic"), idOf("github.com")]; + choose([github]); + choose([github, all]); + expect(store.get(selected)).toEqual([all]); + expect(store.get(appliedFilterAtom)).toBe(""); + choose([all, github]); + expect(store.get(selected)).toEqual([github]); + expect(store.get(appliedFilterAtom)).toBe("@host:*.github.com"); + }); + + test("a box filter keeps the rows in it, and Back finds the rows again", () => { + const { store, selected, idOf, choose } = sidebar(); + const [example, github] = [idOf("example.com"), idOf("github.com")]; + choose([example]); + choose([example, github]); + applyFilter(store, "@host:*.example.com @method:get", { record: true }); + expect(store.get(selected)).toEqual([example]); + goToPlace(store, "back"); + expect(store.get(selected).sort()).toEqual([example, github].sort()); + }); +}); diff --git a/apps/yaak-proxy/lib/navigation.ts b/apps/yaak-proxy/lib/navigation.ts index bd7fdbb9d..7a7952a3d 100644 --- a/apps/yaak-proxy/lib/navigation.ts +++ b/apps/yaak-proxy/lib/navigation.ts @@ -31,8 +31,9 @@ import { ALL_ID, APPS_ID, DOMAINS_ID, + exclusiveAll, itemsById, - queryInText, + rowsForFilter, SAVED_ID, selectionText, } from "./sidebarTree"; @@ -291,45 +292,49 @@ export function syncSidebarSelection(store: Store) { const selectedAtom = selectedIdsFamily(SIDEBAR_TREE_ID); let syncing = false; - function keepRows() { - if (syncing) return; - const text = store.get(appliedFilterAtom); - const items = itemsById(store.get(sidebarTreeAtom)); + function select(next: string[]) { const ids = store.get(selectedAtom); - const kept = ids.filter((id) => { - const item = items.get(id); - return item != null && item.query !== "" && queryInText(item.query, text); - }); - const next = text.trim() === "" ? [ALL_ID] : kept; if (next.length === ids.length && next.every((id, i) => id === ids[i])) return; syncing = true; store.set(selectedAtom, next); syncing = false; } + // `add`: rows whose query is a whole group of the filter join, as when Back returns to a + // filter the rows wrote; otherwise only those selected are kept or dropped. + function keepRows(add: boolean) { + if (syncing) return; + const items = itemsById(store.get(sidebarTreeAtom)); + const next = rowsForFilter(store.get(appliedFilterAtom), store.get(selectedAtom), items, add); + select(next); + } + // The tree writes its selection again as it renders, the same rows in a new array, or its last // row when the selection was emptied; only a change of rows right after a click or a key in // the sidebar is the person selecting. - let lastRows = store.get(selectedAtom).join("\n"); + let lastRows = store.get(selectedAtom); store.sub(selectedAtom, () => { + const before = lastRows; const ids = store.get(selectedAtom); - const key = ids.join("\n"); - if (key === lastRows) return; - lastRows = key; + if (ids.join("\n") === before.join("\n")) return; + lastRows = ids; if (syncing) return; // Not the person's, such as the tree selecting its first row as it mounts over a filter // restored at launch: the rows go back to what the filter says. if (Date.now() - lastSidebarInput > SIDEBAR_INPUT_MS) { - keepRows(); + keepRows(false); return; } + const exclusive = exclusiveAll(ids, before); + if (exclusive !== ids) select(exclusive); // Group headings are not filters. - const rows = ids.filter((id) => id !== APPS_ID && id !== DOMAINS_ID && id !== SAVED_ID); + const rows = exclusive.filter((id) => id !== APPS_ID && id !== DOMAINS_ID && id !== SAVED_ID); if (rows.length === 0) return; const items = itemsById(store.get(sidebarTreeAtom)); const selected = rows.flatMap((id) => { const item = items.get(id); - return item == null ? [] : [item]; + // A heading has no query to apply; System's has its own. + return item == null || (item.query === "" && item.kind !== "all") ? [] : [item]; }); if (selected.length === 0) return; const text = selectionText(selected); @@ -338,7 +343,7 @@ export function syncSidebarSelection(store: Store) { applyFilter(store, text); syncing = false; }); - store.sub(appliedFilterAtom, keepRows); + store.sub(appliedFilterAtom, () => keepRows(true)); // A filter restored at launch selects no row; none at all is All traffic. if (store.get(selectedAtom).length === 0 && store.get(appliedFilterAtom).trim() === "") { diff --git a/apps/yaak-proxy/lib/query.test.ts b/apps/yaak-proxy/lib/query.test.ts index dea324fbf..ed66497b6 100644 --- a/apps/yaak-proxy/lib/query.test.ts +++ b/apps/yaak-proxy/lib/query.test.ts @@ -5,7 +5,9 @@ import { asOneTerm, countsBackFromNow, fieldTerm, + orGroupShapes, pathTerm, + queryShape, termKeys, widerQueries, } from "./query"; @@ -32,6 +34,22 @@ describe("terms", () => { ]); expect(termKeys("")).toEqual([]); }); + + test("come in the groups `OR` separates at the top, a parenthesised one whole", () => { + expect(orGroupShapes("@host:x.com @app:Slack OR (a OR b) OR -@is:system")).toEqual([ + "And(@app:slack,@host:x.com)", + "Or(a,b)", + "-@is:system", + ]); + expect(orGroupShapes("@app:Slack")).toEqual(["@app:slack"]); + expect(orGroupShapes(" ")).toEqual([]); + }); + + test("have a shape that keeps AND and OR apart, whatever the order or case", () => { + expect(queryShape("@app:Slack @host:x.com")).toBe(queryShape("@HOST:x.com @app:slack")); + expect(queryShape("a OR b")).not.toBe(queryShape("a b")); + expect(queryShape("")).toBeNull(); + }); }); describe("activeQuery", () => { diff --git a/apps/yaak-proxy/lib/query.ts b/apps/yaak-proxy/lib/query.ts index c0eb4021b..9064edfd9 100644 --- a/apps/yaak-proxy/lib/query.ts +++ b/apps/yaak-proxy/lib/query.ts @@ -57,6 +57,45 @@ export function nodeText(node: Node, source: string, nested = false): string { /** The terms of a query, each as written and with a `-` when it is excluded, lowercased */ export function termKeys(text: string): string[] { + const root = parseQuery(text); + return root == null ? [] : nodeKeys(root, text); +} + +/** + * A query's shape, for telling whether two say the same: its terms lowercased, with its `AND` + * and `OR` groups kept apart and their members in a set's order. Null for an empty query. + */ +export function queryShape(text: string): string | null { + const root = parseQuery(text); + return root == null ? null : shapeOf(root, text); +} + +/** + * The shape of each group `OR` separates at the top of a query: one for a query with no `OR` + * there, none for an empty one + */ +export function orGroupShapes(text: string): string[] { + const root = parseQuery(text); + if (root == null) return []; + const groups = root.type === "Or" ? root.children : [root]; + return groups.map((g) => shapeOf(g, text)); +} + +function shapeOf(node: Node, text: string): string { + switch (node.type) { + case "And": + case "Or": { + const members = [...new Set(node.children.map((c) => shapeOf(c, text)))].sort(); + return `${node.type}(${members.join(",")})`; + } + case "Not": + return `-${shapeOf(node.node, text)}`; + default: + return text.slice(node.span.start, node.span.end).toLowerCase(); + } +} + +function nodeKeys(node: Node, text: string): string[] { const keys: string[] = []; const walk = (node: Node, negated: boolean) => { switch (node.type) { @@ -73,8 +112,7 @@ export function termKeys(text: string): string[] { ); } }; - const root = parseQuery(text); - if (root != null) walk(root, false); + walk(node, false); return keys; } diff --git a/apps/yaak-proxy/lib/sidebarTree.test.ts b/apps/yaak-proxy/lib/sidebarTree.test.ts index b3a4b973f..2b48c53fc 100644 --- a/apps/yaak-proxy/lib/sidebarTree.test.ts +++ b/apps/yaak-proxy/lib/sidebarTree.test.ts @@ -3,9 +3,12 @@ import type { TreeNode } from "@yaakapp-internal/ui"; import { describe, expect, test } from "vite-plus/test"; import { parseQuery } from "@yaakapp-internal/query"; import { + ALL_ID, buildSidebarTree, + exclusiveAll, itemsById, queryInText, + rowsForFilter, type SidebarItem, selectionText, } from "./sidebarTree"; @@ -262,6 +265,52 @@ describe("selection and the search text", () => { expect(selectionText([a, all])).toBe(""); }); + test("puts a saved search with `OR` of its own in parentheses beside other rows", () => { + const saved = { query: "@status:5xx OR @status:4xx" } as SidebarItem; + const host = { query: "@host:*.github.com" } as SidebarItem; + expect(selectionText([saved, host])).toBe("(@status:5xx OR @status:4xx) OR @host:*.github.com"); + expect(selectionText([saved])).toBe("@status:5xx OR @status:4xx"); + }); + + test("All traffic is selected alone, whichever was chosen last", () => { + expect(exclusiveAll(["a", ALL_ID], ["a"])).toEqual([ALL_ID]); + expect(exclusiveAll([ALL_ID, "a"], [ALL_ID])).toEqual(["a"]); + const rows = ["a", "b"]; + expect(exclusiveAll(rows, ["a"])).toBe(rows); + expect(exclusiveAll([ALL_ID], ["a"])).toEqual([ALL_ID]); + }); + + test("a filter keeps the rows still in it, and finds those whose query is a group of it", () => { + const items = itemsById(buildSidebarTree(flows, 4)); + const github = "app:fieldwork|host:api.github.com"; + const slack = "app:Slack|site:slack.com"; + const both = selectionText([items.get(github), items.get(slack)] as SidebarItem[]); + + // Applied from the box: kept while in it, dropped once not, none added. + expect(rowsForFilter(`${both} card`, [github, slack], items, false)).toEqual([github, slack]); + expect(rowsForFilter("@app:Slack @host:*.slack.com", [github, slack], items, false)).toEqual([ + slack, + ]); + // Gone back to, the rows that wrote it are found again, from none selected. + expect(rowsForFilter(both, [], items, true).sort()).toEqual([github, slack].sort()); + // A group that is only part of a row's query finds nothing. + expect(rowsForFilter("@app:Slack", [], items, true)).not.toContain(slack); + expect(rowsForFilter(" ", [github], items, true)).toEqual([ALL_ID]); + }); + + test("a row is found only by a group that says what its query says", () => { + const either = { id: "saved:either", kind: "saved", query: "@method:GET OR @host:x.com" }; + const system = { id: "system", kind: "group", query: "@is:system" }; + const items = new Map([either, system].map((i) => [i.id, i as SidebarItem])); + // Both terms, but together rather than either + expect(rowsForFilter("@method:GET @host:x.com", [], items, true)).toEqual([]); + expect(rowsForFilter("(@method:GET OR @host:x.com) OR @app:a", [], items, true)).toEqual([ + "saved:either", + ]); + // A heading with a query of its own, as System's, is found as any row is. + expect(rowsForFilter("@is:system", [], items, true)).toEqual(["system"]); + }); + test("keeps a row selected while every term of its query is in the text", () => { expect(queryInText("@app:Slack", "@app:Slack card")).toBe(true); expect(queryInText("@app:slack", "@app:Slack")).toBe(true); diff --git a/apps/yaak-proxy/lib/sidebarTree.ts b/apps/yaak-proxy/lib/sidebarTree.ts index b8728fc0f..68ab80fcf 100644 --- a/apps/yaak-proxy/lib/sidebarTree.ts +++ b/apps/yaak-proxy/lib/sidebarTree.ts @@ -1,7 +1,15 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; import type { TreeNode } from "@yaakapp-internal/ui"; import type { DecryptSubject } from "./decrypt"; -import { fieldTerm, pathTerm, siteTerm, termKeys } from "./query"; +import { + asOneTerm, + fieldTerm, + orGroupShapes, + pathTerm, + queryShape, + siteTerm, + termKeys, +} from "./query"; import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf } from "./search"; export type SidebarKind = @@ -365,11 +373,49 @@ export function itemsById(root: TreeNode): Map return map; } -/** The search text for the selected rows: their queries, OR'd; empty for All traffic */ +/** + * The search text for the selected rows: their queries, OR'd, one with `OR` of its own in + * parentheses so it stays one group; empty for All traffic + */ export function selectionText(items: SidebarItem[]): string { const queries = [...new Set(items.map((i) => i.query))]; if (queries.includes("")) return ""; - return queries.join(" OR "); + return queries.length === 1 ? (queries[0] ?? "") : queries.map(asOneTerm).join(" OR "); +} + +/** + * All traffic is selected alone: chosen beside other rows it takes their place, and another row + * chosen beside it takes its. `before` is the selection as it was. + */ +export function exclusiveAll(ids: string[], before: string[]): string[] { + if (!ids.includes(ALL_ID) || ids.length === 1) return ids; + return before.includes(ALL_ID) ? ids.filter((id) => id !== ALL_ID) : [ALL_ID]; +} + +/** + * The rows a filter applied leaves selected: those selected whose query is still in it, and, + * with `add`, every row whose query is one of its `OR` groups whole, as the filter the rows + * wrote is when Back returns to it. All traffic alone for no filter. + */ +export function rowsForFilter( + text: string, + selected: string[], + items: Map, + add: boolean, +): string[] { + if (text.trim() === "") return [ALL_ID]; + const rows = selected.filter((id) => { + const item = items.get(id); + return item != null && item.query !== "" && queryInText(item.query, text); + }); + if (!add) return rows; + const groups = new Set(orGroupShapes(text)); + for (const [id, item] of items) { + if (item.query === "" || rows.includes(id)) continue; + const shape = queryShape(item.query); + if (shape != null && groups.has(shape)) rows.push(id); + } + return rows; } /** diff --git a/packages/ui/src/components/tree/Tree.tsx b/packages/ui/src/components/tree/Tree.tsx index 697dedca7..597f6e93f 100644 --- a/packages/ui/src/components/tree/Tree.tsx +++ b/packages/ui/src/components/tree/Tree.tsx @@ -28,7 +28,13 @@ import { useAtomValue, useStore } from "jotai"; import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms"; import { type CollapsedAtom, CollapsedAtomContext } from "./context"; import type { ContextMenuRenderer, TreeNode } from "./common"; -import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from "./common"; +import { + closestVisibleNode, + equalSubtree, + getSelectedItems, + hasAncestor, + tabbableSelection, +} from "./common"; import { TreeDragOverlay } from "./TreeDragOverlay"; import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem"; import type { TreeItemListProps } from "./TreeItemList"; @@ -222,9 +228,12 @@ function TreeInner( } const closest = closestVisibleNode(store, collapsedAtom, lastSelectedItem.node); - if (closest != null) { + if (closest != null && lastSelectedId != null) { const id = closest.item.id; - store.set(selectedIdsFamily(treeId), [id]); + const selected = store.get(selectedIdsFamily(treeId)); + const next = tabbableSelection(selected, lastSelectedId, id); + if (next == null) return; + store.set(selectedIdsFamily(treeId), next); store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id }); } }, [selectableItems, treeId]); diff --git a/packages/ui/src/components/tree/common.test.ts b/packages/ui/src/components/tree/common.test.ts new file mode 100644 index 000000000..2b246dd41 --- /dev/null +++ b/packages/ui/src/components/tree/common.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vite-plus/test"; +import { tabbableSelection } from "./common"; + +describe("tabbableSelection", () => { + test("leaves several selected rows alone while the last one focused is in view", () => { + // As the tree renders again, say for new counts, the selection stands. + expect(tabbableSelection(["a", "b", "c"], "b", "b")).toBeNull(); + expect(tabbableSelection(["b"], "b", "b")).toBeNull(); + }); + + test("moves to the closest row in view once the last one focused is hidden", () => { + expect(tabbableSelection(["a", "child"], "child", "parent")).toEqual(["parent"]); + }); + + test("selects the last row focused when the selection no longer holds it", () => { + expect(tabbableSelection([], "b", "b")).toEqual(["b"]); + expect(tabbableSelection(["a"], "b", "b")).toEqual(["b"]); + }); +}); diff --git a/packages/ui/src/components/tree/common.ts b/packages/ui/src/components/tree/common.ts index e03a77b20..7c4f06425 100644 --- a/packages/ui/src/components/tree/common.ts +++ b/packages/ui/src/components/tree/common.ts @@ -85,6 +85,20 @@ export function isVisibleNode( return true; } +/** + * The selection that keeps a row tabbable, from the last row focused and the closest of it still + * in view, or null to leave the selection as it is: while that row is in view and selected, a + * selection of several rows stands, rather than shrinking to it each time the tree renders. + */ +export function tabbableSelection( + selected: readonly string[], + lastId: string, + closestId: string, +): string[] | null { + if (closestId === lastId && selected.includes(lastId)) return null; + return [closestId]; +} + export function closestVisibleNode( store: JotaiStore, collapsedAtom: CollapsedAtom, From 62a15af3954f2c16c4be9313e5749ce52b6a8fb3 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 11:07:08 -0700 Subject: [PATCH 4/4] Confirm clearing Yaak Proxy's traffic in a dialog, with Don't ask again The trash button and its hotkey open a confirm dialog instead of a dropdown. Its Don't ask again checkbox saves a preference that Settings turns back on under Traffic, as Confirm before clearing. Co-Authored-By: Claude Opus 5.5 --- .../components/ClearFlowsButton.tsx | 146 +++++++++++------- apps/yaak-proxy/components/SettingsProxy.tsx | 18 ++- apps/yaak-proxy/lib/clearFlows.test.ts | 56 +++++++ apps/yaak-proxy/lib/clearFlows.ts | 32 ++++ 4 files changed, 189 insertions(+), 63 deletions(-) create mode 100644 apps/yaak-proxy/lib/clearFlows.test.ts create mode 100644 apps/yaak-proxy/lib/clearFlows.ts diff --git a/apps/yaak-proxy/components/ClearFlowsButton.tsx b/apps/yaak-proxy/components/ClearFlowsButton.tsx index 3e0058f89..e12f4b175 100644 --- a/apps/yaak-proxy/components/ClearFlowsButton.tsx +++ b/apps/yaak-proxy/components/ClearFlowsButton.tsx @@ -1,77 +1,109 @@ -import { Dropdown, type DropdownItem, IconButton } from "@yaakapp-internal/ui"; -import { useAtomValue, useSetAtom } from "jotai"; -import { useState } from "react"; +import { Checkbox, Dialog, DialogFooter, IconButton, useHotKey } from "@yaakapp-internal/ui"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { type FormEvent, useCallback, useId, useState } from "react"; +import { clearFlowsAction, confirmClearFlowsAtom } from "../lib/clearFlows"; import { clearFlowsAtom, flowTotalAtom, lockedAtom } from "../lib/store"; +import { useShowToast } from "../lib/toast"; /** - * Clears every flow, once confirmed in its menu, which its hotkey opens too. The list empties at - * once; the flows are deleted in the background. Disabled with none to clear, or while captured - * traffic is locked. + * Clears every flow, from its button or hotkey, once confirmed in a dialog unless "Don't ask + * again" was checked there. The list empties at once; the flows are deleted in the background. + * Disabled with none to clear, or while captured traffic is locked. */ export function ClearFlowsButton() { const total = useAtomValue(flowTotalAtom); const locked = useAtomValue(lockedAtom); - const clear = useSetAtom(clearFlowsAtom); - const [error, setError] = useState(null); + const [confirm, setConfirm] = useAtom(confirmClearFlowsAtom); + const clearFlows = useSetAtom(clearFlowsAtom); + const showToast = useShowToast(); + const [confirming, setConfirming] = useState(false); + const action = clearFlowsAction({ total, locked: locked != null, confirm }); - const items: DropdownItem[] = [ - { - type: "content", - label: ( -
-
- {total === 1 ? "Clear 1 flow?" : `Clear all ${total.toLocaleString()} flows?`} -
-
- {error != null ? ( - {error} - ) : ( - "They are deleted from disk with their bodies, for good." - )} -
-
- ), - }, - { type: "separator" }, - { - label: "Clear", - icon: "trash", - color: "danger", - onSelect: () => { - setError(null); - clear().catch((err: unknown) => { - const message = (err as { message?: unknown } | null)?.message; - setError(typeof message === "string" ? message : String(err)); - }); - }, - }, - ]; + const clear = useCallback(() => { + clearFlows().catch((err: unknown) => { + const message = (err as { message?: unknown } | null)?.message; + showToast({ message: typeof message === "string" ? message : String(err), color: "danger" }); + }); + }, [clearFlows, showToast]); - // Nothing to clear, or nothing that can be read while traffic is locked: no menu, nor hotkey. - if (locked != null || total === 0) { - return ( - - ); - } + const start = useCallback(() => { + if (action === "confirm") setConfirming(true); + else if (action === "clear") clear(); + }, [action, clear]); + + // Bound even when disabled, so the key never falls through to the backend's clear unasked + useHotKey("flows_clear", start); return ( - + <> - + setConfirming(false)} + size="sm" + title="Clear traffic?" + > + {confirming && ( + setConfirming(false)} + onClear={(dontAskAgain) => { + setConfirming(false); + if (dontAskAgain) setConfirm(false); + clear(); + }} + /> + )} + + + ); +} + +function ConfirmClear({ + total, + onCancel, + onClear, +}: { + total: number; + onCancel: () => void; + onClear: (dontAskAgain: boolean) => void; +}) { + const formId = useId(); + const [dontAskAgain, setDontAskAgain] = useState(false); + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onClear(dontAskAgain); + }; + return ( +
+

+ {total === 1 + ? "1 flow will be removed." + : `${total.toLocaleString()} flows will be removed.`} +

+ + + ); } diff --git a/apps/yaak-proxy/components/SettingsProxy.tsx b/apps/yaak-proxy/components/SettingsProxy.tsx index fdc785a73..94359b1eb 100644 --- a/apps/yaak-proxy/components/SettingsProxy.tsx +++ b/apps/yaak-proxy/components/SettingsProxy.tsx @@ -1,10 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { formatSize } from "@yaakapp-internal/lib/formatSize"; -import type { - HttpVersion, - SystemProxyStatus, - TrafficRetention, -} from "@yaakapp-internal/proxy-lib"; +import type { HttpVersion, SystemProxyStatus, TrafficRetention } from "@yaakapp-internal/proxy-lib"; import { Button, Dialog, @@ -22,11 +18,12 @@ import { SettingsSection, VStack, } from "@yaakapp-internal/ui"; -import { useAtomValue, useSetAtom, useStore } from "jotai"; +import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import { useState } from "react"; import { useRpcMutation } from "../hooks/useRpcMutation"; import { useRpcQuery } from "../hooks/useRpcQuery"; import { helperDialogOpenAtom } from "../lib/atoms"; +import { confirmClearFlowsAtom } from "../lib/clearFlows"; import { defaultShell, environmentBlock, @@ -103,6 +100,7 @@ export function SettingsProxy() { {proxyState?.traffic === "keep" && } + @@ -270,6 +268,14 @@ function KeepTrafficRow() { ); } +/** Asking before Clear, which its dialog's "Don't ask again" turns off */ +function ConfirmClearRow() { + const [confirm, setConfirm] = useAtom(confirmClearFlowsAtom); + return ( + + ); +} + function ResetRow() { const [confirming, setConfirming] = useState(false); const store = useStore(); diff --git a/apps/yaak-proxy/lib/clearFlows.test.ts b/apps/yaak-proxy/lib/clearFlows.test.ts new file mode 100644 index 000000000..be0684065 --- /dev/null +++ b/apps/yaak-proxy/lib/clearFlows.test.ts @@ -0,0 +1,56 @@ +import { createStore } from "jotai"; +import { beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import { clearFlowsAction, confirmClearFlowsAtom } from "./clearFlows"; + +describe("clearFlowsAction", () => { + test("asks first while the preference is on", () => { + expect(clearFlowsAction({ total: 3, locked: false, confirm: true })).toBe("confirm"); + }); + + test("clears at once once asking is turned off", () => { + expect(clearFlowsAction({ total: 3, locked: false, confirm: false })).toBe("clear"); + }); + + test("does nothing with none to clear or traffic locked, whatever the preference", () => { + for (const confirm of [true, false]) { + expect(clearFlowsAction({ total: 0, locked: false, confirm })).toBe("disabled"); + expect(clearFlowsAction({ total: 3, locked: true, confirm })).toBe("disabled"); + } + }); +}); + +/** The preference as a new window reads it, once something shows it */ +function mounted() { + const store = createStore(); + const unsub = store.sub(confirmClearFlowsAtom, () => {}); + const value = store.get(confirmClearFlowsAtom); + unsub(); + return value; +} + +describe("confirmClearFlowsAtom", () => { + let saved: Map; + + beforeEach(() => { + saved = new Map(); + vi.stubGlobal("localStorage", { + getItem: (k: string) => saved.get(k) ?? null, + setItem: (k: string, v: string) => void saved.set(k, v), + removeItem: (k: string) => void saved.delete(k), + }); + }); + + test("is on by default", () => { + expect(createStore().get(confirmClearFlowsAtom)).toBe(true); + }); + + test("keeps Don't ask again for later launches, until Settings turns it back on", () => { + createStore().set(confirmClearFlowsAtom, false); + expect(saved.get("confirm_clear_flows")).toBe("false"); + expect(mounted()).toBe(false); + + createStore().set(confirmClearFlowsAtom, true); + expect(saved.get("confirm_clear_flows")).toBe("true"); + expect(mounted()).toBe(true); + }); +}); diff --git a/apps/yaak-proxy/lib/clearFlows.ts b/apps/yaak-proxy/lib/clearFlows.ts new file mode 100644 index 000000000..c3524b2aa --- /dev/null +++ b/apps/yaak-proxy/lib/clearFlows.ts @@ -0,0 +1,32 @@ +import { atomWithStorage, createJSONStorage } from "jotai/utils"; + +/** + * Clearing traffic asks first, until "Don't ask again" is checked in its dialog. The switch in + * Settings turns the asking back on. + */ +export const confirmClearFlowsAtom = atomWithStorage( + "confirm_clear_flows", + true, + // Read through globalThis so tests, which have no localStorage, can import this + createJSONStorage(() => globalThis.localStorage), + { getOnInit: true }, +); + +/** What Clear does now: nothing, ask first, or clear at once */ +export type ClearFlowsAction = "disabled" | "confirm" | "clear"; + +export function clearFlowsAction({ + total, + locked, + confirm, +}: { + /** Flows there are to clear */ + total: number; + /** Captured traffic is locked, so none of it can be read */ + locked: boolean; + /** The saved preference to ask before clearing */ + confirm: boolean; +}): ClearFlowsAction { + if (locked || total === 0) return "disabled"; + return confirm ? "confirm" : "clear"; +}