mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Merge origin/main into stetta/tk_mwaz7asqhsi6jowm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a14fc18b4b
44 files changed
+2715
-348
No files matched your search
@@ -1,78 +1,119 @@
|
|||||||
import { Dropdown, type DropdownItem, IconButton, useFormattedHotkey } from "@yaakapp-internal/ui";
|
import {
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
Checkbox,
|
||||||
import { useState } from "react";
|
Dialog,
|
||||||
|
DialogFooter,
|
||||||
|
IconButton,
|
||||||
|
useFormattedHotkey,
|
||||||
|
useHotKey,
|
||||||
|
} from "@yaakapp-internal/ui";
|
||||||
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { type FormEvent, useCallback, useId, useState } from "react";
|
||||||
|
import { clearFlowsAction, confirmClearFlowsAtom } from "../lib/clearFlows";
|
||||||
import { clearFlowsAtom, flowTotalAtom, lockedAtom } from "../lib/store";
|
import { clearFlowsAtom, flowTotalAtom, lockedAtom } from "../lib/store";
|
||||||
|
import { useShowToast } from "../lib/toast";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears every flow, once confirmed in its menu, which its hotkey opens too. The list empties at
|
* Clears every flow, from its button or hotkey, once confirmed in a dialog unless "Don't ask
|
||||||
* once; the flows are deleted in the background. Disabled with none to clear, or while captured
|
* again" was checked there. The list empties at once; the flows are deleted in the background.
|
||||||
* traffic is locked.
|
* Disabled with none to clear, or while captured traffic is locked.
|
||||||
*/
|
*/
|
||||||
export function ClearFlowsButton() {
|
export function ClearFlowsButton() {
|
||||||
const total = useAtomValue(flowTotalAtom);
|
const total = useAtomValue(flowTotalAtom);
|
||||||
const locked = useAtomValue(lockedAtom);
|
const locked = useAtomValue(lockedAtom);
|
||||||
const clear = useSetAtom(clearFlowsAtom);
|
const [confirm, setConfirm] = useAtom(confirmClearFlowsAtom);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const clearFlows = useSetAtom(clearFlowsAtom);
|
||||||
|
const showToast = useShowToast();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const action = clearFlowsAction({ total, locked: locked != null, confirm });
|
||||||
const hotkey = useFormattedHotkey("flows_clear")?.join("");
|
const hotkey = useFormattedHotkey("flows_clear")?.join("");
|
||||||
|
|
||||||
const items: DropdownItem[] = [
|
const clear = useCallback(() => {
|
||||||
{
|
clearFlows().catch((err: unknown) => {
|
||||||
type: "content",
|
const message = (err as { message?: unknown } | null)?.message;
|
||||||
label: (
|
showToast({ message: typeof message === "string" ? message : String(err), color: "danger" });
|
||||||
<div className="px-1.5 py-1 max-w-[17rem]">
|
});
|
||||||
<div className="text-sm text-text">
|
}, [clearFlows, showToast]);
|
||||||
{total === 1 ? "Clear 1 flow?" : `Clear all ${total.toLocaleString()} flows?`}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-subtle mt-0.5">
|
|
||||||
{error != null ? (
|
|
||||||
<span className="text-danger">{error}</span>
|
|
||||||
) : (
|
|
||||||
"They are deleted from disk with their bodies, for good."
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Clear",
|
|
||||||
icon: "trash",
|
|
||||||
color: "danger",
|
|
||||||
onSelect: () => {
|
|
||||||
setError(null);
|
|
||||||
clear().catch((err: unknown) => {
|
|
||||||
const message = (err as { message?: unknown } | null)?.message;
|
|
||||||
setError(typeof message === "string" ? message : String(err));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Nothing to clear, or nothing that can be read while traffic is locked: no menu, nor hotkey.
|
const start = useCallback(() => {
|
||||||
if (locked != null || total === 0) {
|
if (action === "confirm") setConfirming(true);
|
||||||
return (
|
else if (action === "clear") clear();
|
||||||
<IconButton
|
}, [action, clear]);
|
||||||
className="pointer-events-auto"
|
|
||||||
icon="trash"
|
// Bound even when disabled, so the key never falls through to the backend's clear unasked
|
||||||
title={locked != null ? "Clear flows (traffic is locked)" : "Clear flows (none yet)"}
|
useHotKey("flows_clear", start);
|
||||||
tooltip
|
|
||||||
size="sm"
|
|
||||||
iconColor="secondary"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown items={items} hotKeyAction="flows_clear">
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
className="pointer-events-auto"
|
className="pointer-events-auto"
|
||||||
icon="trash"
|
icon="trash"
|
||||||
title={hotkey == null ? "Clear flows" : `Clear flows ${hotkey}`}
|
title={
|
||||||
|
locked != null
|
||||||
|
? "Clear flows (traffic is locked)"
|
||||||
|
: total === 0
|
||||||
|
? "Clear flows (none yet)"
|
||||||
|
: hotkey == null
|
||||||
|
? "Clear flows"
|
||||||
|
: `Clear flows ${hotkey}`
|
||||||
|
}
|
||||||
tooltip
|
tooltip
|
||||||
size="sm"
|
size="sm"
|
||||||
iconColor="secondary"
|
iconColor="secondary"
|
||||||
|
disabled={action === "disabled"}
|
||||||
|
onClick={start}
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
<Dialog
|
||||||
|
open={confirming}
|
||||||
|
onClose={() => setConfirming(false)}
|
||||||
|
size="sm"
|
||||||
|
title="Clear traffic?"
|
||||||
|
>
|
||||||
|
{confirming && (
|
||||||
|
<ConfirmClear
|
||||||
|
total={total}
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
onClear={(dontAskAgain) => {
|
||||||
|
setConfirming(false);
|
||||||
|
if (dontAskAgain) setConfirm(false);
|
||||||
|
clear();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmClear({
|
||||||
|
total,
|
||||||
|
onCancel,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
total: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onClear: (dontAskAgain: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const formId = useId();
|
||||||
|
const [dontAskAgain, setDontAskAgain] = useState(false);
|
||||||
|
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClear(dontAskAgain);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form id={formId} className="flex flex-col gap-3" onSubmit={handleSubmit}>
|
||||||
|
<p>
|
||||||
|
{total === 1
|
||||||
|
? "1 flow will be removed."
|
||||||
|
: `${total.toLocaleString()} flows will be removed.`}
|
||||||
|
</p>
|
||||||
|
<Checkbox checked={dontAskAgain} title="Don't ask again" onChange={setDontAskAgain} />
|
||||||
|
<DialogFooter
|
||||||
|
inline
|
||||||
|
actions={[
|
||||||
|
{ label: "Cancel", onClick: onCancel },
|
||||||
|
{ label: "Clear", color: "danger", form: formId, autoFocus: true },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -18,9 +18,10 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom, useStore } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes";
|
import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes";
|
||||||
|
import { placeTabsAtom } from "../lib/atoms";
|
||||||
import { findHeader } from "../lib/body";
|
import { findHeader } from "../lib/body";
|
||||||
import {
|
import {
|
||||||
browserTabMemory,
|
browserTabMemory,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
flowResponseToView,
|
flowResponseToView,
|
||||||
} from "../lib/flowViews";
|
} from "../lib/flowViews";
|
||||||
import { messagesTabLabel } from "../lib/messages";
|
import { messagesTabLabel } from "../lib/messages";
|
||||||
|
import { goToPlace } from "../lib/navigation";
|
||||||
import { FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
|
import { FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
|
||||||
import { FlowInfo } from "./FlowInfo";
|
import { FlowInfo } from "./FlowInfo";
|
||||||
import { FlowRequestBody } from "./FlowRequestBody";
|
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[]) {
|
function useShapeTab(pane: FlowPane, flow: Flow, available: string[]) {
|
||||||
|
const store = useStore();
|
||||||
const memory = browserTabMemory();
|
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
|
// 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 defaultTab = defaultTabFor(pane, flow, available, memory);
|
||||||
const onTabChange = useCallback(
|
const onTabChange = useCallback(
|
||||||
(tab: string) => learnTab(pane, flow, tab, memory),
|
(tab: string) => {
|
||||||
[pane, flow, memory],
|
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 */
|
/** A body read through the engine's body RPC, as the shared panes' loaders ask for it */
|
||||||
@@ -147,7 +157,7 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
|
|||||||
...(request.auth == null ? [] : [REQUEST_TAB_AUTH]),
|
...(request.auth == null ? [] : [REQUEST_TAB_AUTH]),
|
||||||
REQUEST_TAB_COOKIES,
|
REQUEST_TAB_COOKIES,
|
||||||
];
|
];
|
||||||
const { defaultTab, onTabChange } = useShapeTab("request", flow, available);
|
const { activeTab, defaultTab, onTabChange } = useShapeTab("request", flow, available);
|
||||||
|
|
||||||
const forceUpdateKey = `${flow.updatedAt}.${body.data == null ? "" : "loaded"}`;
|
const forceUpdateKey = `${flow.updatedAt}.${body.data == null ? "" : "loaded"}`;
|
||||||
|
|
||||||
@@ -163,6 +173,7 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
|
|||||||
bodyLoader={loader}
|
bodyLoader={loader}
|
||||||
extraTabs={extraTabs}
|
extraTabs={extraTabs}
|
||||||
defaultTab={defaultTab}
|
defaultTab={defaultTab}
|
||||||
|
activeTab={activeTab}
|
||||||
onTabChange={onTabChange}
|
onTabChange={onTabChange}
|
||||||
urlLine={<UrlLine flow={flow} />}
|
urlLine={<UrlLine flow={flow} />}
|
||||||
renderBody={() => (
|
renderBody={() => (
|
||||||
@@ -270,7 +281,7 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
|
|||||||
RESPONSE_TAB_INFO,
|
RESPONSE_TAB_INFO,
|
||||||
...(messagesLabel == null ? [] : [RESPONSE_TAB_MESSAGES]),
|
...(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;
|
const saved = flow.savedRequestId != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -285,6 +296,7 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
|
|||||||
tabsStorageKey="proxy_response_tabs"
|
tabsStorageKey="proxy_response_tabs"
|
||||||
activeTabKey={flow.id}
|
activeTabKey={flow.id}
|
||||||
defaultTab={defaultTab}
|
defaultTab={defaultTab}
|
||||||
|
activeTab={activeTab}
|
||||||
onTabChange={onTabChange}
|
onTabChange={onTabChange}
|
||||||
extraTabs={extraTabs}
|
extraTabs={extraTabs}
|
||||||
renderBodyViewer={() => <FlowBodyViewer flow={flow} which="response" />}
|
renderBodyViewer={() => <FlowBodyViewer flow={flow} which="response" />}
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ import type {
|
|||||||
WheelEvent,
|
WheelEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } 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 { shortContentType } from "../lib/body";
|
||||||
import { utcTimestamp } from "../lib/flowViews";
|
import { utcTimestamp } from "../lib/flowViews";
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +53,7 @@ import {
|
|||||||
import { useExportShownHar, useFlowActions } from "../lib/flowActions";
|
import { useExportShownHar, useFlowActions } from "../lib/flowActions";
|
||||||
import { compareFlows } from "../lib/flowWindow";
|
import { compareFlows } from "../lib/flowWindow";
|
||||||
import { countNewFlows, isAtEnd, resumesFollowing } from "../lib/follow";
|
import { countNewFlows, isAtEnd, resumesFollowing } from "../lib/follow";
|
||||||
|
import { applyFilter, goToPlace } from "../lib/navigation";
|
||||||
import { asOneTerm } from "../lib/query";
|
import { asOneTerm } from "../lib/query";
|
||||||
import { folderOf } from "../lib/search";
|
import { folderOf } from "../lib/search";
|
||||||
import {
|
import {
|
||||||
@@ -61,8 +67,6 @@ import {
|
|||||||
oldestFlowAtom,
|
oldestFlowAtom,
|
||||||
proxyStateAtom,
|
proxyStateAtom,
|
||||||
searchLoadingAtom,
|
searchLoadingAtom,
|
||||||
searchTextAtom,
|
|
||||||
setSearchTextAtom,
|
|
||||||
flowsAtom,
|
flowsAtom,
|
||||||
shownFlowsAtom,
|
shownFlowsAtom,
|
||||||
} from "../lib/store";
|
} from "../lib/store";
|
||||||
@@ -108,8 +112,8 @@ export function FlowList({ style, className }: Props) {
|
|||||||
const flowActions = useFlowActions();
|
const flowActions = useFlowActions();
|
||||||
const cellActions = useCellActions();
|
const cellActions = useCellActions();
|
||||||
const exportShown = useExportShownHar();
|
const exportShown = useExportShownHar();
|
||||||
|
const store = useStore();
|
||||||
const selectedId = useAtomValue(selectedFlowIdAtom);
|
const selectedId = useAtomValue(selectedFlowIdAtom);
|
||||||
const setSelectedFlow = useSetAtom(selectedFlowAtom);
|
|
||||||
const loadOlder = useSetAtom(loadOlderFlowsAtom);
|
const loadOlder = useSetAtom(loadOlderFlowsAtom);
|
||||||
const loadMoreSorted = useSetAtom(loadMoreSortedAtom);
|
const loadMoreSorted = useSetAtom(loadMoreSortedAtom);
|
||||||
const oldestFlow = useAtomValue(oldestFlowAtom);
|
const oldestFlow = useAtomValue(oldestFlowAtom);
|
||||||
@@ -322,23 +326,51 @@ export function FlowList({ style, className }: Props) {
|
|||||||
[loadOlderHeld],
|
[loadOlderHeld],
|
||||||
);
|
);
|
||||||
|
|
||||||
const select = useCallback(
|
/** Scrolls a row into view, to its middle when `center` and it is out of view */
|
||||||
(index: number) => {
|
const scrollToRow = useCallback(
|
||||||
const flow = latest.current.flows[index];
|
(index: number, center = false) => {
|
||||||
if (flow == null) return;
|
|
||||||
setSelectedFlow(flow);
|
|
||||||
hold();
|
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el == null) return;
|
if (el == null) return;
|
||||||
const top = index * ROW_HEIGHT;
|
const top = index * ROW_HEIGHT;
|
||||||
if (top < el.scrollTop) setScrollTop(top);
|
const above = top < el.scrollTop;
|
||||||
else if (top + ROW_HEIGHT > el.scrollTop + el.clientHeight) {
|
const below = top + ROW_HEIGHT > el.scrollTop + el.clientHeight;
|
||||||
setScrollTop(top + ROW_HEIGHT - 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(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
// ⌘↓ catches up, as the pill does.
|
// ⌘↓ catches up, as the pill does.
|
||||||
@@ -351,11 +383,11 @@ export function FlowList({ style, className }: Props) {
|
|||||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const index = flows.findIndex((f) => f.id === selectedId);
|
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 (index === 0 && e.key === "ArrowUp") loadOlderHeld();
|
||||||
else {
|
else {
|
||||||
if (sorted && index >= flows.length - 2 && e.key === "ArrowDown") void loadMoreSorted();
|
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],
|
[flows, follow, loadOlderHeld, loadMoreSorted, select, selectedId, sorted],
|
||||||
@@ -366,7 +398,7 @@ export function FlowList({ style, className }: Props) {
|
|||||||
const flow = latest.current.flows[index];
|
const flow = latest.current.flows[index];
|
||||||
if (flow == null) return;
|
if (flow == null) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
select(index);
|
select(index, "add");
|
||||||
// The cell under the pointer, for the menu's items on its value
|
// The cell under the pointer, for the menu's items on its value
|
||||||
const cellEl = (e.target as HTMLElement).closest<HTMLElement>("[data-column]");
|
const cellEl = (e.target as HTMLElement).closest<HTMLElement>("[data-column]");
|
||||||
const column = (cellEl?.dataset.column as ColumnId | undefined) ?? null;
|
const column = (cellEl?.dataset.column as ColumnId | undefined) ?? null;
|
||||||
@@ -483,7 +515,7 @@ export function FlowList({ style, className }: Props) {
|
|||||||
template={template}
|
template={template}
|
||||||
selected={flow.id === selectedId}
|
selected={flow.id === selectedId}
|
||||||
arrivedAt={arrivals.get(flow.id) ?? null}
|
arrivedAt={arrivals.get(flow.id) ?? null}
|
||||||
onSelect={select}
|
onSelect={clickRow}
|
||||||
onContextMenu={openMenu}
|
onContextMenu={openMenu}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1149,18 +1181,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
|
* 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[] {
|
function useCellActions(): (flow: Flow, column: ColumnId) => DropdownItem[] {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const setSearchText = useSetAtom(setSearchTextAtom);
|
|
||||||
const showToast = useShowToast();
|
const showToast = useShowToast();
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(flow: Flow, column: ColumnId) => {
|
(flow: Flow, column: ColumnId) => {
|
||||||
const add = (term: string) => {
|
const add = (term: string) => {
|
||||||
// A search with `OR` at its top is grouped first, so the term narrows all of it.
|
// A search with `OR` at its top is grouped first, so the term narrows all of it.
|
||||||
const text = store.get(searchTextAtom).trim();
|
const text = store.get(appliedFilterAtom).trim();
|
||||||
setSearchText(text === "" ? term : `${asOneTerm(text)} ${term}`);
|
applyFilter(store, text === "" ? term : `${asOneTerm(text)} ${term}`);
|
||||||
};
|
};
|
||||||
const include = cellFilterTerm(flow, column, false);
|
const include = cellFilterTerm(flow, column, false);
|
||||||
const exclude = cellFilterTerm(flow, column, true);
|
const exclude = cellFilterTerm(flow, column, true);
|
||||||
@@ -1188,7 +1219,7 @@ function useCellActions(): (flow: Flow, column: ColumnId) => DropdownItem[] {
|
|||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
[store, setSearchText, showToast],
|
[store, showToast],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import { EmptyStateText, Hotkey, Icon, InlineCode } from "@yaakapp-internal/ui";
|
import { EmptyStateText, Hotkey, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useStore } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { appliedFilterAtom } from "../lib/atoms";
|
||||||
|
import { applyFilter } from "../lib/navigation";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
import { activeQuery, widerQueries } from "../lib/query";
|
import { activeQuery, widerQueries } from "../lib/query";
|
||||||
import {
|
import { hideSystemAtom, pausedAtAtom, proxyStateAtom, searchWindowAtom } from "../lib/store";
|
||||||
hideSystemAtom,
|
|
||||||
pausedAtAtom,
|
|
||||||
proxyStateAtom,
|
|
||||||
searchTermsAtom,
|
|
||||||
searchWindowAtom,
|
|
||||||
setSearchTextAtom,
|
|
||||||
} from "../lib/store";
|
|
||||||
|
|
||||||
/** Nothing to list: no flows yet, recording paused, or a query nothing matches */
|
/** Nothing to list: no flows yet, recording paused, or a query nothing matches */
|
||||||
export function FlowListEmpty() {
|
export function FlowListEmpty() {
|
||||||
const text = useAtomValue(searchTermsAtom);
|
const text = useAtomValue(appliedFilterAtom);
|
||||||
const search = useAtomValue(searchWindowAtom);
|
const search = useAtomValue(searchWindowAtom);
|
||||||
const proxyState = useAtomValue(proxyStateAtom);
|
const proxyState = useAtomValue(proxyStateAtom);
|
||||||
if (search?.loading) return null;
|
if (search?.loading) return null;
|
||||||
@@ -69,7 +64,8 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) {
|
|||||||
*/
|
*/
|
||||||
function NoResults({ text }: { text: string }) {
|
function NoResults({ text }: { text: string }) {
|
||||||
const hideSystem = useAtomValue(hideSystemAtom);
|
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 }[]>([]);
|
const [wider, setWider] = useState<{ text: string; count: number }[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useLocalStorage } from "react-use";
|
|||||||
import { flowLayoutAtom } from "../lib/atoms";
|
import { flowLayoutAtom } from "../lib/atoms";
|
||||||
import { setupIncomplete } from "../lib/firstRun";
|
import { setupIncomplete } from "../lib/firstRun";
|
||||||
import { useMenuExportHar } from "../lib/flowActions";
|
import { useMenuExportHar } from "../lib/flowActions";
|
||||||
|
import { useNavigationControls } from "../lib/navigation";
|
||||||
import { proxyStatusOf, statusTintColor } from "../lib/proxyStatus";
|
import { proxyStatusOf, statusTintColor } from "../lib/proxyStatus";
|
||||||
import { lockedAtom, noFlowsAtom, proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
|
import { lockedAtom, noFlowsAtom, proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
|
||||||
import { getOsType } from "../lib/tauri";
|
import { getOsType } from "../lib/tauri";
|
||||||
@@ -37,6 +38,7 @@ export function ProxyLayout() {
|
|||||||
const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
|
const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
|
||||||
const openSettings = useOpenSettings();
|
const openSettings = useOpenSettings();
|
||||||
useMenuExportHar();
|
useMenuExportHar();
|
||||||
|
useNavigationControls();
|
||||||
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
|
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
|
||||||
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
|
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
|
||||||
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
|
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 type { Extension } from "@codemirror/state";
|
||||||
import { Compartment } from "@codemirror/state";
|
import { Compartment, Prec } from "@codemirror/state";
|
||||||
import { EditorView, tooltips, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
import { EditorView, keymap, tooltips, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||||
import {
|
import {
|
||||||
Dropdown,
|
Dropdown,
|
||||||
|
type DropdownItem,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
@@ -22,36 +29,37 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent";
|
||||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
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 { useExportShownHar } from "../lib/flowActions";
|
||||||
|
import { applyFilter, searchDraftAtom, searchDraftVersionAtom } from "../lib/navigation";
|
||||||
|
import { rpc } from "../lib/rpc";
|
||||||
import { loadSeenValues, savedCompletions, searchProblems } from "../lib/searchBox";
|
import { loadSeenValues, savedCompletions, searchProblems } from "../lib/searchBox";
|
||||||
import {
|
import { allTrafficTotalAtom, lockedAtom, savedQueriesAtom, type Store } from "../lib/store";
|
||||||
allTrafficTotalAtom,
|
import { useShowToast } from "../lib/toast";
|
||||||
lockedAtom,
|
|
||||||
savedQueriesAtom,
|
|
||||||
searchTermsAtom,
|
|
||||||
searchTextAtom,
|
|
||||||
searchTextVersionAtom,
|
|
||||||
setSearchTextAtom,
|
|
||||||
} from "../lib/store";
|
|
||||||
import { savedSearchDialogAtom } from "./SavedSearchDialog";
|
import { savedSearchDialogAtom } from "./SavedSearchDialog";
|
||||||
|
|
||||||
/** How long typing settles before the search runs */
|
/** Recent filters listed above the fields when the empty box is focused */
|
||||||
const SEARCH_DEBOUNCE_MS = 200;
|
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 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
|
* 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
|
* completed as Yaak's sidebar filter is, with the values the proxy has seen. The box composes a
|
||||||
* it and marks what it cannot. Its placeholder counts the flows. ⌘F focuses it and Escape
|
* filter and Enter applies it, as Yaak's URL bar sends; until then a return key at its right
|
||||||
* clears it. Focused, it wraps a long query over as many lines as it needs, as Yaak's URL bar
|
* 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.
|
* does, over what is below; blurred, it is one line again.
|
||||||
*/
|
*/
|
||||||
export const SearchBar = memo(function SearchBar() {
|
export const SearchBar = memo(function SearchBar() {
|
||||||
const [text, setText] = useAtom(searchTextAtom);
|
const [draft, setDraft] = useAtom(searchDraftAtom);
|
||||||
const setTerms = useSetAtom(searchTermsAtom);
|
const version = useAtomValue(searchDraftVersionAtom);
|
||||||
const setSearchText = useSetAtom(setSearchTextAtom);
|
const applied = useAtomValue(appliedFilterAtom);
|
||||||
const version = useAtomValue(searchTextVersionAtom);
|
|
||||||
const total = useAtomValue(allTrafficTotalAtom);
|
const total = useAtomValue(allTrafficTotalAtom);
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
|
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
|
||||||
@@ -59,12 +67,25 @@ export const SearchBar = memo(function SearchBar() {
|
|||||||
// Nothing to search while captured traffic is locked.
|
// Nothing to search while captured traffic is locked.
|
||||||
const locked = useAtomValue(lockedAtom) != null;
|
const locked = useAtomValue(lockedAtom) != null;
|
||||||
const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema;
|
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 handleRef = useRef<InputHandle | null>(null);
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
// Where the box is while focused, so it grows over the list from the header's place for it
|
// 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);
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!focused) {
|
if (!focused) {
|
||||||
setPlace(null);
|
setPlace(null);
|
||||||
@@ -79,11 +100,6 @@ export const SearchBar = memo(function SearchBar() {
|
|||||||
return () => window.removeEventListener("resize", measure);
|
return () => window.removeEventListener("resize", measure);
|
||||||
}, [focused]);
|
}, [focused]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setTerms(text), SEARCH_DEBOUNCE_MS);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [text, setTerms]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "f") {
|
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,
|
loadValues: loadSeenValues,
|
||||||
// Read as completion opens, so saved searches are current without remaking it.
|
// Read as completion opens, so saved searches are current without remaking it.
|
||||||
moreCompletions: (typed) => savedCompletions(store.get(savedQueriesAtom), typed),
|
moreCompletions: (typed) => savedCompletions(store.get(savedQueriesAtom), typed),
|
||||||
|
leadCompletions: () => recentCompletions(latest.current.history),
|
||||||
problems: searchProblems,
|
problems: searchProblems,
|
||||||
}),
|
}),
|
||||||
[schema, store],
|
[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(() => {
|
useEffect(() => {
|
||||||
handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) });
|
handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) });
|
||||||
}, [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 = () => {
|
const clear = () => {
|
||||||
setSearchText("");
|
const handle = handleRef.current;
|
||||||
|
handle?.dispatch({ changes: { from: 0, to: handle.value().length, insert: "" } });
|
||||||
|
applyFilter(store, "");
|
||||||
requestAnimationFrame(() => handleRef.current?.focus());
|
requestAnimationFrame(() => handleRef.current?.focus());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unapplied = isUnapplied({ draft, applied });
|
||||||
|
const hasText = draft !== "" || applied !== "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Holds the header's place for the box, which floats over the list while focused
|
// Holds the header's place for the box, which floats over the list while focused
|
||||||
<div ref={boxRef} className="relative h-sm">
|
<div ref={boxRef} className="relative h-sm">
|
||||||
@@ -148,60 +174,67 @@ export const SearchBar = memo(function SearchBar() {
|
|||||||
onBlur={() => setFocused(false)}
|
onBlur={() => setFocused(false)}
|
||||||
placeholder={locked ? "Traffic is locked" : placeholderFor(total)}
|
placeholder={locked ? "Traffic is locked" : placeholderFor(total)}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
defaultValue={text}
|
defaultValue={draft}
|
||||||
forceUpdateKey={String(version)}
|
forceUpdateKey={String(version)}
|
||||||
onChange={setText}
|
onChange={setDraft}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape" && handleRef.current?.value() !== "") {
|
|
||||||
e.stopPropagation();
|
|
||||||
clear();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
leftSlot={
|
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={
|
rightSlot={
|
||||||
text === "" ? (
|
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor
|
||||||
<kbd className="self-center ml-2 mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
|
<div
|
||||||
⌘F
|
// Pressing these leaves the editor focused, so a wrapped box does not collapse and
|
||||||
</kbd>
|
// move them before the click lands.
|
||||||
) : (
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor
|
className="flex items-center self-center ml-1 mr-0.5"
|
||||||
<div
|
>
|
||||||
// Pressing these leaves the editor focused, so a wrapped box does not collapse and
|
{unapplied ? (
|
||||||
// move them before the click lands.
|
<Key className="mr-1">↵</Key>
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
) : hasText ? null : (
|
||||||
className="flex items-center self-center ml-1 mr-0.5"
|
<Key className="ml-1 mr-1.5">⌘F</Key>
|
||||||
>
|
)}
|
||||||
<Dropdown
|
{hasText && (
|
||||||
items={[
|
<>
|
||||||
{
|
<Dropdown
|
||||||
label: "Save search",
|
items={[
|
||||||
leftSlot: <Icon icon="bookmark" />,
|
{
|
||||||
onSelect: () => setSavedDialog({ mode: "save", query: text.trim() }),
|
label: "Save search",
|
||||||
},
|
leftSlot: <Icon icon="bookmark" />,
|
||||||
exportShown(),
|
onSelect: () =>
|
||||||
]}
|
setSavedDialog({
|
||||||
>
|
mode: "save",
|
||||||
|
query: (draft.trim() === "" ? applied : draft).trim(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
exportShown(),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
icon="ellipsis"
|
||||||
|
title="Search actions"
|
||||||
|
tooltip
|
||||||
|
iconColor="secondary"
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="xs"
|
size="xs"
|
||||||
icon="ellipsis"
|
icon="x"
|
||||||
title="Search actions"
|
title="Clear search"
|
||||||
tooltip
|
tooltip
|
||||||
iconColor="secondary"
|
iconColor="secondary"
|
||||||
|
onClick={clear}
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</>
|
||||||
<IconButton
|
)}
|
||||||
size="xs"
|
</div>
|
||||||
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 */
|
/** The search box's width, which its completions take */
|
||||||
const BOX_WIDTH_VAR = "--proxy-search-width";
|
const BOX_WIDTH_VAR = "--proxy-search-width";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||||
import type {
|
import type { HttpVersion, SystemProxyStatus, TrafficRetention } from "@yaakapp-internal/proxy-lib";
|
||||||
HttpVersion,
|
|
||||||
SystemProxyStatus,
|
|
||||||
TrafficRetention,
|
|
||||||
} from "@yaakapp-internal/proxy-lib";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -22,11 +18,12 @@ import {
|
|||||||
SettingsSection,
|
SettingsSection,
|
||||||
VStack,
|
VStack,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue, useSetAtom, useStore } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||||
import { helperDialogOpenAtom } from "../lib/atoms";
|
import { helperDialogOpenAtom } from "../lib/atoms";
|
||||||
|
import { confirmClearFlowsAtom } from "../lib/clearFlows";
|
||||||
import {
|
import {
|
||||||
defaultShell,
|
defaultShell,
|
||||||
environmentBlock,
|
environmentBlock,
|
||||||
@@ -103,6 +100,7 @@ export function SettingsProxy() {
|
|||||||
<SettingsSection title="Traffic">
|
<SettingsSection title="Traffic">
|
||||||
<TrafficRow />
|
<TrafficRow />
|
||||||
{proxyState?.traffic === "keep" && <KeepTrafficRow />}
|
{proxyState?.traffic === "keep" && <KeepTrafficRow />}
|
||||||
|
<ConfirmClearRow />
|
||||||
<ResetRow />
|
<ResetRow />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
@@ -270,6 +268,14 @@ function KeepTrafficRow() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Asking before Clear, which its dialog's "Don't ask again" turns off */
|
||||||
|
function ConfirmClearRow() {
|
||||||
|
const [confirm, setConfirm] = useAtom(confirmClearFlowsAtom);
|
||||||
|
return (
|
||||||
|
<SettingRowBoolean title="Confirm before clearing" checked={confirm} onChange={setConfirm} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ResetRow() {
|
function ResetRow() {
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } fro
|
|||||||
import { revealLabel, revealPath } from "../lib/opener";
|
import { revealLabel, revealPath } from "../lib/opener";
|
||||||
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
|
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
|
||||||
import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree";
|
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 { getOsType } from "../lib/tauri";
|
||||||
import { useShowToast } from "../lib/toast";
|
import { useShowToast } from "../lib/toast";
|
||||||
import { AppIcon } from "./AppIcon";
|
import { AppIcon } from "./AppIcon";
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom, useStore } from "jotai";
|
||||||
import { useEffect, useLayoutEffect, useState } from "react";
|
import { useEffect, useLayoutEffect, useState } from "react";
|
||||||
import { AddColumnDialog } from "../components/AddColumnDialog";
|
import { AddColumnDialog } from "../components/AddColumnDialog";
|
||||||
import { FlowDetail } from "../components/FlowDetail";
|
import { FlowDetail } from "../components/FlowDetail";
|
||||||
import { FlowList } from "../components/FlowList";
|
import { FlowList } from "../components/FlowList";
|
||||||
import { selectedFlowAtom } from "../lib/atoms";
|
|
||||||
import { browserTabMemory, learnedTabKey, flowShape } from "../lib/defaultTab";
|
import { browserTabMemory, learnedTabKey, flowShape } from "../lib/defaultTab";
|
||||||
|
import { goToPlace } from "../lib/navigation";
|
||||||
import { flowWindowAtom } from "../lib/store";
|
import { flowWindowAtom } from "../lib/store";
|
||||||
import { type FlowFixture, fixtures } from "./fixtures";
|
import { type FlowFixture, fixtures } from "./fixtures";
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export function FlowPanesHarness() {
|
|||||||
|
|
||||||
function FixtureCard({ fixture }: { fixture: FlowFixture }) {
|
function FixtureCard({ fixture }: { fixture: FlowFixture }) {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const setSelected = useSetAtom(selectedFlowAtom);
|
const store = useStore();
|
||||||
const setWindow = useSetAtom(flowWindowAtom);
|
const setWindow = useSetAtom(flowWindowAtom);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -52,7 +52,8 @@ function FixtureCard({ fixture }: { fixture: FlowFixture }) {
|
|||||||
browserTabMemory()?.setItem(learnedTabKey("response", flowShape(fixture.flow)), tab);
|
browserTabMemory()?.setItem(learnedTabKey("response", flowShape(fixture.flow)), tab);
|
||||||
}
|
}
|
||||||
setWindow({ flows, hasOlder: false, total: flows.length });
|
setWindow({ flows, hasOlder: false, total: flows.length });
|
||||||
setSelected(flows.find((f) => f.id === fixture.flow.id) ?? fixture.flow);
|
const shown = flows.find((f) => f.id === fixture.flow.id) ?? fixture.flow;
|
||||||
|
goToPlace(store, "update", { selection: [shown.id], flow: shown });
|
||||||
setReady(true);
|
setReady(true);
|
||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- once per fixture
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- once per fixture
|
||||||
}, [fixture]);
|
}, [fixture]);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { type ReactNode, useEffect, useState } from "react";
|
|||||||
import { ProxyLayout } from "../components/ProxyLayout";
|
import { ProxyLayout } from "../components/ProxyLayout";
|
||||||
import { DragRegionCheck } from "./DragRegionCheck";
|
import { DragRegionCheck } from "./DragRegionCheck";
|
||||||
import { useHotkeyContextValue } from "../lib/hotkeys";
|
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 =
|
const LOCKED =
|
||||||
"Traffic is locked: Yaak Proxy stores traffic encrypted and couldn't read its key from the " +
|
"Traffic is locked: Yaak Proxy stores traffic encrypted and couldn't read its key from the " +
|
||||||
@@ -81,9 +82,11 @@ export function LockedHarness() {
|
|||||||
const [synced, setSynced] = useState(false);
|
const [synced, setSynced] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncProxyState(store);
|
syncProxyState(store);
|
||||||
syncFlows(store);
|
void syncNavigation(store).then(() => {
|
||||||
syncSidebarSelection(store);
|
syncFlows(store);
|
||||||
setSynced(true);
|
syncSidebarSelection(store);
|
||||||
|
setSynced(true);
|
||||||
|
});
|
||||||
}, [store]);
|
}, [store]);
|
||||||
if (!synced) return null;
|
if (!synced) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom, useStore } from "jotai";
|
||||||
import { useLayoutEffect, useMemo, useState } from "react";
|
import { useLayoutEffect, useMemo, useState } from "react";
|
||||||
import { FlowDetail } from "../components/FlowDetail";
|
import { FlowDetail } from "../components/FlowDetail";
|
||||||
import { FlowList } from "../components/FlowList";
|
import { FlowList } from "../components/FlowList";
|
||||||
import { ProxyEditorHost } from "../components/ProxyEditorHost";
|
import { ProxyEditorHost } from "../components/ProxyEditorHost";
|
||||||
import { MessageSourceContext } from "../hooks/useFlowMessages";
|
import { MessageSourceContext } from "../hooks/useFlowMessages";
|
||||||
import { selectedFlowAtom } from "../lib/atoms";
|
import { goToPlace } from "../lib/navigation";
|
||||||
import { flowWindowAtom } from "../lib/store";
|
import { flowWindowAtom } from "../lib/store";
|
||||||
import { fixtureSource, type StreamFixture, streamFixtures } from "./streamFixtures";
|
import { fixtureSource, type StreamFixture, streamFixtures } from "./streamFixtures";
|
||||||
|
|
||||||
@@ -51,15 +51,16 @@ function listedFlows(shown: Flow): Flow[] {
|
|||||||
|
|
||||||
function StreamDetail({ fixture }: { fixture: StreamFixture }) {
|
function StreamDetail({ fixture }: { fixture: StreamFixture }) {
|
||||||
const source = useMemo(() => fixtureSource(fixture), [fixture]);
|
const source = useMemo(() => fixtureSource(fixture), [fixture]);
|
||||||
const setSelected = useSetAtom(selectedFlowAtom);
|
const store = useStore();
|
||||||
const setWindow = useSetAtom(flowWindowAtom);
|
const setWindow = useSetAtom(flowWindowAtom);
|
||||||
const [flow, setFlow] = useState(fixture.flow);
|
const [flow, setFlow] = useState(fixture.flow);
|
||||||
const withList = new URLSearchParams(location.search).has("list");
|
const withList = new URLSearchParams(location.search).has("list");
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const flows = listedFlows(flow);
|
const flows = listedFlows(flow);
|
||||||
setWindow({ flows, hasOlder: false, total: flows.length });
|
setWindow({ flows, hasOlder: false, total: flows.length });
|
||||||
setSelected(flows.find((f) => f.id === flow.id) ?? flow);
|
const shown = flows.find((f) => f.id === flow.id) ?? flow;
|
||||||
}, [flow, setSelected, setWindow]);
|
goToPlace(store, "update", { selection: [shown.id], flow: shown });
|
||||||
|
}, [flow, store, setWindow]);
|
||||||
// The count on the flow follows its messages, as the engine writes it
|
// The count on the flow follows its messages, as the engine writes it
|
||||||
useLayoutEffect(
|
useLayoutEffect(
|
||||||
() => source.subscribe(() => setFlow((f) => ({ ...f, messageCount: f.messageCount + 1 }))),
|
() => source.subscribe(() => setFlow((f) => ({ ...f, messageCount: f.messageCount + 1 }))),
|
||||||
|
|||||||
@@ -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 { sortFlows } from "../lib/columns";
|
||||||
import { appIconPng, fixtures } from "./fixtures";
|
import { appIconPng, fixtures } from "./fixtures";
|
||||||
|
|
||||||
@@ -9,9 +14,25 @@ import { appIconPng, fixtures } from "./fixtures";
|
|||||||
export function installFixtureBackend() {
|
export function installFixtureBackend() {
|
||||||
const internals = (window as unknown as { __TAURI_INTERNALS__: Record<string, unknown> })
|
const internals = (window as unknown as { __TAURI_INTERNALS__: Record<string, unknown> })
|
||||||
.__TAURI_INTERNALS__;
|
.__TAURI_INTERNALS__;
|
||||||
internals.invoke = async (cmd: string, args?: { cmd?: string; payload?: unknown }) => {
|
// For checking the window as traffic arrives: `__harnessEmit("model_write", payload)`
|
||||||
|
(window as unknown as { __harnessEmit: typeof emit }).__harnessEmit = emit;
|
||||||
|
internals.transformCallback = (callback: (event: unknown) => void) => {
|
||||||
|
const id = callbacks.size + 1;
|
||||||
|
callbacks.set(id, callback);
|
||||||
|
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 === "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;
|
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}`);
|
throw new Error(`Not in the harness: ${cmd}`);
|
||||||
};
|
};
|
||||||
(
|
(
|
||||||
@@ -66,9 +87,128 @@ function harnessState(status: string | null): GetProxyStateResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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";
|
||||||
|
const LAST_FILTER_KEY = "harness_last_filter";
|
||||||
|
|
||||||
|
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();
|
||||||
|
// `OR` between groups, as the sidebar writes several rows
|
||||||
|
const groups = query.replace(/[()]/g, " ").split(/\s+OR\s+/);
|
||||||
|
if (groups.length > 1) return groups.some((g) => matches(flow, g));
|
||||||
|
return query
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.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 {
|
function answer(cmd: string, payload: unknown): unknown {
|
||||||
const req = (payload ?? {}) as Record<string, unknown>;
|
const req = (payload ?? {}) as Record<string, unknown>;
|
||||||
switch (cmd) {
|
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;
|
||||||
|
// Held by the engine for as long as the app runs; the tab's session here
|
||||||
|
case "get_window_places":
|
||||||
|
return sessionStorage.getItem("harness_window_places");
|
||||||
|
case "set_window_places":
|
||||||
|
sessionStorage.setItem("harness_window_places", String(req.places));
|
||||||
|
return true;
|
||||||
|
case "get_last_filter":
|
||||||
|
return localStorage.getItem(LAST_FILTER_KEY) ?? "";
|
||||||
|
case "set_last_filter":
|
||||||
|
localStorage.setItem(LAST_FILTER_KEY, String(req.filter));
|
||||||
|
return true;
|
||||||
|
case "list_saved_queries":
|
||||||
|
return stored<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": {
|
case "get_flow_body": {
|
||||||
// A copy from `?many` reads its original's body
|
// A copy from `?many` reads its original's body
|
||||||
const id = String(req.flowId).split("~")[0];
|
const id = String(req.flowId).split("~")[0];
|
||||||
@@ -123,20 +263,24 @@ function answer(cmd: string, payload: unknown): unknown {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
{ scope: "global", action: "flows_clear" },
|
{ scope: "global", action: "flows_clear" },
|
||||||
{ label: "Clear Traffic", defaultHotkey: "Meta+Shift+K" },
|
{ label: "Clear Flows", defaultHotkey: "Meta+Shift+K" },
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
case "list_flows": {
|
case "list_flows": {
|
||||||
const many = Number(new URLSearchParams(location.search).get("many") ?? 0);
|
const all = allFlows();
|
||||||
const all = (many > 0 ? manyFlows(many) : fixtures.map((f) => f.flow)).map((f, i) => ({
|
const query = typeof req.query === "string" ? req.query : "";
|
||||||
...f,
|
const matching = all.filter((f) => matches(f, query));
|
||||||
number: 1401 + i,
|
|
||||||
}));
|
|
||||||
// In a sort's order, as the database lists it, all in one page
|
// In a sort's order, as the database lists it, all in one page
|
||||||
const sort = (req.sort ?? []) as FlowSortKey[];
|
const sort = (req.sort ?? []) as FlowSortKey[];
|
||||||
const flows = sort.length > 0 ? sortFlows(all, sort) : all.reverse();
|
const flows = sort.length > 0 ? sortFlows(matching, sort) : matching.reverse();
|
||||||
return { flows, total: flows.length, matching: null, cursor: null };
|
const page = req.limit === 0 ? [] : flows;
|
||||||
|
return {
|
||||||
|
flows: page,
|
||||||
|
total: all.length,
|
||||||
|
matching: query === "" ? null : matching.length,
|
||||||
|
cursor: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case "list_seen":
|
case "list_seen":
|
||||||
return { hosts: [], apps: [] };
|
return { hosts: [], apps: [] };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Flow } from "@yaakapp-internal/proxy-lib";
|
|||||||
import type { ResponseViewMode, SplitLayoutLayout } from "@yaakapp-internal/ui";
|
import type { ResponseViewMode, SplitLayoutLayout } from "@yaakapp-internal/ui";
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { atomWithStorage } from "jotai/utils";
|
import { atomWithStorage } from "jotai/utils";
|
||||||
|
import { currentPlace, EMPTY_PLACE, type PlaceStack, stackOf, updatePlace } from "./places";
|
||||||
|
|
||||||
/** The sidebar's tree, whose selection narrows the list */
|
/** The sidebar's tree, whose selection narrows the list */
|
||||||
export const SIDEBAR_TREE_ID = "proxy-sidebar";
|
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);
|
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 */
|
/** The list follows new traffic, rather than being held where it was scrolled or selected */
|
||||||
export const followingAtom = atom(true);
|
export const followingAtom = atom(true);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { createStore } from "jotai";
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||||
|
import { clearFlowsAction, confirmClearFlowsAtom } from "./clearFlows";
|
||||||
|
|
||||||
|
describe("clearFlowsAction", () => {
|
||||||
|
test("asks first while the preference is on", () => {
|
||||||
|
expect(clearFlowsAction({ total: 3, locked: false, confirm: true })).toBe("confirm");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears at once once asking is turned off", () => {
|
||||||
|
expect(clearFlowsAction({ total: 3, locked: false, confirm: false })).toBe("clear");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does nothing with none to clear or traffic locked, whatever the preference", () => {
|
||||||
|
for (const confirm of [true, false]) {
|
||||||
|
expect(clearFlowsAction({ total: 0, locked: false, confirm })).toBe("disabled");
|
||||||
|
expect(clearFlowsAction({ total: 3, locked: true, confirm })).toBe("disabled");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The preference as a new window reads it, once something shows it */
|
||||||
|
function mounted() {
|
||||||
|
const store = createStore();
|
||||||
|
const unsub = store.sub(confirmClearFlowsAtom, () => {});
|
||||||
|
const value = store.get(confirmClearFlowsAtom);
|
||||||
|
unsub();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("confirmClearFlowsAtom", () => {
|
||||||
|
let saved: Map<string, string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
saved = new Map();
|
||||||
|
vi.stubGlobal("localStorage", {
|
||||||
|
getItem: (k: string) => saved.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void saved.set(k, v),
|
||||||
|
removeItem: (k: string) => void saved.delete(k),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is on by default", () => {
|
||||||
|
expect(createStore().get(confirmClearFlowsAtom)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps Don't ask again for later launches, until Settings turns it back on", () => {
|
||||||
|
createStore().set(confirmClearFlowsAtom, false);
|
||||||
|
expect(saved.get("confirm_clear_flows")).toBe("false");
|
||||||
|
expect(mounted()).toBe(false);
|
||||||
|
|
||||||
|
createStore().set(confirmClearFlowsAtom, true);
|
||||||
|
expect(saved.get("confirm_clear_flows")).toBe("true");
|
||||||
|
expect(mounted()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clearing traffic asks first, until "Don't ask again" is checked in its dialog. The switch in
|
||||||
|
* Settings turns the asking back on.
|
||||||
|
*/
|
||||||
|
export const confirmClearFlowsAtom = atomWithStorage<boolean>(
|
||||||
|
"confirm_clear_flows",
|
||||||
|
true,
|
||||||
|
// Read through globalThis so tests, which have no localStorage, can import this
|
||||||
|
createJSONStorage<boolean>(() => globalThis.localStorage),
|
||||||
|
{ getOnInit: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** What Clear does now: nothing, ask first, or clear at once */
|
||||||
|
export type ClearFlowsAction = "disabled" | "confirm" | "clear";
|
||||||
|
|
||||||
|
export function clearFlowsAction({
|
||||||
|
total,
|
||||||
|
locked,
|
||||||
|
confirm,
|
||||||
|
}: {
|
||||||
|
/** Flows there are to clear */
|
||||||
|
total: number;
|
||||||
|
/** Captured traffic is locked, so none of it can be read */
|
||||||
|
locked: boolean;
|
||||||
|
/** The saved preference to ask before clearing */
|
||||||
|
confirm: boolean;
|
||||||
|
}): ClearFlowsAction {
|
||||||
|
if (locked || total === 0) return "disabled";
|
||||||
|
return confirm ? "confirm" : "clear";
|
||||||
|
}
|
||||||
@@ -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,294 @@
|
|||||||
|
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[] = [];
|
||||||
|
let lastFilter = "";
|
||||||
|
let windowPlaces: string | null = null;
|
||||||
|
|
||||||
|
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 ?? "");
|
||||||
|
if (cmd === "get_last_filter") return lastFilter;
|
||||||
|
if (cmd === "get_window_places") return windowPlaces;
|
||||||
|
if (cmd === "set_window_places") windowPlaces = (payload as { places: string }).places;
|
||||||
|
if (cmd === "set_last_filter") lastFilter = payload.filter ?? "";
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
listen: vi.fn(() => () => {}),
|
||||||
|
}));
|
||||||
|
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.
|
||||||
|
const sidebarSelection = vi.hoisted(() => ({ atom: null as unknown }));
|
||||||
|
vi.mock("@yaakapp-internal/ui", () => ({ selectedIdsFamily: () => sidebarSelection.atom }));
|
||||||
|
|
||||||
|
const {
|
||||||
|
applyFilter,
|
||||||
|
goToPlace,
|
||||||
|
noteSidebarInput,
|
||||||
|
searchDraftAtom,
|
||||||
|
searchDraftVersionAtom,
|
||||||
|
syncNavigation,
|
||||||
|
syncSidebarSelection,
|
||||||
|
} = await import("./navigation");
|
||||||
|
const { sidebarFlowsAtom, sidebarTreeAtom } = await import("./store");
|
||||||
|
const { itemsById } = await import("./sidebarTree");
|
||||||
|
|
||||||
|
function flow(id: string): Flow {
|
||||||
|
return { id } as Flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newStore() {
|
||||||
|
const store = createStore();
|
||||||
|
store.set(placeStackAtom, stackOf(EMPTY_PLACE));
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sidebarSelection.atom = atom<string[]>([]);
|
||||||
|
existing.clear();
|
||||||
|
recorded.length = 0;
|
||||||
|
lastFilter = "";
|
||||||
|
windowPlaces = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncNavigation", () => {
|
||||||
|
test("a launch restores the last filter applied, and keeps each one applied after", async () => {
|
||||||
|
lastFilter = "@host:*.github.com";
|
||||||
|
const store = createStore();
|
||||||
|
await syncNavigation(store);
|
||||||
|
await settle();
|
||||||
|
expect(store.get(appliedFilterAtom)).toBe("@host:*.github.com");
|
||||||
|
expect(store.get(searchDraftAtom)).toBe("@host:*.github.com");
|
||||||
|
expect(store.get(canGoBackAtom)).toBe(false);
|
||||||
|
|
||||||
|
applyFilter(store, "stripe");
|
||||||
|
await settle();
|
||||||
|
expect(lastFilter).toBe("stripe");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a launch the person has already moved on from keeps where they are", async () => {
|
||||||
|
lastFilter = "old";
|
||||||
|
const store = createStore();
|
||||||
|
await syncNavigation(store);
|
||||||
|
applyFilter(store, "new");
|
||||||
|
await settle();
|
||||||
|
expect(store.get(appliedFilterAtom)).toBe("new");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reload takes back the places, the tabs and the selection, not the last filter", async () => {
|
||||||
|
existing.add("B");
|
||||||
|
lastFilter = "elsewhere";
|
||||||
|
const before = createStore();
|
||||||
|
await syncNavigation(before);
|
||||||
|
applyFilter(before, "x");
|
||||||
|
goToPlace(before, "add", { selection: ["B"], flow: flow("B") });
|
||||||
|
goToPlace(before, "update", { tabs: { response: "headers" } });
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const after = createStore();
|
||||||
|
await syncNavigation(after);
|
||||||
|
await settle();
|
||||||
|
expect(after.get(appliedFilterAtom)).toBe("x");
|
||||||
|
expect(after.get(placeAtom).tabs).toEqual({ response: "headers" });
|
||||||
|
expect(after.get(selectedFlowAtom)?.id).toBe("B");
|
||||||
|
expect(goToPlace(after, "back")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a late read of the selected flow leaves a newer one alone", async () => {
|
||||||
|
existing.add("A");
|
||||||
|
const store = newStore();
|
||||||
|
goToPlace(store, "add", { selection: ["A"], flow: flow("A") });
|
||||||
|
goToPlace(store, "add", { selection: ["B"], flow: flow("B") });
|
||||||
|
store.set(selectedFlowAtom, null);
|
||||||
|
goToPlace(store, "back");
|
||||||
|
const newer = { ...flow("A"), status: 200 } as Flow;
|
||||||
|
goToPlace(store, "update", { selection: ["A"], flow: newer });
|
||||||
|
await settle();
|
||||||
|
expect(store.get(selectedFlowAtom)).toBe(newer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("goToPlace", () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncSidebarSelection", () => {
|
||||||
|
function sidebar() {
|
||||||
|
const store = newStore();
|
||||||
|
const flows = ["https://a.example.com/x", "https://api.github.com/y", "https://api.stripe.com"];
|
||||||
|
store.set(
|
||||||
|
sidebarFlowsAtom,
|
||||||
|
flows.map((url, i) => ({ id: `f${i}`, url, method: "GET" }) as Flow),
|
||||||
|
);
|
||||||
|
syncSidebarSelection(store);
|
||||||
|
const selected = sidebarSelection.atom as ReturnType<typeof atom<string[]>>;
|
||||||
|
const idOf = (label: string) =>
|
||||||
|
[...itemsById(store.get(sidebarTreeAtom)).values()].find((i) => i.label === label)?.id ?? "";
|
||||||
|
// As a click in the sidebar does
|
||||||
|
const choose = (ids: string[]) => {
|
||||||
|
noteSidebarInput();
|
||||||
|
store.set(selected, ids);
|
||||||
|
};
|
||||||
|
return { store, selected, idOf, choose };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("rows chosen together apply as one filter and stay selected", () => {
|
||||||
|
const { store, selected, idOf, choose } = sidebar();
|
||||||
|
const [example, github] = [idOf("example.com"), idOf("github.com")];
|
||||||
|
choose([example]);
|
||||||
|
choose([example, github]);
|
||||||
|
expect(store.get(appliedFilterAtom)).toBe("@host:*.example.com OR @host:*.github.com");
|
||||||
|
expect(store.get(selected)).toEqual([example, github]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("System's heading applies its query; the other headings apply nothing", () => {
|
||||||
|
const { store, idOf, choose } = sidebar();
|
||||||
|
store.set(sidebarFlowsAtom, [
|
||||||
|
{
|
||||||
|
id: "s",
|
||||||
|
url: "https://ocsp.apple.com/x",
|
||||||
|
method: "GET",
|
||||||
|
clientName: "trustd",
|
||||||
|
clientPath: "/usr/libexec/trustd",
|
||||||
|
} as Flow,
|
||||||
|
]);
|
||||||
|
choose([idOf("System")]);
|
||||||
|
expect(store.get(appliedFilterAtom)).toBe("@is:system");
|
||||||
|
choose([idOf("Domains")]);
|
||||||
|
expect(store.get(appliedFilterAtom)).toBe("@is:system");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("All traffic is exclusive either way round", () => {
|
||||||
|
const { store, selected, idOf, choose } = sidebar();
|
||||||
|
const [all, github] = [idOf("All traffic"), idOf("github.com")];
|
||||||
|
choose([github]);
|
||||||
|
choose([github, all]);
|
||||||
|
expect(store.get(selected)).toEqual([all]);
|
||||||
|
expect(store.get(appliedFilterAtom)).toBe("");
|
||||||
|
choose([all, github]);
|
||||||
|
expect(store.get(selected)).toEqual([github]);
|
||||||
|
expect(store.get(appliedFilterAtom)).toBe("@host:*.github.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a box filter keeps the rows in it, and Back finds the rows again", () => {
|
||||||
|
const { store, selected, idOf, choose } = sidebar();
|
||||||
|
const [example, github] = [idOf("example.com"), idOf("github.com")];
|
||||||
|
choose([example]);
|
||||||
|
choose([example, github]);
|
||||||
|
applyFilter(store, "@host:*.example.com @method:get", { record: true });
|
||||||
|
expect(store.get(selected)).toEqual([example]);
|
||||||
|
goToPlace(store, "back");
|
||||||
|
expect(store.get(selected).sort()).toEqual([example, github].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
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,
|
||||||
|
exclusiveAll,
|
||||||
|
itemsById,
|
||||||
|
rowsForFilter,
|
||||||
|
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, or shows the flow already, as it was since.
|
||||||
|
const current = currentPlace(store.get(placeStackAtom)).selection.has(id);
|
||||||
|
if (current && store.get(selectedFlowAtom) == null) store.set(selectedFlowAtom, found);
|
||||||
|
})
|
||||||
|
.catch((err) => console.warn("Failed to read the selected flow", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores where the window was, before the flows load for its filter: its places after a
|
||||||
|
* reload, which the engine holds in memory while the app runs, or at launch the last filter
|
||||||
|
* applied, which the encrypted database keeps. Filters can hold captured values, so neither is
|
||||||
|
* kept in the web view's storage. Keeps them as they change.
|
||||||
|
*/
|
||||||
|
export async function syncNavigation(store: Store) {
|
||||||
|
const kept = stackFromJson(await rpc("get_window_places", {}).catch(() => null));
|
||||||
|
const stack = kept ?? stackOf(EMPTY_PLACE);
|
||||||
|
store.set(placeStackAtom, stack);
|
||||||
|
showPlace(store, currentPlace(stack), { filter: true, reveal: true });
|
||||||
|
if (kept == null) {
|
||||||
|
// Unread while traffic is locked, as the database is.
|
||||||
|
rpc("get_last_filter", {})
|
||||||
|
.then((filter) => {
|
||||||
|
// Only over the window as it opened, not where it has been taken since.
|
||||||
|
const now = store.get(placeStackAtom);
|
||||||
|
if (filter === "" || now.places.length > 1 || currentPlace(now).filter !== "") return;
|
||||||
|
goToPlace(store, "update", { filter });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One write at a time, the latest places after it, so they arrive in order.
|
||||||
|
let pending: string | null = null;
|
||||||
|
let writing = false;
|
||||||
|
const write = () => {
|
||||||
|
if (writing || pending == null) return;
|
||||||
|
const places = pending;
|
||||||
|
pending = null;
|
||||||
|
writing = true;
|
||||||
|
rpc("set_window_places", { places })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
writing = false;
|
||||||
|
write();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
store.sub(placeStackAtom, () => {
|
||||||
|
pending = stackToJson(store.get(placeStackAtom));
|
||||||
|
write();
|
||||||
|
});
|
||||||
|
let lastFilter = store.get(appliedFilterAtom);
|
||||||
|
store.sub(appliedFilterAtom, () => {
|
||||||
|
const filter = store.get(appliedFilterAtom);
|
||||||
|
if (filter === lastFilter) return;
|
||||||
|
lastFilter = filter;
|
||||||
|
rpc("set_last_filter", { filter }).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `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 select(next: string[]) {
|
||||||
|
const ids = store.get(selectedAtom);
|
||||||
|
if (next.length === ids.length && next.every((id, i) => id === ids[i])) return;
|
||||||
|
syncing = true;
|
||||||
|
store.set(selectedAtom, next);
|
||||||
|
syncing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `add`: rows whose query is a whole group of the filter join, as when Back returns to a
|
||||||
|
// filter the rows wrote; otherwise only those selected are kept or dropped.
|
||||||
|
function keepRows(add: boolean) {
|
||||||
|
if (syncing) return;
|
||||||
|
const items = itemsById(store.get(sidebarTreeAtom));
|
||||||
|
const next = rowsForFilter(store.get(appliedFilterAtom), store.get(selectedAtom), items, add);
|
||||||
|
select(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tree writes its selection again as it renders, the same rows in a new array, or its last
|
||||||
|
// row when the selection was emptied; only a change of rows right after a click or a key in
|
||||||
|
// the sidebar is the person selecting.
|
||||||
|
let lastRows = store.get(selectedAtom);
|
||||||
|
store.sub(selectedAtom, () => {
|
||||||
|
const before = lastRows;
|
||||||
|
const ids = store.get(selectedAtom);
|
||||||
|
if (ids.join("\n") === before.join("\n")) return;
|
||||||
|
lastRows = ids;
|
||||||
|
if (syncing) return;
|
||||||
|
// Not the person's, such as the tree selecting its first row as it mounts over a filter
|
||||||
|
// restored at launch: the rows go back to what the filter says.
|
||||||
|
if (Date.now() - lastSidebarInput > SIDEBAR_INPUT_MS) {
|
||||||
|
keepRows(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exclusive = exclusiveAll(ids, before);
|
||||||
|
if (exclusive !== ids) select(exclusive);
|
||||||
|
// Group headings are not filters.
|
||||||
|
const rows = exclusive.filter((id) => id !== APPS_ID && id !== DOMAINS_ID && id !== SAVED_ID);
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
const items = itemsById(store.get(sidebarTreeAtom));
|
||||||
|
const selected = rows.flatMap((id) => {
|
||||||
|
const item = items.get(id);
|
||||||
|
// A heading has no query to apply; System's has its own.
|
||||||
|
return item == null || (item.query === "" && item.kind !== "all") ? [] : [item];
|
||||||
|
});
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
const text = selectionText(selected);
|
||||||
|
if (text === store.get(appliedFilterAtom)) return;
|
||||||
|
syncing = true;
|
||||||
|
applyFilter(store, text);
|
||||||
|
syncing = false;
|
||||||
|
});
|
||||||
|
store.sub(appliedFilterAtom, () => keepRows(true));
|
||||||
|
|
||||||
|
// A filter restored at launch selects no row; none at all is All traffic.
|
||||||
|
if (store.get(selectedAtom).length === 0 && store.get(appliedFilterAtom).trim() === "") {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
asOneTerm,
|
asOneTerm,
|
||||||
countsBackFromNow,
|
countsBackFromNow,
|
||||||
fieldTerm,
|
fieldTerm,
|
||||||
|
orGroupShapes,
|
||||||
pathTerm,
|
pathTerm,
|
||||||
|
queryShape,
|
||||||
termKeys,
|
termKeys,
|
||||||
widerQueries,
|
widerQueries,
|
||||||
} from "./query";
|
} from "./query";
|
||||||
@@ -32,6 +34,22 @@ describe("terms", () => {
|
|||||||
]);
|
]);
|
||||||
expect(termKeys("")).toEqual([]);
|
expect(termKeys("")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("come in the groups `OR` separates at the top, a parenthesised one whole", () => {
|
||||||
|
expect(orGroupShapes("@host:x.com @app:Slack OR (a OR b) OR -@is:system")).toEqual([
|
||||||
|
"And(@app:slack,@host:x.com)",
|
||||||
|
"Or(a,b)",
|
||||||
|
"-@is:system",
|
||||||
|
]);
|
||||||
|
expect(orGroupShapes("@app:Slack")).toEqual(["@app:slack"]);
|
||||||
|
expect(orGroupShapes(" ")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("have a shape that keeps AND and OR apart, whatever the order or case", () => {
|
||||||
|
expect(queryShape("@app:Slack @host:x.com")).toBe(queryShape("@HOST:x.com @app:slack"));
|
||||||
|
expect(queryShape("a OR b")).not.toBe(queryShape("a b"));
|
||||||
|
expect(queryShape("")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("activeQuery", () => {
|
describe("activeQuery", () => {
|
||||||
|
|||||||
@@ -57,6 +57,45 @@ export function nodeText(node: Node, source: string, nested = false): string {
|
|||||||
|
|
||||||
/** The terms of a query, each as written and with a `-` when it is excluded, lowercased */
|
/** The terms of a query, each as written and with a `-` when it is excluded, lowercased */
|
||||||
export function termKeys(text: string): string[] {
|
export function termKeys(text: string): string[] {
|
||||||
|
const root = parseQuery(text);
|
||||||
|
return root == null ? [] : nodeKeys(root, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A query's shape, for telling whether two say the same: its terms lowercased, with its `AND`
|
||||||
|
* and `OR` groups kept apart and their members in a set's order. Null for an empty query.
|
||||||
|
*/
|
||||||
|
export function queryShape(text: string): string | null {
|
||||||
|
const root = parseQuery(text);
|
||||||
|
return root == null ? null : shapeOf(root, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape of each group `OR` separates at the top of a query: one for a query with no `OR`
|
||||||
|
* there, none for an empty one
|
||||||
|
*/
|
||||||
|
export function orGroupShapes(text: string): string[] {
|
||||||
|
const root = parseQuery(text);
|
||||||
|
if (root == null) return [];
|
||||||
|
const groups = root.type === "Or" ? root.children : [root];
|
||||||
|
return groups.map((g) => shapeOf(g, text));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeOf(node: Node, text: string): string {
|
||||||
|
switch (node.type) {
|
||||||
|
case "And":
|
||||||
|
case "Or": {
|
||||||
|
const members = [...new Set(node.children.map((c) => shapeOf(c, text)))].sort();
|
||||||
|
return `${node.type}(${members.join(",")})`;
|
||||||
|
}
|
||||||
|
case "Not":
|
||||||
|
return `-${shapeOf(node.node, text)}`;
|
||||||
|
default:
|
||||||
|
return text.slice(node.span.start, node.span.end).toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeKeys(node: Node, text: string): string[] {
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
const walk = (node: Node, negated: boolean) => {
|
const walk = (node: Node, negated: boolean) => {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
@@ -73,8 +112,7 @@ export function termKeys(text: string): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const root = parseQuery(text);
|
walk(node, false);
|
||||||
if (root != null) walk(root, false);
|
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import type { TreeNode } from "@yaakapp-internal/ui";
|
|||||||
import { describe, expect, test } from "vite-plus/test";
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
import { parseQuery } from "@yaakapp-internal/query";
|
import { parseQuery } from "@yaakapp-internal/query";
|
||||||
import {
|
import {
|
||||||
|
ALL_ID,
|
||||||
buildSidebarTree,
|
buildSidebarTree,
|
||||||
|
exclusiveAll,
|
||||||
itemsById,
|
itemsById,
|
||||||
queryInText,
|
queryInText,
|
||||||
|
rowsForFilter,
|
||||||
type SidebarItem,
|
type SidebarItem,
|
||||||
selectionText,
|
selectionText,
|
||||||
} from "./sidebarTree";
|
} from "./sidebarTree";
|
||||||
@@ -262,6 +265,52 @@ describe("selection and the search text", () => {
|
|||||||
expect(selectionText([a, all])).toBe("");
|
expect(selectionText([a, all])).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("puts a saved search with `OR` of its own in parentheses beside other rows", () => {
|
||||||
|
const saved = { query: "@status:5xx OR @status:4xx" } as SidebarItem;
|
||||||
|
const host = { query: "@host:*.github.com" } as SidebarItem;
|
||||||
|
expect(selectionText([saved, host])).toBe("(@status:5xx OR @status:4xx) OR @host:*.github.com");
|
||||||
|
expect(selectionText([saved])).toBe("@status:5xx OR @status:4xx");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("All traffic is selected alone, whichever was chosen last", () => {
|
||||||
|
expect(exclusiveAll(["a", ALL_ID], ["a"])).toEqual([ALL_ID]);
|
||||||
|
expect(exclusiveAll([ALL_ID, "a"], [ALL_ID])).toEqual(["a"]);
|
||||||
|
const rows = ["a", "b"];
|
||||||
|
expect(exclusiveAll(rows, ["a"])).toBe(rows);
|
||||||
|
expect(exclusiveAll([ALL_ID], ["a"])).toEqual([ALL_ID]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a filter keeps the rows still in it, and finds those whose query is a group of it", () => {
|
||||||
|
const items = itemsById(buildSidebarTree(flows, 4));
|
||||||
|
const github = "app:fieldwork|host:api.github.com";
|
||||||
|
const slack = "app:Slack|site:slack.com";
|
||||||
|
const both = selectionText([items.get(github), items.get(slack)] as SidebarItem[]);
|
||||||
|
|
||||||
|
// Applied from the box: kept while in it, dropped once not, none added.
|
||||||
|
expect(rowsForFilter(`${both} card`, [github, slack], items, false)).toEqual([github, slack]);
|
||||||
|
expect(rowsForFilter("@app:Slack @host:*.slack.com", [github, slack], items, false)).toEqual([
|
||||||
|
slack,
|
||||||
|
]);
|
||||||
|
// Gone back to, the rows that wrote it are found again, from none selected.
|
||||||
|
expect(rowsForFilter(both, [], items, true).sort()).toEqual([github, slack].sort());
|
||||||
|
// A group that is only part of a row's query finds nothing.
|
||||||
|
expect(rowsForFilter("@app:Slack", [], items, true)).not.toContain(slack);
|
||||||
|
expect(rowsForFilter(" ", [github], items, true)).toEqual([ALL_ID]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a row is found only by a group that says what its query says", () => {
|
||||||
|
const either = { id: "saved:either", kind: "saved", query: "@method:GET OR @host:x.com" };
|
||||||
|
const system = { id: "system", kind: "group", query: "@is:system" };
|
||||||
|
const items = new Map([either, system].map((i) => [i.id, i as SidebarItem]));
|
||||||
|
// Both terms, but together rather than either
|
||||||
|
expect(rowsForFilter("@method:GET @host:x.com", [], items, true)).toEqual([]);
|
||||||
|
expect(rowsForFilter("(@method:GET OR @host:x.com) OR @app:a", [], items, true)).toEqual([
|
||||||
|
"saved:either",
|
||||||
|
]);
|
||||||
|
// A heading with a query of its own, as System's, is found as any row is.
|
||||||
|
expect(rowsForFilter("@is:system", [], items, true)).toEqual(["system"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps a row selected while every term of its query is in the text", () => {
|
test("keeps a row selected while every term of its query is in the text", () => {
|
||||||
expect(queryInText("@app:Slack", "@app:Slack card")).toBe(true);
|
expect(queryInText("@app:Slack", "@app:Slack card")).toBe(true);
|
||||||
expect(queryInText("@app:slack", "@app:Slack")).toBe(true);
|
expect(queryInText("@app:slack", "@app:Slack")).toBe(true);
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||||
import type { TreeNode } from "@yaakapp-internal/ui";
|
import type { TreeNode } from "@yaakapp-internal/ui";
|
||||||
import type { DecryptSubject } from "./decrypt";
|
import type { DecryptSubject } from "./decrypt";
|
||||||
import { fieldTerm, pathTerm, siteTerm, termKeys } from "./query";
|
import {
|
||||||
|
asOneTerm,
|
||||||
|
fieldTerm,
|
||||||
|
orGroupShapes,
|
||||||
|
pathTerm,
|
||||||
|
queryShape,
|
||||||
|
siteTerm,
|
||||||
|
termKeys,
|
||||||
|
} from "./query";
|
||||||
import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf } from "./search";
|
import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf } from "./search";
|
||||||
|
|
||||||
export type SidebarKind =
|
export type SidebarKind =
|
||||||
@@ -365,11 +373,49 @@ export function itemsById(root: TreeNode<SidebarItem>): Map<string, SidebarItem>
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The search text for the selected rows: their queries, OR'd; empty for All traffic */
|
/**
|
||||||
|
* The search text for the selected rows: their queries, OR'd, one with `OR` of its own in
|
||||||
|
* parentheses so it stays one group; empty for All traffic
|
||||||
|
*/
|
||||||
export function selectionText(items: SidebarItem[]): string {
|
export function selectionText(items: SidebarItem[]): string {
|
||||||
const queries = [...new Set(items.map((i) => i.query))];
|
const queries = [...new Set(items.map((i) => i.query))];
|
||||||
if (queries.includes("")) return "";
|
if (queries.includes("")) return "";
|
||||||
return queries.join(" OR ");
|
return queries.length === 1 ? (queries[0] ?? "") : queries.map(asOneTerm).join(" OR ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All traffic is selected alone: chosen beside other rows it takes their place, and another row
|
||||||
|
* chosen beside it takes its. `before` is the selection as it was.
|
||||||
|
*/
|
||||||
|
export function exclusiveAll(ids: string[], before: string[]): string[] {
|
||||||
|
if (!ids.includes(ALL_ID) || ids.length === 1) return ids;
|
||||||
|
return before.includes(ALL_ID) ? ids.filter((id) => id !== ALL_ID) : [ALL_ID];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows a filter applied leaves selected: those selected whose query is still in it, and,
|
||||||
|
* with `add`, every row whose query is one of its `OR` groups whole, as the filter the rows
|
||||||
|
* wrote is when Back returns to it. All traffic alone for no filter.
|
||||||
|
*/
|
||||||
|
export function rowsForFilter(
|
||||||
|
text: string,
|
||||||
|
selected: string[],
|
||||||
|
items: Map<string, SidebarItem>,
|
||||||
|
add: boolean,
|
||||||
|
): string[] {
|
||||||
|
if (text.trim() === "") return [ALL_ID];
|
||||||
|
const rows = selected.filter((id) => {
|
||||||
|
const item = items.get(id);
|
||||||
|
return item != null && item.query !== "" && queryInText(item.query, text);
|
||||||
|
});
|
||||||
|
if (!add) return rows;
|
||||||
|
const groups = new Set(orGroupShapes(text));
|
||||||
|
for (const [id, item] of items) {
|
||||||
|
if (item.query === "" || rows.includes(id)) continue;
|
||||||
|
const shape = queryShape(item.query);
|
||||||
|
if (shape != null && groups.has(shape)) rows.push(id);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+10
-103
@@ -6,10 +6,9 @@ import type {
|
|||||||
SavedQuery,
|
SavedQuery,
|
||||||
SortValue,
|
SortValue,
|
||||||
} from "@yaakapp-internal/proxy-lib";
|
} from "@yaakapp-internal/proxy-lib";
|
||||||
import { selectedIdsFamily } from "@yaakapp-internal/ui";
|
|
||||||
import { atom, type createStore } from "jotai";
|
import { atom, type createStore } from "jotai";
|
||||||
import { atomWithStorage } from "jotai/utils";
|
import { atomWithStorage } from "jotai/utils";
|
||||||
import { followingAtom, SIDEBAR_TREE_ID, selectedFlowAtom } from "./atoms";
|
import { appliedFilterAtom, dropSelectedAtom, followingAtom, selectedFlowAtom } from "./atoms";
|
||||||
import {
|
import {
|
||||||
type ColumnPrefs,
|
type ColumnPrefs,
|
||||||
defaultColumnPrefs,
|
defaultColumnPrefs,
|
||||||
@@ -31,19 +30,9 @@ import {
|
|||||||
import { activeQuery, anyCountsBackFromNow, countsBackFromNow, type FlowQuery } from "./query";
|
import { activeQuery, anyCountsBackFromNow, countsBackFromNow, type FlowQuery } from "./query";
|
||||||
import { listen, rpc } from "./rpc";
|
import { listen, rpc } from "./rpc";
|
||||||
import { changesFor, FlowEvents } from "./searchChanges";
|
import { changesFor, FlowEvents } from "./searchChanges";
|
||||||
import {
|
import { buildSidebarTree, type SavedRow } from "./sidebarTree";
|
||||||
ALL_ID,
|
|
||||||
APPS_ID,
|
|
||||||
buildSidebarTree,
|
|
||||||
DOMAINS_ID,
|
|
||||||
itemsById,
|
|
||||||
queryInText,
|
|
||||||
SAVED_ID,
|
|
||||||
type SavedRow,
|
|
||||||
selectionText,
|
|
||||||
} 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 */
|
/** How long new flows gather before they reach the list, so a burst renders once */
|
||||||
const FLUSH_MS = 100;
|
const FLUSH_MS = 100;
|
||||||
@@ -76,7 +65,7 @@ export const flowsLoadedAtom = atom(false);
|
|||||||
* does not re-render what reads it.
|
* does not re-render what reads it.
|
||||||
*/
|
*/
|
||||||
export const noFlowsAtom = atom(
|
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 */
|
/** 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 */
|
/** 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));
|
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 */
|
/** 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, {
|
export const hideSystemAtom = atomWithStorage("sidebar_hide_system", false, undefined, {
|
||||||
getOnInit: true,
|
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) =>
|
export const activeQueryAtom = atom<FlowQuery | null>((get) =>
|
||||||
activeQuery(get(searchTermsAtom), get(hideSystemAtom)),
|
activeQuery(get(appliedFilterAtom), get(hideSystemAtom)),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** The saved searches, in their order */
|
/** 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 */
|
/** Flows All traffic covers in the database: every one, or all but System's while hidden */
|
||||||
export const allTrafficTotalAtom = atom((get) =>
|
export const allTrafficTotalAtom = atom((get) =>
|
||||||
get(hideSystemAtom) ? get(nonSystemTotalAtom) : get(flowTotalAtom),
|
get(hideSystemAtom) ? get(nonSystemTotalAtom) : get(flowTotalAtom),
|
||||||
@@ -454,9 +427,8 @@ export function syncFlows(store: Store) {
|
|||||||
applySortedChangesTo(store, changes);
|
applySortedChangesTo(store, changes);
|
||||||
const selected = store.get(selectedFlowAtom);
|
const selected = store.get(selectedFlowAtom);
|
||||||
const change = changes.findLast((c) => c.model.id === selected?.id);
|
const change = changes.findLast((c) => c.model.id === selected?.id);
|
||||||
if (change != null) {
|
if (change?.change.type === "delete") store.set(dropSelectedAtom, new Set([change.model.id]));
|
||||||
store.set(selectedFlowAtom, change.change.type === "delete" ? null : change.model);
|
else if (change != null) store.set(selectedFlowAtom, change.model);
|
||||||
}
|
|
||||||
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS);
|
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +545,7 @@ export function syncFlows(store: Store) {
|
|||||||
: s.flows;
|
: s.flows;
|
||||||
return { ...s, flows, buffered };
|
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);
|
if (recountTimer == null) recountTimer = setTimeout(recount, RECOUNT_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -805,7 +777,7 @@ const emptyFlowsAtom = atom(null, (_get, set) => {
|
|||||||
set(sortedWindowAtom, (s) =>
|
set(sortedWindowAtom, (s) =>
|
||||||
s == null ? s : { ...s, flows: [], boundary: null, cursor: null, buffered: [] },
|
s == null ? s : { ...s, flows: [], boundary: null, cursor: null, buffered: [] },
|
||||||
);
|
);
|
||||||
set(selectedFlowAtom, null);
|
set(dropSelectedAtom);
|
||||||
set(followingAtom, true);
|
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
|
* 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.
|
* 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 { createRoot } from "react-dom/client";
|
||||||
import { ProxyLayout } from "./components/ProxyLayout";
|
import { ProxyLayout } from "./components/ProxyLayout";
|
||||||
import { useHotkeyContextValue } from "./lib/hotkeys";
|
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";
|
import "./main.css";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const jotaiStore = createStore();
|
const jotaiStore = createStore();
|
||||||
|
|
||||||
syncProxyState(jotaiStore);
|
syncProxyState(jotaiStore);
|
||||||
|
// Before the flows load, so they load for the filter it restores
|
||||||
|
await syncNavigation(jotaiStore);
|
||||||
syncFlows(jotaiStore);
|
syncFlows(jotaiStore);
|
||||||
syncSidebarSelection(jotaiStore);
|
syncSidebarSelection(jotaiStore);
|
||||||
|
|
||||||
|
|||||||
+10
@@ -1,6 +1,16 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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";
|
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
|
* 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`).
|
* stream's are its messages, and read back from them (`bodies::from_messages`).
|
||||||
|
|||||||
+35
-3
@@ -1,5 +1,5 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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, };
|
export type ActionInvocation = { "scope": "global", action: GlobalAction, };
|
||||||
|
|
||||||
@@ -35,6 +35,8 @@ export type CaStatusRequest = Record<string, never>;
|
|||||||
|
|
||||||
export type CaTrustRequest = Record<string, never>;
|
export type CaTrustRequest = Record<string, never>;
|
||||||
|
|
||||||
|
export type ClearFilterHistoryRequest = Record<string, never>;
|
||||||
|
|
||||||
export type ClearFlowsRequest = Record<string, never>;
|
export type ClearFlowsRequest = Record<string, never>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,6 +117,8 @@ entry: string,
|
|||||||
*/
|
*/
|
||||||
builtIn: boolean, };
|
builtIn: boolean, };
|
||||||
|
|
||||||
|
export type DeleteFilterHistoryRequest = { filter: string, };
|
||||||
|
|
||||||
export type DeleteSavedQueryRequest = { id: string, };
|
export type DeleteSavedQueryRequest = { id: string, };
|
||||||
|
|
||||||
export type DropReason = "hop_by_hop" | "set_by_sending" | "body_decoded";
|
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 FilterBodyResponse = { content: string, error: string | null, };
|
||||||
|
|
||||||
|
export type FilterHistoryChangedPayload = Record<string, never>;
|
||||||
|
|
||||||
export type FilterLanguage = "jsonpath" | "xpath";
|
export type FilterLanguage = "jsonpath" | "xpath";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,6 +245,13 @@ decodeError?: string,
|
|||||||
*/
|
*/
|
||||||
note?: 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<string>, };
|
||||||
|
|
||||||
|
export type GetLastFilterRequest = Record<string, never>;
|
||||||
|
|
||||||
export type GetMessagePayloadRequest = { flowId: string, seq: number, };
|
export type GetMessagePayloadRequest = { flowId: string, seq: number, };
|
||||||
|
|
||||||
export type GetMessagePayloadResponse = {
|
export type GetMessagePayloadResponse = {
|
||||||
@@ -315,6 +328,8 @@ export type GetStorageSizeResponse = {
|
|||||||
*/
|
*/
|
||||||
bytes: number, };
|
bytes: number, };
|
||||||
|
|
||||||
|
export type GetWindowPlacesRequest = Record<string, never>;
|
||||||
|
|
||||||
export type GlobalAction = "recording_toggle" | "flows_clear";
|
export type GlobalAction = "recording_toggle" | "flows_clear";
|
||||||
|
|
||||||
export type HelperRegisterRequest = Record<string, never>;
|
export type HelperRegisterRequest = Record<string, never>;
|
||||||
@@ -333,6 +348,8 @@ export type ListActionsRequest = Record<string, never>;
|
|||||||
|
|
||||||
export type ListActionsResponse = { actions: Array<[ActionInvocation, ActionMetadata]>, };
|
export type ListActionsResponse = { actions: Array<[ActionInvocation, ActionMetadata]>, };
|
||||||
|
|
||||||
|
export type ListFilterHistoryRequest = Record<string, never>;
|
||||||
|
|
||||||
export type ListFlowsResponse = {
|
export type ListFlowsResponse = {
|
||||||
/**
|
/**
|
||||||
* Newest first.
|
* Newest first.
|
||||||
@@ -459,6 +476,11 @@ export type QueryValuesRequest = {
|
|||||||
*/
|
*/
|
||||||
path: string, };
|
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 = {
|
export type ReflectGrpcHostRequest = {
|
||||||
/**
|
/**
|
||||||
* `host` or `host:port`, over TLS on 443 unless a port is given, or either after
|
* `host` or `host:port`, over TLS on 443 unless a port is given, or either after
|
||||||
@@ -472,9 +494,9 @@ export type RenameSavedQueryRequest = { id: string, name: string, };
|
|||||||
|
|
||||||
export type ResetCapturedDataRequest = Record<string, never>;
|
export type ResetCapturedDataRequest = Record<string, never>;
|
||||||
|
|
||||||
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<QueryDiagnostic>], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array<SavedQuery>], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], 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<string>], match_flows: [MatchFlowsRequest, Array<string>], count_saved_queries: [CountSavedQueriesRequest, Array<SavedQueryCount>], };
|
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<QueryDiagnostic>], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array<SavedQuery>], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array<FilterHistoryEntry>], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], get_last_filter: [GetLastFilterRequest, string], set_last_filter: [SetLastFilterRequest, boolean], get_window_places: [GetWindowPlacesRequest, string | null], set_window_places: [SetWindowPlacesRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], 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<string>], match_flows: [MatchFlowsRequest, Array<string>], get_flows: [GetFlowsRequest, Array<Flow>], count_saved_queries: [CountSavedQueriesRequest, Array<SavedQueryCount>], };
|
||||||
|
|
||||||
export type SaveFlowBodyRequest = { flowId: string, which: FlowBody,
|
export type SaveFlowBodyRequest = { flowId: string, which: FlowBody,
|
||||||
/**
|
/**
|
||||||
@@ -588,6 +610,11 @@ export type SetKeepTrafficRequest = {
|
|||||||
*/
|
*/
|
||||||
bytes: number | null, };
|
bytes: number | null, };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filter the window applied, kept in the encrypted database for the next launch.
|
||||||
|
*/
|
||||||
|
export type SetLastFilterRequest = { filter: string, };
|
||||||
|
|
||||||
export type SetPortRequest = { port: number, };
|
export type SetPortRequest = { port: number, };
|
||||||
|
|
||||||
export type SetRecordingRequest = { enabled: boolean, };
|
export type SetRecordingRequest = { enabled: boolean, };
|
||||||
@@ -596,6 +623,11 @@ export type SetSystemProxyAtLaunchRequest = { enabled: boolean, };
|
|||||||
|
|
||||||
export type SetTrafficRequest = { traffic: TrafficRetention, };
|
export type SetTrafficRequest = { traffic: TrafficRetention, };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window's places, opaque to the engine, kept in memory for a reload of the window.
|
||||||
|
*/
|
||||||
|
export type SetWindowPlacesRequest = { places: string, };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A column the list sorts by.
|
* A column the list sorts by.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- 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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The filter the window last applied, however it was applied, which the next launch restores.
|
||||||
|
CREATE TABLE last_filter
|
||||||
|
(
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
|
||||||
|
filter TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -519,11 +519,13 @@ mod tests {
|
|||||||
tables,
|
tables,
|
||||||
vec![
|
vec![
|
||||||
"_sqlx_migrations",
|
"_sqlx_migrations",
|
||||||
|
"filter_history",
|
||||||
"flow_clear_mark",
|
"flow_clear_mark",
|
||||||
"flow_messages",
|
"flow_messages",
|
||||||
"flow_sweep_pending",
|
"flow_sweep_pending",
|
||||||
"flows",
|
"flows",
|
||||||
"grpc_schemas",
|
"grpc_schemas",
|
||||||
|
"last_filter",
|
||||||
"saved_queries"
|
"saved_queries"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -615,7 +617,8 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
&applied[6..],
|
&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");
|
let mark_columns = columns(&conn, "flow_clear_mark");
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! 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<Vec<FilterHistoryEntry>, 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::<rusqlite::Result<Vec<_>>>())
|
||||||
|
.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<bool, String> {
|
||||||
|
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<bool, String> {
|
||||||
|
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<usize, String> {
|
||||||
|
db.with_tx(|c| {
|
||||||
|
c.conn()
|
||||||
|
.resolve()
|
||||||
|
.execute("DELETE FROM filter_history", [])
|
||||||
|
.map_err(yaak_database::Error::from)
|
||||||
|
})
|
||||||
|
.map_err(|e| failed("clear", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filter the window last applied, in any way, or empty for none. Empty for a database from
|
||||||
|
/// before it, read without migrating.
|
||||||
|
pub fn last(db: &ProxyQueryManager) -> Result<String, String> {
|
||||||
|
db.with_conn(|c| {
|
||||||
|
let conn = c.conn().resolve();
|
||||||
|
let Ok(mut stmt) = conn.prepare("SELECT filter FROM last_filter WHERE id = 1") else {
|
||||||
|
return Ok(String::new());
|
||||||
|
};
|
||||||
|
match stmt.query_row([], |row| row.get(0)) {
|
||||||
|
Ok(filter) => Ok(filter),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(String::new()),
|
||||||
|
Err(e) => Err(failed("read the last filter from", e)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the filter the window applied, for the next launch to restore.
|
||||||
|
pub fn set_last(db: &ProxyQueryManager, filter: &str) -> Result<(), String> {
|
||||||
|
db.with_tx(|c| {
|
||||||
|
c.conn()
|
||||||
|
.resolve()
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO last_filter (id, filter) VALUES (1, ?) \
|
||||||
|
ON CONFLICT (id) DO UPDATE SET filter = excluded.filter",
|
||||||
|
[filter],
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(yaak_database::Error::from)
|
||||||
|
})
|
||||||
|
.map_err(|e| failed("keep the last filter in", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn open(dir: &tempfile::TempDir) -> ProxyQueryManager {
|
||||||
|
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filters(db: &ProxyQueryManager) -> Vec<String> {
|
||||||
|
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::<String>::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 keeps_the_last_filter_applied() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = open(&dir);
|
||||||
|
assert_eq!(last(&db).unwrap(), "");
|
||||||
|
set_last(&db, "@host:*.github.com").unwrap();
|
||||||
|
set_last(&db, "stripe").unwrap();
|
||||||
|
assert_eq!(last(&db).unwrap(), "stripe");
|
||||||
|
set_last(&db, "").unwrap();
|
||||||
|
assert_eq!(last(&db).unwrap(), "");
|
||||||
|
// Apart from the history, which only Enter writes.
|
||||||
|
assert_eq!(filters(&db), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<String>::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ pub mod control;
|
|||||||
pub mod convert;
|
pub mod convert;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
mod exit_gate;
|
mod exit_gate;
|
||||||
|
pub mod filter_history;
|
||||||
pub mod flows;
|
pub mod flows;
|
||||||
pub mod grpc;
|
pub mod grpc;
|
||||||
pub mod har;
|
pub mod har;
|
||||||
@@ -109,6 +110,9 @@ pub struct ProxyCtx {
|
|||||||
/// Captured traffic is to be emptied as the databases are first unlocked, this launch
|
/// Captured traffic is to be emptied as the databases are first unlocked, this launch
|
||||||
/// being set to clear it. Taken once it is.
|
/// being set to clear it. Taken once it is.
|
||||||
clear_at_launch: Arc<AtomicBool>,
|
clear_at_launch: Arc<AtomicBool>,
|
||||||
|
/// The window's places, as it last sent them, for a reload of it to take back. Held in
|
||||||
|
/// memory only, as filters can hold captured values, and gone when the app quits.
|
||||||
|
window_places: Arc<Mutex<Option<String>>>,
|
||||||
/// `traffic.lock`, locked shared for as long as this process has the databases open, so
|
/// `traffic.lock`, locked shared for as long as this process has the databases open, so
|
||||||
/// another launch can't delete them from under it: clearing them takes it alone.
|
/// another launch can't delete them from under it: clearing them takes it alone.
|
||||||
traffic_lock: Arc<Mutex<Option<std::fs::File>>>,
|
traffic_lock: Arc<Mutex<Option<std::fs::File>>>,
|
||||||
@@ -166,6 +170,7 @@ impl ProxyCtx {
|
|||||||
resets: Arc::new(AtomicU64::new(0)),
|
resets: Arc::new(AtomicU64::new(0)),
|
||||||
sweeper: Default::default(),
|
sweeper: Default::default(),
|
||||||
clear_at_launch: Arc::new(AtomicBool::new(false)),
|
clear_at_launch: Arc::new(AtomicBool::new(false)),
|
||||||
|
window_places: Arc::new(Mutex::new(None)),
|
||||||
traffic_lock: Arc::new(Mutex::new(None)),
|
traffic_lock: Arc::new(Mutex::new(None)),
|
||||||
system_proxy_intent: Arc::new(Mutex::new(())),
|
system_proxy_intent: Arc::new(Mutex::new(())),
|
||||||
watching_system_proxy: Arc::new(AtomicBool::new(false)),
|
watching_system_proxy: Arc::new(AtomicBool::new(false)),
|
||||||
@@ -1745,6 +1750,60 @@ pub struct CountSavedQueriesRequest {
|
|||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct SavedQueriesChangedPayload {}
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 GetLastFilterRequest {}
|
||||||
|
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
pub struct GetWindowPlacesRequest {}
|
||||||
|
|
||||||
|
/// The window's places, opaque to the engine, kept in memory for a reload of the window.
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
pub struct SetWindowPlacesRequest {
|
||||||
|
pub places: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filter the window applied, kept in the encrypted database for the next launch.
|
||||||
|
#[derive(Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
pub struct SetLastFilterRequest {
|
||||||
|
pub filter: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, TS)]
|
#[derive(Deserialize, TS)]
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct ClearFlowsRequest {}
|
pub struct ClearFlowsRequest {}
|
||||||
@@ -2761,6 +2820,68 @@ fn delete_saved_query(ctx: &ProxyCtx, req: DeleteSavedQueryRequest) -> Result<bo
|
|||||||
Ok(deleted)
|
Ok(deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_flows(ctx: ProxyCtx, req: GetFlowsRequest) -> Result<Vec<Flow>, 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<Vec<filter_history::FilterHistoryEntry>, RpcError> {
|
||||||
|
filter_history::list(&ctx.db()?).map_err(rpc_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_window_places(
|
||||||
|
ctx: &ProxyCtx,
|
||||||
|
_req: GetWindowPlacesRequest,
|
||||||
|
) -> Result<Option<String>, RpcError> {
|
||||||
|
Ok(ctx.window_places.lock().unwrap_or_else(PoisonError::into_inner).clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_window_places(ctx: &ProxyCtx, req: SetWindowPlacesRequest) -> Result<bool, RpcError> {
|
||||||
|
*ctx.window_places.lock().unwrap_or_else(PoisonError::into_inner) = Some(req.places);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_last_filter(ctx: &ProxyCtx, _req: GetLastFilterRequest) -> Result<String, RpcError> {
|
||||||
|
filter_history::last(&ctx.db()?).map_err(rpc_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_last_filter(ctx: &ProxyCtx, req: SetLastFilterRequest) -> Result<bool, RpcError> {
|
||||||
|
filter_history::set_last(&ctx.db()?, &req.filter).map_err(rpc_error)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_filter(ctx: &ProxyCtx, req: RecordFilterRequest) -> Result<bool, RpcError> {
|
||||||
|
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<bool, RpcError> {
|
||||||
|
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<usize, RpcError> {
|
||||||
|
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(
|
async fn count_saved_queries(
|
||||||
ctx: ProxyCtx,
|
ctx: ProxyCtx,
|
||||||
req: CountSavedQueriesRequest,
|
req: CountSavedQueriesRequest,
|
||||||
@@ -3644,6 +3765,14 @@ define_rpc! {
|
|||||||
create_saved_query(CreateSavedQueryRequest) -> saved_queries::SavedQuery,
|
create_saved_query(CreateSavedQueryRequest) -> saved_queries::SavedQuery,
|
||||||
rename_saved_query(RenameSavedQueryRequest) -> saved_queries::SavedQuery,
|
rename_saved_query(RenameSavedQueryRequest) -> saved_queries::SavedQuery,
|
||||||
delete_saved_query(DeleteSavedQueryRequest) -> bool,
|
delete_saved_query(DeleteSavedQueryRequest) -> bool,
|
||||||
|
list_filter_history(ListFilterHistoryRequest) -> Vec<filter_history::FilterHistoryEntry>,
|
||||||
|
record_filter(RecordFilterRequest) -> bool,
|
||||||
|
delete_filter_history(DeleteFilterHistoryRequest) -> bool,
|
||||||
|
clear_filter_history(ClearFilterHistoryRequest) -> usize,
|
||||||
|
get_last_filter(GetLastFilterRequest) -> String,
|
||||||
|
set_last_filter(SetLastFilterRequest) -> bool,
|
||||||
|
get_window_places(GetWindowPlacesRequest) -> Option<String>,
|
||||||
|
set_window_places(SetWindowPlacesRequest) -> bool,
|
||||||
list_grpc_schemas(ListGrpcSchemasRequest) -> Vec<GrpcSchema>,
|
list_grpc_schemas(ListGrpcSchemasRequest) -> Vec<GrpcSchema>,
|
||||||
list_messages(ListMessagesRequest) -> ListMessagesResponse,
|
list_messages(ListMessagesRequest) -> ListMessagesResponse,
|
||||||
remove_grpc_schema(RemoveGrpcSchemaRequest) -> bool,
|
remove_grpc_schema(RemoveGrpcSchemaRequest) -> bool,
|
||||||
@@ -3684,10 +3813,12 @@ define_rpc! {
|
|||||||
open_terminal(OpenTerminalRequest) -> bool,
|
open_terminal(OpenTerminalRequest) -> bool,
|
||||||
query_values(QueryValuesRequest) -> Vec<String>,
|
query_values(QueryValuesRequest) -> Vec<String>,
|
||||||
match_flows(MatchFlowsRequest) -> Vec<String>,
|
match_flows(MatchFlowsRequest) -> Vec<String>,
|
||||||
|
get_flows(GetFlowsRequest) -> Vec<Flow>,
|
||||||
count_saved_queries(CountSavedQueriesRequest) -> Vec<saved_queries::SavedQueryCount>,
|
count_saved_queries(CountSavedQueriesRequest) -> Vec<saved_queries::SavedQueryCount>,
|
||||||
}
|
}
|
||||||
events {
|
events {
|
||||||
decrypt_list_changed(DecryptListChangedPayload),
|
decrypt_list_changed(DecryptListChangedPayload),
|
||||||
|
filter_history_changed(FilterHistoryChangedPayload),
|
||||||
flows_cleared(FlowsClearedPayload),
|
flows_cleared(FlowsClearedPayload),
|
||||||
flows_removed(FlowsRemovedPayload),
|
flows_removed(FlowsRemovedPayload),
|
||||||
message_write(MessagePayload),
|
message_write(MessagePayload),
|
||||||
@@ -4464,6 +4595,73 @@ 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<Flow>| flows.into_iter().map(|f| f.id).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window's places are held for a reload of it, in memory, and none at launch.
|
||||||
|
#[test]
|
||||||
|
fn window_places_are_held_in_memory_for_a_reload() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (events, _rx) = RpcEventEmitter::new();
|
||||||
|
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
|
||||||
|
assert_eq!(get_window_places(&ctx, GetWindowPlacesRequest {}).unwrap(), None);
|
||||||
|
let places = r#"{"places":[{"filter":"stripe","selection":[],"tabs":{}}],"index":0}"#;
|
||||||
|
set_window_places(&ctx, SetWindowPlacesRequest { places: places.into() }).unwrap();
|
||||||
|
let held = get_window_places(&ctx.clone(), GetWindowPlacesRequest {}).unwrap();
|
||||||
|
assert_eq!(held.as_deref(), Some(places));
|
||||||
|
drop(ctx);
|
||||||
|
|
||||||
|
let (events, _rx) = RpcEventEmitter::new();
|
||||||
|
let next = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
|
||||||
|
assert_eq!(get_window_places(&next, GetWindowPlacesRequest {}).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters applied are listed newest first, each once, and the app hears of each change.
|
||||||
|
#[test]
|
||||||
|
fn filter_history_commands_record_list_and_forget() {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
};
|
||||||
|
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]
|
#[tokio::test]
|
||||||
async fn the_app_still_hears_why_a_body_failed_to_decode() {
|
async fn the_app_still_hears_why_a_body_failed_to_decode() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -5061,6 +5259,8 @@ mod tests {
|
|||||||
flow_with_response(&ctx, "fl_a", "text/plain", b"one");
|
flow_with_response(&ctx, "fl_a", "text/plain", b"one");
|
||||||
flow_with_response(&ctx, "fl_b", "text/plain", b"two");
|
flow_with_response(&ctx, "fl_b", "text/plain", b"two");
|
||||||
saved_queries::create(&ctx.db().unwrap(), "Errors", "@status:>=400").unwrap();
|
saved_queries::create(&ctx.db().unwrap(), "Errors", "@status:>=400").unwrap();
|
||||||
|
filter_history::record(&ctx.db().unwrap(), "@host:api.stripe.com").unwrap();
|
||||||
|
filter_history::set_last(&ctx.db().unwrap(), "@status:>=400").unwrap();
|
||||||
assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), 2);
|
assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), 2);
|
||||||
}
|
}
|
||||||
if let Some(settings) = settings {
|
if let Some(settings) = settings {
|
||||||
@@ -5070,7 +5270,8 @@ mod tests {
|
|||||||
ProxyCtx::new(dir, events, keys.clone()).unwrap()
|
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]
|
#[test]
|
||||||
fn a_launch_clears_captured_traffic_by_default() {
|
fn a_launch_clears_captured_traffic_by_default() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -5081,6 +5282,12 @@ mod tests {
|
|||||||
assert_eq!(blobs_of(&ctx, "fl_a", 0), 0);
|
assert_eq!(blobs_of(&ctx, "fl_a", 0), 0);
|
||||||
let saved = saved_queries::list(&db).unwrap();
|
let saved = saved_queries::list(&db).unwrap();
|
||||||
assert_eq!(saved.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Errors"]);
|
assert_eq!(saved.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Errors"]);
|
||||||
|
let history = filter_history::list(&db).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
history.iter().map(|h| h.filter.as_str()).collect::<Vec<_>>(),
|
||||||
|
["@host:api.stripe.com"]
|
||||||
|
);
|
||||||
|
assert_eq!(filter_history::last(&db).unwrap(), "@status:>=400");
|
||||||
// New traffic is written as before.
|
// New traffic is written as before.
|
||||||
flow_with_response(&ctx, "fl_c", "text/plain", b"three");
|
flow_with_response(&ctx, "fl_c", "text/plain", b"three");
|
||||||
assert_eq!(flows::count(&db).unwrap(), 1);
|
assert_eq!(flows::count(&db).unwrap(), 1);
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) {
|
|||||||
error!("Failed to emit the HAR export: {e}");
|
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.refresh" => webview_window.eval("location.reload()").unwrap(),
|
||||||
"dev.toggle_devtools" => {
|
"dev.toggle_devtools" => {
|
||||||
if webview_window.is_devtools_open() {
|
if webview_window.is_devtools_open() {
|
||||||
@@ -82,6 +92,12 @@ async fn rpc(
|
|||||||
router.dispatch(&cmd, payload, &ctx).await.map_err(|e| e.message)
|
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() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
@@ -135,7 +151,7 @@ pub fn run() {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![rpc])
|
.invoke_handler(tauri::generate_handler![rpc, set_go_menu])
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building yaak proxy tauri application")
|
.expect("error while building yaak proxy tauri application")
|
||||||
.run(|app_handle, event| {
|
.run(|app_handle, event| {
|
||||||
|
|||||||
@@ -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.
|
/// The event the window hears File's Export All Flows as HAR by.
|
||||||
pub const EXPORT_HAR_EVENT: &str = "menu_export_har";
|
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<R: Runtime>(app_handle: &AppHandle<R>, 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<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>> {
|
pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||||
let pkg_info = app_handle.package_info();
|
let pkg_info = app_handle.package_info();
|
||||||
let config = app_handle.config();
|
let config = app_handle.config();
|
||||||
@@ -124,6 +155,22 @@ pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>>
|
|||||||
&PredefinedMenuItem::fullscreen(app_handle, None)?,
|
&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,
|
&window_menu,
|
||||||
&help_menu,
|
&help_menu,
|
||||||
#[cfg(dev)]
|
#[cfg(dev)]
|
||||||
|
|||||||
@@ -78,8 +78,18 @@ export type DropdownItemDefault = {
|
|||||||
*/
|
*/
|
||||||
submenuTrigger?: "row" | "button";
|
submenuTrigger?: "row" | "button";
|
||||||
icon?: IconProps["icon"];
|
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 type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent;
|
||||||
|
|
||||||
export interface DropdownProps {
|
export interface DropdownProps {
|
||||||
@@ -819,6 +829,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onHover={handleItemHover}
|
onHover={handleItemHover}
|
||||||
onOpenSubmenu={(item, el) => setActiveSubmenu({ item, parent: el })}
|
onOpenSubmenu={(item, el) => setActiveSubmenu({ item, parent: el })}
|
||||||
|
onCloseAll={handleCloseAll}
|
||||||
// oxlint-disable-next-line no-array-index-key -- It's fine
|
// oxlint-disable-next-line no-array-index-key -- It's fine
|
||||||
key={i}
|
key={i}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -891,10 +902,15 @@ interface MenuItemProps {
|
|||||||
onFocus: (item: DropdownItemDefault) => void;
|
onFocus: (item: DropdownItemDefault) => void;
|
||||||
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||||
onOpenSubmenu: (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;
|
focused: boolean;
|
||||||
isParentOfActiveSubmenu?: 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({
|
function MenuItem({
|
||||||
className,
|
className,
|
||||||
focused,
|
focused,
|
||||||
@@ -903,6 +919,7 @@ function MenuItem({
|
|||||||
item,
|
item,
|
||||||
onSelect,
|
onSelect,
|
||||||
onOpenSubmenu,
|
onOpenSubmenu,
|
||||||
|
onCloseAll,
|
||||||
isParentOfActiveSubmenu,
|
isParentOfActiveSubmenu,
|
||||||
...props
|
...props
|
||||||
}: MenuItemProps) {
|
}: MenuItemProps) {
|
||||||
@@ -939,6 +956,9 @@ function MenuItem({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
|
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 =
|
const rightSlot =
|
||||||
item.submenu && !hasButtonSubmenu ? (
|
item.submenu && !hasButtonSubmenu ? (
|
||||||
@@ -952,8 +972,8 @@ function MenuItem({
|
|||||||
ref={initRef}
|
ref={initRef}
|
||||||
size="sm"
|
size="sm"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onMouseEnter={hasButtonSubmenu ? undefined : handleMouseEnter}
|
onMouseEnter={overlaid > 0 ? undefined : handleMouseEnter}
|
||||||
onMouseLeave={hasButtonSubmenu ? undefined : (e) => e.currentTarget.blur()}
|
onMouseLeave={overlaid > 0 ? undefined : (e) => e.currentTarget.blur()}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -974,7 +994,7 @@ function MenuItem({
|
|||||||
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
|
"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",
|
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
|
||||||
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
|
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
|
||||||
hasButtonSubmenu && "pr-8",
|
ROW_BUTTONS_PADDING[overlaid],
|
||||||
item.color === "danger" && "text-danger!",
|
item.color === "danger" && "text-danger!",
|
||||||
item.color === "primary" && "text-primary!",
|
item.color === "primary" && "text-primary!",
|
||||||
item.color === "success" && "text-success!",
|
item.color === "success" && "text-success!",
|
||||||
@@ -988,13 +1008,13 @@ function MenuItem({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasButtonSubmenu) {
|
if (overlaid === 0) {
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The submenu trigger overlays the row as a sibling (not a child) because the row is
|
// The actions and submenu trigger overlay the row as siblings (not children) because the row
|
||||||
// itself a button and buttons cannot nest. Hover handling lives on this wrapper so the
|
// 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.
|
// row keeps its focus highlight while the mouse is over them.
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative grid group/menuitem"
|
className="relative grid group/menuitem"
|
||||||
@@ -1013,23 +1033,44 @@ function MenuItem({
|
|||||||
"opacity-0 group-hover/menuitem:opacity-100 group-focus-within/menuitem:opacity-100",
|
"opacity-0 group-hover/menuitem:opacity-100 group-focus-within/menuitem:opacity-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<IconButton
|
{actions.map((action) => (
|
||||||
color="custom"
|
<IconButton
|
||||||
size="2xs"
|
key={action.title}
|
||||||
tabIndex={-1}
|
color="custom"
|
||||||
icon="ellipsis_vertical"
|
size="2xs"
|
||||||
iconColor="secondary"
|
tabIndex={-1}
|
||||||
title="More actions"
|
icon={action.icon}
|
||||||
className="h-full! w-7!"
|
iconColor="secondary"
|
||||||
onMouseDown={(e) => {
|
title={action.title}
|
||||||
// Prevent the trigger from stealing focus, which would unhighlight the row
|
className="h-full! w-6!"
|
||||||
e.preventDefault();
|
// Prevent the button from stealing focus, which would unhighlight the row
|
||||||
}}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenSubmenu(item, e.currentTarget);
|
action.onSelect();
|
||||||
}}
|
if (action.closeMenu) onCloseAll?.();
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hasButtonSubmenu && (
|
||||||
|
<IconButton
|
||||||
|
color="custom"
|
||||||
|
size="2xs"
|
||||||
|
tabIndex={-1}
|
||||||
|
icon="ellipsis_vertical"
|
||||||
|
iconColor="secondary"
|
||||||
|
title="More actions"
|
||||||
|
className="h-full! w-7!"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Prevent the trigger from stealing focus, which would unhighlight the row
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenSubmenu(item, e.currentTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export interface QueryFilterOptions {
|
|||||||
* searches. `typed` is the word, and the completions replace it.
|
* searches. `typed` is the word, and the completions replace it.
|
||||||
*/
|
*/
|
||||||
moreCompletions?: (typed: string) => Completion[];
|
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 */
|
/** The terms of a query the search cannot run, underlined as typing settles */
|
||||||
problems?: (text: string) => Promise<QueryProblem[]>;
|
problems?: (text: string) => Promise<QueryProblem[]>;
|
||||||
/**
|
/**
|
||||||
@@ -139,6 +144,7 @@ function completionSource({
|
|||||||
values,
|
values,
|
||||||
loadValues,
|
loadValues,
|
||||||
moreCompletions,
|
moreCompletions,
|
||||||
|
leadCompletions,
|
||||||
fieldsOnly,
|
fieldsOnly,
|
||||||
}: QueryFilterOptions) {
|
}: QueryFilterOptions) {
|
||||||
return async (ctx: CompletionContext): Promise<CompletionResult | null> => {
|
return async (ctx: CompletionContext): Promise<CompletionResult | null> => {
|
||||||
@@ -179,11 +185,14 @@ function completionSource({
|
|||||||
if (from === word.from) options.push(...more);
|
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
|
// Field names pop up for something typed; on an empty spot, only when asked for
|
||||||
const onlyFields = options.every((o) => o.type === "field");
|
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
|
// The engine has already filtered and ranked the options
|
||||||
return { from, to, options, filter: false };
|
return { from, to, options, filter: false };
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export interface HttpRequestPaneViewProps {
|
|||||||
* request doesn't carry to the next
|
* request doesn't carry to the next
|
||||||
*/
|
*/
|
||||||
defaultTab?: string;
|
defaultTab?: string;
|
||||||
|
/** The tab to show over any remembered, while this request offers it */
|
||||||
|
activeTab?: string;
|
||||||
/** A tab was chosen */
|
/** A tab was chosen */
|
||||||
onTabChange?: (tab: string) => void;
|
onTabChange?: (tab: string) => void;
|
||||||
}
|
}
|
||||||
@@ -165,6 +167,7 @@ export function HttpRequestPaneView({
|
|||||||
extraTabs,
|
extraTabs,
|
||||||
urlLine,
|
urlLine,
|
||||||
defaultTab,
|
defaultTab,
|
||||||
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
}: HttpRequestPaneViewProps) {
|
}: HttpRequestPaneViewProps) {
|
||||||
const { ErrorBoundary } = useEditorHost();
|
const { ErrorBoundary } = useEditorHost();
|
||||||
@@ -284,6 +287,7 @@ export function HttpRequestPaneView({
|
|||||||
storageKey={tabsStorageKey}
|
storageKey={tabsStorageKey}
|
||||||
activeTabKey={request.id}
|
activeTabKey={request.id}
|
||||||
defaultValue={defaultTab}
|
defaultValue={defaultTab}
|
||||||
|
value={activeTab}
|
||||||
keyedDefault={defaultTab != null}
|
keyedDefault={defaultTab != null}
|
||||||
onChangeValue={onTabChange}
|
onChangeValue={onTabChange}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export interface HttpResponsePaneViewProps {
|
|||||||
* doesn't carry to the next
|
* doesn't carry to the next
|
||||||
*/
|
*/
|
||||||
defaultTab?: string;
|
defaultTab?: string;
|
||||||
|
/** The tab to show over any remembered, while this response offers it */
|
||||||
|
activeTab?: string;
|
||||||
/** A tab was chosen */
|
/** A tab was chosen */
|
||||||
onTabChange?: (tab: string) => void;
|
onTabChange?: (tab: string) => void;
|
||||||
}
|
}
|
||||||
@@ -116,6 +118,7 @@ export function HttpResponsePaneView({
|
|||||||
requestShownApart = false,
|
requestShownApart = false,
|
||||||
bare = false,
|
bare = false,
|
||||||
defaultTab,
|
defaultTab,
|
||||||
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
}: HttpResponsePaneViewProps) {
|
}: HttpResponsePaneViewProps) {
|
||||||
const { ErrorBoundary } = useEditorHost();
|
const { ErrorBoundary } = useEditorHost();
|
||||||
@@ -264,6 +267,7 @@ export function HttpResponsePaneView({
|
|||||||
storageKey={tabsStorageKey}
|
storageKey={tabsStorageKey}
|
||||||
activeTabKey={activeTabKey}
|
activeTabKey={activeTabKey}
|
||||||
defaultValue={defaultTab}
|
defaultValue={defaultTab}
|
||||||
|
value={activeTab}
|
||||||
keyedDefault={defaultTab != null}
|
keyedDefault={defaultTab != null}
|
||||||
onChangeValue={onTabChange}
|
onChangeValue={onTabChange}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export interface TabsProps {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Default tab value. If not provided, defaults to first tab. */
|
/** Default tab value. If not provided, defaults to first tab. */
|
||||||
defaultValue?: string;
|
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 */
|
/** Called when active tab changes */
|
||||||
onChangeValue?: (value: string) => void;
|
onChangeValue?: (value: string) => void;
|
||||||
tabs: TabItem[];
|
tabs: TabItem[];
|
||||||
@@ -99,6 +104,7 @@ export interface TabsProps {
|
|||||||
export const Tabs = forwardRef<TabsRef, TabsProps>(function Tabs(
|
export const Tabs = forwardRef<TabsRef, TabsProps>(function Tabs(
|
||||||
{
|
{
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
value: controlledValue,
|
||||||
onChangeValue: onChangeValueProp,
|
onChangeValue: onChangeValueProp,
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
@@ -142,7 +148,12 @@ export const Tabs = forwardRef<TabsRef, TabsProps>(function Tabs(
|
|||||||
keyedDefault && internal?.key !== activeTabKey ? undefined : internal?.value;
|
keyedDefault && internal?.key !== activeTabKey ? undefined : internal?.value;
|
||||||
const offered = (v: string | undefined) =>
|
const offered = (v: string | undefined) =>
|
||||||
v != null && (!keyedDefault || originalTabs.some((t) => t.value === v));
|
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)
|
// Helper to normalize storage (handle migration from old format)
|
||||||
const normalizeStorage = useCallback(
|
const normalizeStorage = useCallback(
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ import { useAtomValue, useStore } from "jotai";
|
|||||||
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms";
|
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms";
|
||||||
import { type CollapsedAtom, CollapsedAtomContext } from "./context";
|
import { type CollapsedAtom, CollapsedAtomContext } from "./context";
|
||||||
import type { ContextMenuRenderer, TreeNode } from "./common";
|
import type { ContextMenuRenderer, TreeNode } from "./common";
|
||||||
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from "./common";
|
import {
|
||||||
|
closestVisibleNode,
|
||||||
|
equalSubtree,
|
||||||
|
getSelectedItems,
|
||||||
|
hasAncestor,
|
||||||
|
tabbableSelection,
|
||||||
|
} from "./common";
|
||||||
import { TreeDragOverlay } from "./TreeDragOverlay";
|
import { TreeDragOverlay } from "./TreeDragOverlay";
|
||||||
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem";
|
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem";
|
||||||
import type { TreeItemListProps } from "./TreeItemList";
|
import type { TreeItemListProps } from "./TreeItemList";
|
||||||
@@ -222,9 +228,12 @@ function TreeInner<T extends { id: string }>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const closest = closestVisibleNode(store, collapsedAtom, lastSelectedItem.node);
|
const closest = closestVisibleNode(store, collapsedAtom, lastSelectedItem.node);
|
||||||
if (closest != null) {
|
if (closest != null && lastSelectedId != null) {
|
||||||
const id = closest.item.id;
|
const id = closest.item.id;
|
||||||
store.set(selectedIdsFamily(treeId), [id]);
|
const selected = store.get(selectedIdsFamily(treeId));
|
||||||
|
const next = tabbableSelection(selected, lastSelectedId, id);
|
||||||
|
if (next == null) return;
|
||||||
|
store.set(selectedIdsFamily(treeId), next);
|
||||||
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||||
}
|
}
|
||||||
}, [selectableItems, treeId]);
|
}, [selectableItems, treeId]);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { tabbableSelection } from "./common";
|
||||||
|
|
||||||
|
describe("tabbableSelection", () => {
|
||||||
|
test("leaves several selected rows alone while the last one focused is in view", () => {
|
||||||
|
// As the tree renders again, say for new counts, the selection stands.
|
||||||
|
expect(tabbableSelection(["a", "b", "c"], "b", "b")).toBeNull();
|
||||||
|
expect(tabbableSelection(["b"], "b", "b")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moves to the closest row in view once the last one focused is hidden", () => {
|
||||||
|
expect(tabbableSelection(["a", "child"], "child", "parent")).toEqual(["parent"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selects the last row focused when the selection no longer holds it", () => {
|
||||||
|
expect(tabbableSelection([], "b", "b")).toEqual(["b"]);
|
||||||
|
expect(tabbableSelection(["a"], "b", "b")).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,6 +85,20 @@ export function isVisibleNode<T extends { id: string }>(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selection that keeps a row tabbable, from the last row focused and the closest of it still
|
||||||
|
* in view, or null to leave the selection as it is: while that row is in view and selected, a
|
||||||
|
* selection of several rows stands, rather than shrinking to it each time the tree renders.
|
||||||
|
*/
|
||||||
|
export function tabbableSelection(
|
||||||
|
selected: readonly string[],
|
||||||
|
lastId: string,
|
||||||
|
closestId: string,
|
||||||
|
): string[] | null {
|
||||||
|
if (closestId === lastId && selected.includes(lastId)) return null;
|
||||||
|
return [closestId];
|
||||||
|
}
|
||||||
|
|
||||||
export function closestVisibleNode<T extends { id: string }>(
|
export function closestVisibleNode<T extends { id: string }>(
|
||||||
store: JotaiStore,
|
store: JotaiStore,
|
||||||
collapsedAtom: CollapsedAtom,
|
collapsedAtom: CollapsedAtom,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export { DropMarker } from "./components/DropMarker";
|
|||||||
export type {
|
export type {
|
||||||
ContextMenuProps,
|
ContextMenuProps,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
|
DropdownItemAction,
|
||||||
DropdownItemContent,
|
DropdownItemContent,
|
||||||
DropdownItemDefault,
|
DropdownItemDefault,
|
||||||
DropdownItemSeparator,
|
DropdownItemSeparator,
|
||||||
|
|||||||
Reference in new issue
Block a user