mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Apply Yaak Proxy's filter on Enter, with places, Back and Forward, and filter history
The search box now composes a filter and Enter applies it. The main window's view (filter, selected flow, card tabs) is a place: a session stack of them backs Back and Forward (⌘[ ⌘], mouse buttons, a Go menu), the current one survives a reload, and the last filter comes back at launch. Filters applied with Enter go into a new filter_history table, listed from the magnifier and recalled with Up in the empty box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e870604607
commit
c2d4c39c62
31 files changed
+1981
-264
No files matched your search
@@ -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 (
|
||||
<HttpRequestPaneView
|
||||
@@ -161,6 +171,7 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
|
||||
bodyLoader={loader}
|
||||
extraTabs={extraTabs}
|
||||
defaultTab={defaultTab}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
urlLine={<UrlLine flow={flow} />}
|
||||
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={() => (
|
||||
|
||||
@@ -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<HTMLDivElement>) => {
|
||||
// ⌘↓ 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<HTMLElement>("[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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<InputHandle | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(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<CSSProperties | null>(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
|
||||
<div ref={boxRef} className="relative h-sm">
|
||||
@@ -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={
|
||||
<Icon icon="search" size="sm" className="ml-2 mr-1 self-center text-text-subtlest" />
|
||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor
|
||||
<div
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex items-center self-center ml-0.5"
|
||||
>
|
||||
<FilterHistory history={history} store={store} disabled={locked} />
|
||||
</div>
|
||||
}
|
||||
rightSlot={
|
||||
text === "" ? (
|
||||
<kbd className="self-center ml-2 mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
|
||||
⌘F
|
||||
</kbd>
|
||||
) : (
|
||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor
|
||||
<div
|
||||
// Pressing these leaves the editor focused, so a wrapped box does not collapse and
|
||||
// move them before the click lands.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex items-center self-center ml-1 mr-0.5"
|
||||
>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Save search",
|
||||
leftSlot: <Icon icon="bookmark" />,
|
||||
onSelect: () => setSavedDialog({ mode: "save", query: text.trim() }),
|
||||
},
|
||||
exportShown(),
|
||||
]}
|
||||
>
|
||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor
|
||||
<div
|
||||
// Pressing these leaves the editor focused, so a wrapped box does not collapse and
|
||||
// move them before the click lands.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex items-center self-center ml-1 mr-0.5"
|
||||
>
|
||||
{unapplied ? (
|
||||
<Key className="mr-1">↵</Key>
|
||||
) : hasText ? null : (
|
||||
<Key className="ml-1 mr-1.5">⌘F</Key>
|
||||
)}
|
||||
{hasText && (
|
||||
<>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Save search",
|
||||
leftSlot: <Icon icon="bookmark" />,
|
||||
onSelect: () =>
|
||||
setSavedDialog({
|
||||
mode: "save",
|
||||
query: (draft.trim() === "" ? applied : draft).trim(),
|
||||
}),
|
||||
},
|
||||
exportShown(),
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="ellipsis"
|
||||
title="Search actions"
|
||||
tooltip
|
||||
iconColor="secondary"
|
||||
/>
|
||||
</Dropdown>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="ellipsis"
|
||||
title="Search actions"
|
||||
icon="x"
|
||||
title="Clear search"
|
||||
tooltip
|
||||
iconColor="secondary"
|
||||
onClick={clear}
|
||||
/>
|
||||
</Dropdown>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="x"
|
||||
title="Clear search"
|
||||
tooltip
|
||||
iconColor="secondary"
|
||||
onClick={clear}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -209,6 +242,183 @@ export const SearchBar = memo(function SearchBar() {
|
||||
);
|
||||
});
|
||||
|
||||
function Key({ children, className }: { children: string; className?: string }) {
|
||||
return (
|
||||
<kbd
|
||||
className={classNames(
|
||||
className,
|
||||
"px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: (
|
||||
<span className="block font-mono text-xs truncate max-w-md" title={filter}>
|
||||
{filter}
|
||||
</span>
|
||||
),
|
||||
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: <Icon icon="trash" />,
|
||||
onSelect: () => void rpc("clear_filter_history", {}).catch(fail),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="search"
|
||||
title="Recent filters"
|
||||
tooltip
|
||||
iconColor="secondary"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown> })
|
||||
.__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<number, (event: unknown) => void>();
|
||||
const listeners = new Map<string, number[]>();
|
||||
|
||||
/** 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<T>(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<string, unknown>;
|
||||
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<string>(HISTORY_KEY).map((filter) => ({ filter, appliedAt: "" }));
|
||||
case "record_filter": {
|
||||
const filter = String(req.filter).trim();
|
||||
if (filter === "") return false;
|
||||
store(HISTORY_KEY, [filter, ...stored<string>(HISTORY_KEY).filter((f) => f !== filter)]);
|
||||
emit("filter_history_changed");
|
||||
return true;
|
||||
}
|
||||
case "delete_filter_history":
|
||||
store(
|
||||
HISTORY_KEY,
|
||||
stored<string>(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<SavedQuery>(SAVED_KEY);
|
||||
case "create_saved_query": {
|
||||
const saved = stored<SavedQuery>(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<SavedQuery>(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: [] };
|
||||
|
||||
@@ -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<Flow | null>(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<PlaceStack>(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<string>) => {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<string[]>([]) }));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
/** 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<null>(MENU_GO_BACK_EVENT, () => goToPlace(store, "back"));
|
||||
const offForward = subscribe<null>(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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
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<string>, b: ReadonlySet<string>): 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<string>): 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<StoredPlace>;
|
||||
if (typeof v.filter !== "string" || !Array.isArray(v.selection)) return null;
|
||||
const tabs: PlaceTabs = {};
|
||||
const t = (v.tabs ?? {}) as Record<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
+10
-103
@@ -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<typeof createStore>;
|
||||
export type Store = ReturnType<typeof createStore>;
|
||||
|
||||
/** 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<FlowQuery | null>((get) =>
|
||||
activeQuery(get(searchTermsAtom), get(hideSystemAtom)),
|
||||
activeQuery(get(appliedFilterAtom), get(hideSystemAtom)),
|
||||
);
|
||||
|
||||
/** The saved searches, in their order */
|
||||
@@ -123,13 +103,6 @@ const savedRowsAtom = atom<SavedRow[]>((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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user