+
);
}
diff --git a/apps/yaak-proxy/components/FirstRun.tsx b/apps/yaak-proxy/components/FirstRun.tsx
index a4b8faec4..f6efbc927 100644
--- a/apps/yaak-proxy/components/FirstRun.tsx
+++ b/apps/yaak-proxy/components/FirstRun.tsx
@@ -1,4 +1,11 @@
-import { Button, Icon, InlineCode, LoadingIcon } from "@yaakapp-internal/ui";
+import {
+ Button,
+ CONTROL_TOOLTIP_DELAY,
+ Icon,
+ InlineCode,
+ LoadingIcon,
+ Tooltip,
+} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { ReactNode } from "react";
@@ -127,9 +134,17 @@ export function FirstRun() {
title="What to decrypt"
description={
everything ? (
- Everything
+
+ Everything
+
) : (
- Pick as they appear
+
+ Pick as they appear
+
)
}
action={
diff --git a/apps/yaak-proxy/components/FlowCard.tsx b/apps/yaak-proxy/components/FlowCard.tsx
index 977b04326..6fd906c9c 100644
--- a/apps/yaak-proxy/components/FlowCard.tsx
+++ b/apps/yaak-proxy/components/FlowCard.tsx
@@ -1,19 +1,21 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
import {
+ CONTROL_TOOLTIP_DELAY,
CountBadge,
Dropdown,
EmptyStateText,
type ExtraPaneTab,
+ type HttpBodyResult,
HttpRequestPaneView,
HttpResponsePaneView,
- type HttpBodyResult,
Icon,
IconButton,
parseCookieHeaders,
ResponseCookies,
type ResponseViewMode,
SplitLayout,
+ Tooltip,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useSetAtom } from "jotai";
@@ -181,17 +183,18 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
/** The method and URL, at the status line's height so the two header lines align */
function UrlLine({ flow }: { flow: Flow }) {
return (
-
- {flow.method}
-
- {flow.url}
-
-
+ // The whole URL on hover, when it runs past the card
+
+
+ {flow.method}
+ {flow.url}
+
+
);
}
@@ -295,10 +298,11 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
icon="bookmark"
iconColor={saved ? "primary" : "secondary"}
title={saved ? "Saved to Yaak" : "Save to Yaak"}
+ tooltip
onClick={() => openSave(flow)}
/>
-
+
}
diff --git a/apps/yaak-proxy/components/FlowDetail.tsx b/apps/yaak-proxy/components/FlowDetail.tsx
index 5c31ac522..60fe3199e 100644
--- a/apps/yaak-proxy/components/FlowDetail.tsx
+++ b/apps/yaak-proxy/components/FlowDetail.tsx
@@ -7,8 +7,8 @@ import { FlowCard } from "./FlowCard";
import { NotDecrypted } from "./NotDecrypted";
/**
- * The detail pane: the selected flow's card, or what the pane holds before one is selected. Side
- * by side, a wider gutter separates the card from the list and it sits a little under the header.
+ * The detail pane: the selected flow's card, or what the pane holds before one is selected. The
+ * split's gutter separates it from the list; side by side it sits a little under the header.
*/
export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideBySide: boolean }) {
const flow = useAtomValue(selectedFlowAtom);
@@ -16,7 +16,7 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
return (
(null);
- const { width } = useContainerSize(containerRef);
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);
const ignoreScroll = useRef(false);
const [following, setFollowing] = useAtom(followAtom);
const [heldAt, setHeldAt] = useState(null);
@@ -101,10 +106,12 @@ export function FlowList({ style, className }: Props) {
// Where the platform cannot tell which app sent a flow, there is no App column.
const hasApps = useAtomValue(hasAppsAtom);
const columns = useMemo(
- () => shownColumns(prefs, width).filter((c) => hasApps || c.id !== "app"),
- [prefs, width, hasApps],
+ () => shownColumns(prefs).filter((c) => hasApps || c.id !== "app"),
+ [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 && sort == null);
const virtualizer = useVirtualizer({
@@ -173,13 +180,29 @@ export function FlowList({ style, className }: Props) {
if (latest.current.sort == null) void loadOlder();
}, [hold, loadOlder]);
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (el == null) return;
+ const measure = () => setScrollbarWidth(el.offsetWidth - el.clientWidth);
+ 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;
}
- const el = scrollRef.current;
- if (el == null) return;
+ if (!moved) return;
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 2;
if (atEnd && latest.current.sort == null) {
setFollowing(true);
@@ -192,6 +215,13 @@ export function FlowList({ style, className }: Props) {
}
}, [hold, loadOlder, setFollowing, ROW_HEIGHT]);
+ // 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) => {
@@ -247,12 +277,18 @@ export function FlowList({ style, className }: Props) {
[select],
);
+ // A third click on a column goes back to start time, following as Sort by Start Time does.
const handleSort = useCallback(
(column: ColumnId) => {
+ const next = nextSort(latest.current.sort, column);
+ if (next == null) {
+ follow();
+ return;
+ }
hold();
- setSort((prev) => nextSort(prev, column));
+ setSort(next);
},
- [hold, setSort],
+ [follow, hold, setSort],
);
const newCount = following || sort != null ? 0 : countNewFlows(flows, heldAt);
@@ -261,18 +297,29 @@ export function FlowList({ style, className }: Props) {
return (
-
+ {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- scrolls the list sideways */}
+
{divider.count === 1 ? "1 new" : `${divider.count} new`}
@@ -436,8 +488,8 @@ const FlowRow = memo(function FlowRow({
: {}),
}}
className={classNames(
- "absolute top-0 inset-x-0 grid items-center gap-x-3 px-1.5 rounded-md whitespace-nowrap",
- "[&>*]:overflow-hidden [&>*]:text-ellipsis [&>*]:min-w-0 tabular-nums",
+ "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"
@@ -469,9 +521,8 @@ function FlowMark({ flow }: { flow: Flow }) {
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 title = states.length === 0 ? undefined : states.join("\n");
- return (
-
+ const mark = (
+
{flow.error != null ? (
) : flow.cancelled ? (
@@ -487,10 +538,28 @@ function FlowMark({ flow }: { flow: Flow }) {
) : 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;
@@ -507,18 +576,25 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
switch (column) {
case "number":
return (
-
+
{flow.number || ""}
);
case "app": {
- if (flow.clientPath == null) return ;
- const folder = folderOf(flow);
+ 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":
@@ -526,63 +602,74 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
);
- case "host":
+ case "host": {
+ const host = hostOf(flow.url);
// Cut from the front, so the part that names the service stays.
return (
-
- {hostOf(flow.url)}
-
+
+
+ {host}
+
+
);
+ }
case "path": {
// A tunnel's only path is its host; what it carried is unread.
- if (flow.tunnelled) return Not decrypted;
+ if (flow.tunnelled) {
+ return Not decrypted;
+ }
const { path, query } = splitPath(flow.url);
if (flow.streamKind == null) {
return (
-
- {path}
- {query}
-
+
+
+ {path}
+ {query}
+
+
);
}
// 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}
- {query}
+
+
+
+ {path}
+ {query}
+
+
-
-
+
);
}
case "status":
return (flow.cancelled || flow.tunnelled) && flow.status == null ? (
- --
+ --
) : (
-
+
);
case "type":
return failed && noResponse ? (
-
- {flow.error}
-
+
+
+ {flow.error}
+
+
) : flow.cancelled && noResponse ? (
- Cancelled
+ Cancelled
) : (
-
+
{shortContentType(flow.contentType) ?? "--"}
);
@@ -590,6 +677,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
return (
+
{Number.isFinite(open) ? formatMillis(Math.max(0, open)) : "--"}
);
@@ -611,6 +699,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
return (
{flow.remoteAddr ?? "--"}
+
+ {flow.remoteAddr ?? "--"}
+
);
case "version":
return (
-
+
{flow.upstreamHttpVersion ?? flow.clientHttpVersion}
);
diff --git a/apps/yaak-proxy/components/FlowListEmpty.tsx b/apps/yaak-proxy/components/FlowListEmpty.tsx
index 6c51e596a..8003f045c 100644
--- a/apps/yaak-proxy/components/FlowListEmpty.tsx
+++ b/apps/yaak-proxy/components/FlowListEmpty.tsx
@@ -49,7 +49,7 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) {
diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx
index 45971fe0c..61bcb8847 100644
--- a/apps/yaak-proxy/components/FlowListHeader.tsx
+++ b/apps/yaak-proxy/components/FlowListHeader.tsx
@@ -15,6 +15,7 @@ import {
type ColumnId,
type ColumnPrefs,
type FlowSort,
+ resetWidth,
resetWidths,
resizeColumn,
toggleColumn,
@@ -25,6 +26,8 @@ import { columnPrefsAtom } from "../lib/store";
interface Props {
columns: Column[];
template: string;
+ /** The rows' width, as CSS, so the header scrolls sideways with them */
+ width: string;
prefs: ColumnPrefs;
sort: FlowSort | null;
onSort: (column: ColumnId) => void;
@@ -32,13 +35,15 @@ interface Props {
}
/**
- * The list's only control surface: a click on a label sorts by it and a second reverses it,
- * dragging the edge after a label resizes its column, and the picker sits in the empty corner on
- * hover, or anywhere on a right-click.
+ * The list's only control surface: a click on a label sorts by it, a second reverses it and a
+ * third goes back to start time. Dragging the edge after a label resizes its column and a
+ * double-click on it resets it. The picker sits in the empty corner on hover, or anywhere on a
+ * right-click.
*/
export const FlowListHeader = memo(function FlowListHeader({
columns,
template,
+ width,
prefs,
sort,
onSort,
@@ -82,67 +87,68 @@ export const FlowListHeader = memo(function FlowListHeader({
}, []);
return (
- // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the same menu as the picker
-
-
-
-
-
-
- {columns.map((c) => (
-
-
- {c.id !== "path" && (
+ // The same margins and padding as the rows, so each label sits over its cells.
+
+ {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the same menu as the picker */}
+
,
LoadingIcon: () => LOADING,
+ CONTROL_TOOLTIP_DELAY: 0,
+ Tooltip: ({ children }: { children: ReactNode }) => <>{children}>,
}));
const data = (truncated = false): FlowBodyData => ({
diff --git a/apps/yaak-proxy/components/FlowRequestBody.tsx b/apps/yaak-proxy/components/FlowRequestBody.tsx
index af41d3bc8..162a0adcd 100644
--- a/apps/yaak-proxy/components/FlowRequestBody.tsx
+++ b/apps/yaak-proxy/components/FlowRequestBody.tsx
@@ -1,5 +1,11 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
-import { Banner, EmptyStateText, LoadingIcon } from "@yaakapp-internal/ui";
+import {
+ Banner,
+ CONTROL_TOOLTIP_DELAY,
+ EmptyStateText,
+ LoadingIcon,
+ Tooltip,
+} from "@yaakapp-internal/ui";
import type { HttpBodyResult } from "@yaakapp-internal/ui/httpTypes";
import type { ReactNode } from "react";
import type { FlowBodyData } from "../lib/flowViews";
@@ -39,9 +45,11 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro
return (
-
- First {formatSize(body.data.storedSize)} of {formatSize(requestSize)} kept
-
+
+
+ First {formatSize(body.data.storedSize)} of {formatSize(requestSize)} kept
+
+
{editor}
diff --git a/apps/yaak-proxy/components/FlowTimeline.tsx b/apps/yaak-proxy/components/FlowTimeline.tsx
index a0b0927b4..064baef73 100644
--- a/apps/yaak-proxy/components/FlowTimeline.tsx
+++ b/apps/yaak-proxy/components/FlowTimeline.tsx
@@ -1,10 +1,12 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
+ CONTROL_TOOLTIP_DELAY,
EventViewer,
EventViewerRow,
formatMillis,
Icon,
type IconProps,
+ Tooltip,
} from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { type FlowPhase, flowPhases, type PhaseKind } from "../lib/phases";
@@ -35,12 +37,21 @@ export function FlowTimeline({ flow }: { flow: Flow }) {
onClick={onClick}
icon={}
content={
-
- {event.label}
- {event.detail != null && (
- {` ${event.detail}`}
- )}
-
+ event.detail == null ? (
+ {event.label}
+ ) : (
+
+
+ {event.label}
+ {` ${event.detail}`}
+
+
+ )
}
columns="auto minmax(0,1fr) 6rem 6rem"
cells={{
diff --git a/apps/yaak-proxy/components/NotDecrypted.tsx b/apps/yaak-proxy/components/NotDecrypted.tsx
index 13b28ab3f..b45e4bd8f 100644
--- a/apps/yaak-proxy/components/NotDecrypted.tsx
+++ b/apps/yaak-proxy/components/NotDecrypted.tsx
@@ -1,6 +1,6 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
-import { Button, formatMillis, Icon } from "@yaakapp-internal/ui";
+import { Button, CONTROL_TOOLTIP_DELAY, formatMillis, Icon, Tooltip } from "@yaakapp-internal/ui";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { type DecryptSubject, detailActions, listEdit, setDecrypted } from "../lib/decrypt";
import { sidebarPath } from "../lib/search";
@@ -46,13 +46,23 @@ export function NotDecrypted({ flow }: { flow: Flow }) {
{facts.join(" · ")}
{flow.error != null &&
{flow.error}
}
{actions.listed ? (
-
- Decrypting new connections
-
+
+
Decrypting new connections
+
) : actions.blocked != null ? (
-
- Excluded by {actions.blocked}
-
+
+
+ Excluded by {actions.blocked}
+
+
) : (
{actions.host && (
@@ -60,6 +70,7 @@ export function NotDecrypted({ flow }: { flow: Flow }) {
size="xs"
variant="border"
title={`Decrypt new connections to ${host}`}
+ tooltip
disabled={save.isPending}
onClick={() => decrypt(hostSubject)}
>
@@ -71,6 +82,7 @@ export function NotDecrypted({ flow }: { flow: Flow }) {
size="xs"
variant="border"
title={`Decrypt new connections from ${app}`}
+ tooltip
disabled={save.isPending}
onClick={() => decrypt(appSubject)}
>
diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx
index 95a4b181b..4f4ffd8be 100644
--- a/apps/yaak-proxy/components/ProxyLayout.tsx
+++ b/apps/yaak-proxy/components/ProxyLayout.tsx
@@ -63,13 +63,14 @@ export function ProxyLayout() {
included, falls through to the header, which drags the window. */}