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,