import { useVirtualizer } from "@tanstack/react-virtual"; import { formatSize } from "@yaakapp-internal/lib/formatSize"; import type { Flow, FlowSortKey } from "@yaakapp-internal/proxy-lib"; import { ContextMenu, CONTROL_TOOLTIP_DELAY, CountBadge, type DropdownItem, formatMillis, HttpMethodTagRaw, HttpStatusTagRaw, Icon, Tooltip, } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import type { CSSProperties, KeyboardEvent, MouseEvent, ReactElement, WheelEvent } from "react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { appliedFilterAtom, placeSelectionAtom, revealFlowAtom, selectedFlowIdAtom, } from "../lib/atoms"; import { shortContentType } from "../lib/body"; import { utcTimestamp } from "../lib/flowViews"; import { type Column, type ColumnId, cellFilterTerm, customValue, gridTemplate, gridWidth, hostOf, isCustomColumn, nextSort, operationOf, showOperationFor, shownColumns, tlsVersionOf, toggleSortKey, unavailableColumns, urlParts, } from "../lib/columns"; import { useExportShownHar, useFlowActions } from "../lib/flowActions"; import { compareFlows } from "../lib/flowWindow"; import { countNewFlows, isAtEnd, resumesFollowing } from "../lib/follow"; import { applyFilter, goToPlace } from "../lib/navigation"; import { asOneTerm } from "../lib/query"; import { folderOf } from "../lib/search"; import { columnPrefsAtom, hasAppsAtom, flowSortAtom, followAtom, loadMoreSortedAtom, loadOlderFlowsAtom, newestStartedAtAtom, oldestFlowAtom, proxyStateAtom, searchLoadingAtom, flowsAtom, shownFlowsAtom, } from "../lib/store"; import { useShowToast } from "../lib/toast"; import { AppIcon } from "./AppIcon"; import { FlowListEmpty, PausedRow } from "./FlowListEmpty"; import { FlowListHeader } from "./FlowListHeader"; const BOTTOM_PADDING = 6; /** Rows from the top at which older flows start loading, or from the bottom, sorted, more */ const LOAD_OLDER_ROWS = 20; /** How long a row that arrives while following stays tinted */ const ARRIVAL_MS = 1400; /** Most flows arriving together that still tint; more is a burst */ const ARRIVAL_MAX_BATCH = 5; /** `h-xs`, the height of Yaak's event rows and menu items, in pixels at the root font size */ function rowHeight(): number { const root = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 15; return Math.round(1.8 * root); } interface Props { style?: CSSProperties; className?: string; } /** * Follows new traffic until a flow is selected or the list is scrolled, then holds. Scrolling to * the top loads older flows from the database. Catching up leaves a divider where the hold * began. Sorting by columns reads the flows from the database in that order, a page at a time on * scrolling down, and stops following. The columns keep their widths, and the list scrolls * sideways when they are wider than it, the header with it. */ export function FlowList({ style, className }: Props) { const flows = useAtomValue(shownFlowsAtom); const [menu, setMenu] = useState<{ flow: Flow; column: ColumnId | null; x: number; y: number; } | null>(null); const flowActions = useFlowActions(); const cellActions = useCellActions(); const exportShown = useExportShownHar(); const store = useStore(); const selectedId = useAtomValue(selectedFlowIdAtom); const loadOlder = useSetAtom(loadOlderFlowsAtom); const loadMoreSorted = useSetAtom(loadMoreSortedAtom); const oldestFlow = useAtomValue(oldestFlowAtom); const [prefs, setPrefs] = useAtom(columnPrefsAtom); const [sort, setSort] = useAtom(flowSortAtom); const sorted = sort.length > 0; const allFlows = useAtomValue(flowsAtom); // Operation shows itself the first time a flow has one, unless the person set it already. useEffect(() => { if (prefs.visible.operation !== undefined) return; const next = showOperationFor(prefs, allFlows); if (next !== prefs) setPrefs(next); }, [prefs, allFlows, setPrefs]); const proxyState = useAtomValue(proxyStateAtom); const paused = proxyState?.state === "running" && proxyState.recording === false; const scrollRef = useRef(null); const headerScrollRef = useRef(null); const lastScrollTop = useRef(0); // The list's vertical scrollbar, which the header leaves room for so the two scroll as far const [scrollbarWidth, setScrollbarWidth] = useState(0); // The list's visible height, so a resize that leaves it at its end follows again const [viewportHeight, setViewportHeight] = useState(0); const ignoreScroll = useRef(false); const [following, setFollowing] = useAtom(followAtom); const [heldAt, setHeldAt] = useState(null); /** The first flow after the last catch-up's hold, and how many came after it */ const [divider, setDivider] = useState<{ id: string; count: number } | null>(null); // The newest flow across all traffic, not just this list, so a filter change keeps the hold const newestStartedAt = useAtomValue(newestStartedAtAtom); const ROW_HEIGHT = useMemo(rowHeight, []); // Read by the callbacks below, which stay the same across new traffic so rows don't re-render const latest = useRef({ flows, following, newestStartedAt, heldAt, sort }); useLayoutEffect(() => { latest.current = { flows, following, newestStartedAt, heldAt, sort }; }); // Where the platform cannot tell which app sent a flow, there is no App column. const hasApps = useAtomValue(hasAppsAtom); const columns = useMemo(() => shownColumns(prefs, unavailableColumns(hasApps)), [prefs, hasApps]); const template = useMemo(() => gridTemplate(columns), [columns]); // A row's grid, its padding and the list's margins: `px-1.5` and `left-1.5 right-1.5` const contentWidth = useMemo(() => `calc(${gridWidth(columns)} + 1.5rem)`, [columns]); const arrivals = useArrivals(flows, following && !sorted); const virtualizer = useVirtualizer({ count: flows.length, getScrollElement: () => scrollRef.current, estimateSize: () => ROW_HEIGHT, getItemKey: (index) => flows[index]?.id ?? index, paddingEnd: BOTTOM_PADDING + (paused && flows.length > 0 ? ROW_HEIGHT + 8 : 0), overscan: 40, }); const setScrollTop = useCallback((top: number) => { const el = scrollRef.current; if (el == null) return; const max = el.scrollHeight - el.clientHeight; const next = Math.max(0, Math.min(top, max)); if (Math.abs(el.scrollTop - next) < 1) return; ignoreScroll.current = true; el.scrollTop = next; }, []); const hold = useCallback(() => { if (!latest.current.following) return; setFollowing(false); setHeldAt(latest.current.newestStartedAt); }, [setFollowing]); const follow = useCallback(() => { const { flows, heldAt, following, sort } = latest.current; // Catching up from a hold marks where it began. if (!following && sort.length === 0) { const count = countNewFlows(flows, heldAt); const first = flows[flows.length - count]; setDivider(count > 0 && first != null ? { id: first.id, count } : null); } setSort([]); setFollowing(true); setHeldAt(null); setScrollTop(Number.MAX_SAFE_INTEGER); }, [setFollowing, setScrollTop, setSort]); // A sorted list never follows, whether the sort was just set, kept from the last launch, or // kept through a clear. useLayoutEffect(() => { if (sorted && following) setFollowing(false); }, [sorted, following, setFollowing]); // A held list follows again once it is at its end, not only when scrolled there: a sidebar row, // search or resize can leave it there with no scroll to make, as one that fits its viewport // always is, and its flows would otherwise count as new while all are on screen. Judged when // which flows are listed changes, or the list's height, not when one listed is updated or // `following` itself changes, so selecting a row at the end still holds; and not while a // search's first page loads, as its empty list is at its end only until the page is in. const searchLoading = useAtomValue(searchLoadingAtom); const listed = `${flows.length}:${flows[0]?.id ?? ""}:${flows.at(-1)?.id ?? ""}`; useLayoutEffect(() => { const el = scrollRef.current; if (el == null || searchLoading) return; if (!resumesFollowing({ following: latest.current.following, sorted, el })) return; setFollowing(true); setHeldAt(null); }, [listed, sorted, searchLoading, viewportHeight, setFollowing]); useLayoutEffect(() => { if (following && !sorted) setScrollTop(Number.MAX_SAFE_INTEGER); }, [following, sorted, flows, paused, setScrollTop]); // Sorted, rows that leave, deleted or moved past what is read, can leave the end of the list // in view with no scroll to load the next page; it loads as they go. useEffect(() => { const el = scrollRef.current; if (!sorted || el == null) return; const toEnd = el.scrollHeight - el.scrollTop - el.clientHeight; if (toEnd < LOAD_OLDER_ROWS * ROW_HEIGHT) void loadMoreSorted(); }, [sorted, flows, loadMoreSorted, ROW_HEIGHT]); // A new sort starts from its top. const sortKey = JSON.stringify(sort); useLayoutEffect(() => { if (sortKey !== "[]") setScrollTop(0); }, [sortKey, setScrollTop]); // Older flows that loaded in above push the rows down; scroll by as much, so they stay put. const anchor = useRef(null); useLayoutEffect(() => { const previous = anchor.current; anchor.current = oldestFlow; if (following || sorted || previous == null || oldestFlow == null) return; if (compareFlows(oldestFlow, previous) >= 0) return; const added = flows.findIndex((f) => compareFlows(f, previous) >= 0); const el = scrollRef.current; if (added > 0 && el != null) setScrollTop(el.scrollTop + added * ROW_HEIGHT); }, [oldestFlow, flows, following, sorted, setScrollTop, ROW_HEIGHT]); // Sorted, the flows loaded are what is sorted; older ones would land anywhere. const loadOlderHeld = useCallback(() => { hold(); if (latest.current.sort.length === 0) void loadOlder(); }, [hold, loadOlder]); useEffect(() => { const el = scrollRef.current; if (el == null) return; const measure = () => { setScrollbarWidth(el.offsetWidth - el.clientWidth); setViewportHeight(el.clientHeight); }; measure(); const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); }, []); const handleScroll = useCallback(() => { const el = scrollRef.current; if (el == null) return; if (headerScrollRef.current != null) headerScrollRef.current.scrollLeft = el.scrollLeft; // Sideways only: following and holding go by the vertical position. const top = el.scrollTop; const moved = top !== lastScrollTop.current; lastScrollTop.current = top; if (ignoreScroll.current) { ignoreScroll.current = false; return; } if (!moved) return; const sortedNow = latest.current.sort.length > 0; if (isAtEnd(el) && !sortedNow) { setFollowing(true); setHeldAt(null); } else { hold(); if (el.scrollTop < LOAD_OLDER_ROWS * ROW_HEIGHT && !sortedNow) { void loadOlder(); } } // Sorted, the next page of the sort loads near the bottom. const toEnd = el.scrollHeight - el.scrollTop - el.clientHeight; if (sortedNow && toEnd < LOAD_OLDER_ROWS * ROW_HEIGHT) void loadMoreSorted(); }, [hold, loadOlder, loadMoreSorted, setFollowing, ROW_HEIGHT]); // Focus moving to a sort button out of view scrolls the header itself; the rows follow. const handleHeaderScroll = useCallback(() => { const header = headerScrollRef.current; const el = scrollRef.current; if (header == null || el == null || header.scrollLeft === el.scrollLeft) return; el.scrollLeft = header.scrollLeft; }, []); // The header scrolls the list sideways, as it has no scrollbar of its own. const handleHeaderWheel = useCallback((e: WheelEvent) => { const el = scrollRef.current; if (el == null || e.deltaX === 0) return; el.scrollLeft += e.deltaX; }, []); // A list too short to scroll still loads older flows on scrolling up. const handleWheel = useCallback( (e: WheelEvent) => { if (e.deltaY < 0 && e.currentTarget.scrollTop <= 0) loadOlderHeld(); }, [loadOlderHeld], ); /** Scrolls a row into view, to its middle when `center` and it is out of view */ const scrollToRow = useCallback( (index: number, center = false) => { const el = scrollRef.current; if (el == null) return; const top = index * ROW_HEIGHT; const above = top < el.scrollTop; const below = top + ROW_HEIGHT > el.scrollTop + el.clientHeight; if (center && (above || below)) setScrollTop(top - (el.clientHeight - ROW_HEIGHT) / 2); else if (above) setScrollTop(top); else if (below) setScrollTop(top + ROW_HEIGHT - el.clientHeight); }, [setScrollTop, ROW_HEIGHT], ); // A click is a place of its own to go back to; the arrow keys move the current place along. const select = useCallback( (index: number, move: "add" | "update") => { const flow = latest.current.flows[index]; if (flow == null) return; goToPlace(store, move, { selection: [flow.id], flow }); hold(); scrollToRow(index); }, [store, hold, scrollToRow], ); const clickRow = useCallback((index: number) => select(index, "add"), [select]); // A place gone back to scrolls its flow into view once the list has it. const [reveal, setReveal] = useAtom(revealFlowAtom); const placeSelection = useAtomValue(placeSelectionAtom); useLayoutEffect(() => { if (reveal == null) return; // Selected elsewhere since if (!placeSelection.has(reveal.id)) { setReveal(null); return; } const index = flows.findIndex((f) => f.id === reveal.id); if (index === -1) return; setReveal(null); hold(); scrollToRow(index, true); }, [reveal, placeSelection, flows, hold, scrollToRow, setReveal]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { // ⌘↓ catches up, as the pill does. if (e.metaKey && !e.shiftKey && !e.altKey && e.key === "ArrowDown") { e.preventDefault(); follow(); return; } if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; e.preventDefault(); const index = flows.findIndex((f) => f.id === selectedId); if (index === -1) select(e.key === "ArrowDown" ? 0 : flows.length - 1, "update"); else if (index === 0 && e.key === "ArrowUp") loadOlderHeld(); else { if (sorted && index >= flows.length - 2 && e.key === "ArrowDown") void loadMoreSorted(); select(e.key === "ArrowDown" ? index + 1 : index - 1, "update"); } }, [flows, follow, loadOlderHeld, loadMoreSorted, select, selectedId, sorted], ); const openMenu = useCallback( (index: number, e: MouseEvent) => { const flow = latest.current.flows[index]; if (flow == null) return; e.preventDefault(); select(index, "add"); // The cell under the pointer, for the menu's items on its value const cellEl = (e.target as HTMLElement).closest("[data-column]"); const column = (cellEl?.dataset.column as ColumnId | undefined) ?? null; setMenu({ flow, column, x: e.clientX, y: e.clientY }); }, [select], ); // A click sorts by the column alone; a third goes back to arrival order, following as Reset // Sort does. Option-click adds the column to the sort, reverses it, then takes it out. const handleSort = useCallback( (column: ColumnId, add: boolean) => { const current = latest.current.sort; const next = add ? toggleSortKey(current, column) : nextSort(current, column); if (next.length === 0) { follow(); return; } hold(); setSort(next); }, [follow, hold, setSort], ); const handleSetSort = useCallback( (next: FlowSortKey[]) => { if (next.length === 0) { follow(); return; } hold(); setSort(next); }, [follow, hold, setSort], ); const menuItems = useMemo((): DropdownItem[] => { if (menu == null) return []; // The cell's value first, as it is what was right-clicked const cell = menu.column == null ? [] : cellActions(menu.flow, menu.column); return [ ...(cell.length > 0 ? [...cell, { type: "separator" } as const] : []), ...flowActions(menu.flow), exportShown(), ]; }, [menu, flowActions, cellActions, exportShown]); const newCount = following || sorted ? 0 : countNewFlows(flows, heldAt); const dividerIndex = divider == null ? -1 : flows.findIndex((f) => f.id === divider.id); const totalSize = virtualizer.getTotalSize(); return (
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- scrolls the list sideways */}
{flows.length === 0 ? (
) : (
{virtualizer.getVirtualItems().map((item) => { const flow = flows[item.index]; if (flow == null) return null; return ( ); })} {dividerIndex > 0 && divider != null && (
{divider.count === 1 ? "1 new" : `${divider.count} new`}
)} {paused && }
)}
{newCount > 0 && ( )} setMenu(null)} />
); } /** * When each flow that arrived while following came in, for the tint that fades over * `ARRIVAL_MS`. Only the newest end of the list is checked, back to the first flow already * known, so a new flow costs the same however many are held. */ function useArrivals(flows: Flow[], following: boolean): Map { const seen = useRef<{ known: Set | null; arrivals: Map }>({ known: null, arrivals: new Map(), }); // Worked out in the render the new flows cause, rather than in a second one after it. return useMemo(() => { const s = seen.current; if (s.known == null || !following) { s.known = new Set(flows.map((f) => f.id)); return s.arrivals; } const now = Date.now(); const arrived: string[] = []; for (let i = flows.length - 1; i >= 0; i--) { const flow = flows[i]; if (flow == null || s.known.has(flow.id)) break; s.known.add(flow.id); arrived.push(flow.id); } // Kept to the flows held, which the window trims. if (s.known.size > flows.length * 2) s.known = new Set(flows.map((f) => f.id)); // A burst renews every row in view, so tinting it says nothing and costs a layer a row. if (arrived.length === 0 || arrived.length > ARRIVAL_MAX_BATCH) return s.arrivals; const next = new Map(); for (const [id, at] of s.arrivals) if (now - at < ARRIVAL_MS) next.set(id, at); for (const id of arrived) next.set(id, now); s.arrivals = next; return next; }, [flows, following]); } interface RowProps { flow: Flow; index: number; start: number; height: number; columns: Column[]; template: string; selected: boolean; /** When it arrived while following, for its tint, or null */ arrivedAt: number | null; onSelect: (index: number) => void; onContextMenu: (index: number, e: MouseEvent) => void; } const FlowRow = memo(function FlowRow({ flow, index, start, height, columns, template, selected, arrivedAt, onSelect, onContextMenu, }: RowProps) { const failed = flow.error != null; // Failed or cancelled before any response; after the headers, the body was cut short mid-way. const noResponse = (failed || flow.cancelled) && flow.status == null; const ids = columns.map((c) => c.id); // A failure's reason, or Cancelled, takes the type and size cells together, side by side. const typeAt = ids.indexOf("type"); const spanReason = noResponse && typeAt !== -1 && ids[typeAt + 1] === "size"; const sinceArrival = arrivedAt == null ? null : Date.now() - arrivedAt; return (
{ if (e.button === 0) onSelect(index); }} onContextMenu={(e) => onContextMenu(index, e)} style={{ height, transform: `translateY(${start}px)`, gridTemplateColumns: template, // Continues the fade where it was when a row scrolls back into view. ...(sinceArrival != null && sinceArrival < ARRIVAL_MS ? { animationDelay: `-${sinceArrival}ms` } : {}), }} className={classNames( "absolute top-0 left-1.5 right-1.5 grid items-center px-1.5 rounded-md whitespace-nowrap", "tabular-nums", flow.cancelled && "text-text-subtlest", selected ? "text-text bg-surface-highlight group-focus-within/flows:bg-surface-active" : "hover:bg-surface-highlight/50", sinceArrival != null && sinceArrival < ARRIVAL_MS && !selected && "flow-arrived", )} > {columns.map((c) => { if (spanReason && c.id === "size") return null; return ( // Names the cell's column for its menu, without a box of its own in the row's grid
); })}
); }); /** * One mark per row, by a fixed priority: failed, cancelled, not decrypted, streaming, cut, saved. * Its tooltip lists every state the flow has. Agent reads join it once the engine records them. */ function FlowMark({ flow }: { flow: Flow }) { const open = isOpenStream(flow); const states: string[] = []; if (flow.error != null) states.push(`Failed: ${flow.error}`); if (flow.cancelled) states.push("Cancelled by the app"); if (flow.tunnelled) states.push("Not decrypted"); if (open) states.push("Streaming"); if (flow.responseTruncated) states.push("Body cut at the capture limit"); if (flow.savedRequestId != null) states.push("Saved to Yaak"); const mark = ( {flow.error != null ? ( ) : flow.cancelled ? ( ) : flow.tunnelled ? ( ) : open ? ( ) : flow.responseTruncated ? ( ) : flow.savedRequestId != null ? ( ) : null} ); if (states.length === 0) return mark; return ( {mark} ); } const mono = "font-mono text-xs"; /** Every cell: its padding matching the header's, and its text cut short at its width */ const cell = "min-w-0 px-1.5 overflow-hidden text-ellipsis"; /** A cell with its full text in a tooltip, shown when the cell cuts it short */ function Truncated({ text, children }: { text: string; children: ReactElement }) { return ( {children} ); } /** An undecrypted tunnel still open: written as it opens, done once it closes */ function isOpenTunnel(flow: Flow): boolean { return flow.tunnelled && flow.elapsedMs == null && flow.error == null; } /** A WebSocket, event stream or gRPC call still open: written as it opens, done once it ends */ function isOpenStream(flow: Flow): boolean { return flow.streamKind != null && flow.elapsedMs == null && flow.error == null && !flow.cancelled; } function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: boolean }) { const failed = flow.error != null; const noResponse = (failed || flow.cancelled) && flow.status == null; switch (column) { case "number": return ( {flow.number || ""} ); case "app": { if (flow.clientPath == null) return ; const name = flow.clientName ?? flow.clientPath; // A program outside an app says which folder it ran in, in full. const folder = folderOf(flow) == null ? null : flow.clientCwd; return ( ); } case "method": return ( ); case "host": { const host = hostOf(flow.url); // Cut from the front, so the part that names the service stays. return ( {host} ); } case "path": { // A tunnel's only path is its host; what it carried is unread. if (flow.tunnelled) { return Not decrypted; } const path = pathOf(flow.url); if (flow.streamKind == null) { return ( {path} ); } // A stream's message count rides the end of the path, so it survives the narrow width, // ticking while the stream is open. return ( {path} ); } case "status": return (flow.cancelled || flow.tunnelled) && flow.status == null ? ( -- ) : ( ); case "type": return failed && noResponse ? ( {flow.error} ) : flow.cancelled && noResponse ? ( Cancelled ) : ( {shortContentType(flow.contentType) ?? "--"} ); case "size": return ( {noResponse ? "--" : formatSize(flow.responseSize)} ); case "time": // An open stream or tunnel shows how long it has been open, quietly, as its messages land. if (isOpenStream(flow) || isOpenTunnel(flow)) { const open = Date.now() - Date.parse(utcTimestamp(flow.startedAt)); return ( {Number.isFinite(open) ? formatMillis(Math.max(0, open)) : "--"} ); } return ( = 1000 && !flow.cancelled ? "text-text" : "text-text-subtle", )} > {flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)} ); case "remote": return ( {flow.remoteAddr ?? "--"} ); case "version": return ( {flow.upstreamHttpVersion ?? flow.clientHttpVersion} ); case "query": { const query = flow.tunnelled ? "" : (urlParts(flow.url).query ?? ""); return ( {query} ); } case "started": { const at = new Date(utcTimestamp(flow.startedAt)); if (Number.isNaN(at.getTime())) return ; return ( {clockTime(at)} ); } case "requestSize": return ( {flow.requestSize === 0 ? "--" : formatSize(flow.requestSize)} ); case "latency": return ( {flow.elapsedHeadersMs == null ? "--" : formatMillis(flow.elapsedHeadersMs)} ); case "tls": return ( {tlsVersionOf(flow)} ); case "operation": { const operation = operationOf(flow) ?? ""; return ( {operation} ); } default: { if (!isCustomColumn(column)) return ; const value = customValue(flow, column.slice("field:".length)) ?? ""; return ( {value} ); } } } /** The URL's path as the browser reads it, `/` for none, without the query */ function pathOf(url: string): string { try { return new URL(url).pathname; } catch { return url; } } /** `14:03:27.481`, in local time */ function clockTime(at: Date): string { const two = (n: number) => String(n).padStart(2, "0"); const ms = String(at.getMilliseconds()).padStart(3, "0"); return `${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}.${ms}`; } /** * 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, applied at once, and copying it */ function useCellActions(): (flow: Flow, column: ColumnId) => DropdownItem[] { const store = useStore(); const showToast = useShowToast(); return useCallback( (flow: Flow, column: ColumnId) => { const add = (term: string) => { // A search with `OR` at its top is grouped first, so the term narrows all of it. const text = store.get(appliedFilterAtom).trim(); applyFilter(store, text === "" ? term : `${asOneTerm(text)} ${term}`); }; const include = cellFilterTerm(flow, column, false); const exclude = cellFilterTerm(flow, column, true); const value = cellText(flow, column); const items: DropdownItem[] = []; if (include != null && exclude != null) { items.push( { label: "Filter by This Value", icon: "filter", onSelect: () => add(include) }, { label: "Exclude This Value", icon: "minus_circle", onSelect: () => add(exclude) }, ); } if (value !== "") { items.push({ label: "Copy Value", icon: "copy", onSelect: async () => { try { await navigator.clipboard.writeText(value); showToast({ message: "Copied value" }); } catch (err) { showToast({ message: `Failed to copy value: ${String(err)}`, color: "danger" }); } }, }); } return items; }, [store, showToast], ); } /** A cell's value as text, as it shows it, for copying */ function cellText(flow: Flow, column: ColumnId): string { if (isCustomColumn(column)) return customValue(flow, column.slice("field:".length)) ?? ""; switch (column) { case "number": return flow.number ? String(flow.number) : ""; case "app": return flow.clientName ?? flow.clientPath ?? ""; case "method": return flow.method; case "host": return hostOf(flow.url); case "path": return flow.tunnelled ? "" : pathOf(flow.url); case "query": return flow.tunnelled ? "" : (urlParts(flow.url).query ?? ""); case "status": return flow.status == null ? "" : String(flow.status); case "type": return flow.contentType ?? ""; case "size": return String(flow.responseSize); case "requestSize": return String(flow.requestSize); case "time": return flow.elapsedMs == null ? "" : String(flow.elapsedMs); case "latency": return flow.elapsedHeadersMs == null ? "" : String(flow.elapsedHeadersMs); case "started": { const at = new Date(utcTimestamp(flow.startedAt)); return Number.isNaN(at.getTime()) ? "" : at.toISOString(); } case "remote": return flow.remoteAddr ?? ""; case "version": return flow.upstreamHttpVersion ?? flow.clientHttpVersion; case "tls": return tlsVersionOf(flow); case "operation": return operationOf(flow) ?? ""; } }