Polish Yaak Proxy's flow list, sidebar and header

Columns keep fixed widths, Path included, and the list scrolls sideways
with the header in step instead of dropping columns or capping Host.
Resize handles sit on every column boundary, a double-click resets one,
and a third click on a sorted header goes back to start time, with empty
values sorting lowest. The search box takes up to 44rem and wraps over
the list while focused, its completions as wide as it. The list/detail
handle fills the gutter. Native title tooltips become Yaak's tooltip,
shared buttons gain an opt-in for it, and folder and program rows in the
sidebar get Reveal in Finder and Copy path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and Stetta task tk_mw9j1flbfooxxx0a committed 2026-11-09 10:45:46 -07:00
1 parent 77226dbedb
commit c78732ecaf
30 files changed
+1032 -421

No files matched your search

@@ -54,6 +54,7 @@ export function ClearFlowsButton() {
className="pointer-events-auto"
icon="trash"
title={locked != null ? "Clear flows (traffic is locked)" : "Clear flows (none yet)"}
tooltip
size="sm"
iconColor="secondary"
disabled
@@ -67,6 +68,7 @@ export function ClearFlowsButton() {
className="pointer-events-auto"
icon="trash"
title="Clear flows"
tooltip
size="sm"
iconColor="secondary"
/>
@@ -1,7 +1,7 @@
import { Prec } from "@codemirror/state";
import { type EditorView, keymap } from "@codemirror/view";
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
import { Icon, IconButton, Input } from "@yaakapp-internal/ui";
import { CONTROL_TOOLTIP_DELAY, Icon, IconButton, Input, Tooltip } from "@yaakapp-internal/ui";
import { filterAutocomplete } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import { useMemo, useRef, useState } from "react";
@@ -71,28 +71,29 @@ function EntryRow({
entry.builtIn && "text-text-subtlest",
)}
>
<Icon
icon={negated ? "lock" : "lock_open"}
size="sm"
className={classNames("shrink-0", entry.builtIn ? "opacity-60" : "text-text-subtle")}
title={negated ? "Excluded" : "Decrypted"}
/>
<Tooltip wrap content={negated ? "Excluded" : "Decrypted"} delay={CONTROL_TOOLTIP_DELAY}>
<Icon
icon={negated ? "lock" : "lock_open"}
size="sm"
className={classNames("shrink-0", entry.builtIn ? "opacity-60" : "text-text-subtle")}
/>
</Tooltip>
<span className={classNames("truncate", entry.entry !== "*" && "font-mono text-xs")}>
{entryLabel(entry.entry)}
</span>
{entry.builtIn && (
<span
title="Ships with Yaak Proxy"
className="shrink-0 text-2xs rounded px-1 border border-border-subtle"
>
built-in
</span>
<Tooltip wrap content="Ships with Yaak Proxy" delay={CONTROL_TOOLTIP_DELAY}>
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
built-in
</span>
</Tooltip>
)}
<IconButton
size="xs"
icon="x"
iconColor="secondary"
title={`Remove ${entryLabel(entry.entry)}`}
tooltip
disabled={disabled}
className="ml-auto opacity-0 group-hover/entry:opacity-100 focus-visible:opacity-100"
onClick={onRemove}
@@ -151,18 +152,20 @@ function EntryInput({
}, [seenRef]);
return (
<div className="grid gap-1" title="Applies to new connections">
<Input
label="Add entry"
hideLabel
size="sm"
stateKey={null}
language={null}
placeholder="Add host, app or *"
extensions={extensions}
onChange={() => setError(null)}
/>
{error != null && <div className="text-xs text-danger">{error}</div>}
</div>
<Tooltip wrap content="Applies to new connections" delay={CONTROL_TOOLTIP_DELAY}>
<div className="grid gap-1">
<Input
label="Add entry"
hideLabel
size="sm"
stateKey={null}
language={null}
placeholder="Add host, app or *"
extensions={extensions}
onChange={() => setError(null)}
/>
{error != null && <div className="text-xs text-danger">{error}</div>}
</div>
</Tooltip>
);
}
+18 -3
View File
@@ -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 ? (
<span title="Except the built-in exclusions">Everything</span>
<Tooltip
wrap
content="Except the built-in exclusions"
delay={CONTROL_TOOLTIP_DELAY}
>
<span>Everything</span>
</Tooltip>
) : (
<span title="From a flow or the sidebar">Pick as they appear</span>
<Tooltip wrap content="From a flow or the sidebar" delay={CONTROL_TOOLTIP_DELAY}>
<span>Pick as they appear</span>
</Tooltip>
)
}
action={
+17 -13
View File
@@ -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 (
<div
className={classNames(
"flex items-center gap-2 min-h-[calc(var(--height-sm)+0.25rem)] -mb-1.5",
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars",
)}
>
<span className="text-text-subtle shrink-0">{flow.method}</span>
<span className="text-text select-text cursor-text" title={flow.url}>
{flow.url}
</span>
</div>
// The whole URL on hover, when it runs past the card
<Tooltip wrap onlyWhenTruncated content={flow.url} delay={CONTROL_TOOLTIP_DELAY}>
<div
className={classNames(
"flex items-center gap-2 min-h-[calc(var(--height-sm)+0.25rem)] -mb-1.5",
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars",
)}
>
<span className="text-text-subtle shrink-0">{flow.method}</span>
<span className="text-text select-text cursor-text">{flow.url}</span>
</div>
</Tooltip>
);
}
@@ -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)}
/>
<Dropdown items={flowActions(flow).slice(2)}>
<IconButton size="sm" icon="ellipsis" iconColor="secondary" title="Actions" />
<IconButton size="sm" icon="ellipsis" iconColor="secondary" title="Actions" tooltip />
</Dropdown>
</div>
}
+3 -3
View File
@@ -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 (
<div
style={style}
className={classNames("min-h-0 min-w-0 pb-2", sideBySide ? "pr-2 pl-3 pt-3" : "px-2")}
className={classNames("min-h-0 min-w-0 pb-2", sideBySide ? "pr-2 pt-3" : "px-2")}
>
<div
className={classNames(
+162 -71
View File
@@ -3,17 +3,18 @@ import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
ContextMenu,
CONTROL_TOOLTIP_DELAY,
CountBadge,
formatMillis,
HttpMethodTagRaw,
HttpStatusTagRaw,
Icon,
useContainerSize,
Tooltip,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import type { CSSProperties, KeyboardEvent, MouseEvent, WheelEvent } from "react";
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, MouseEvent, ReactElement, WheelEvent } from "react";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms";
import { shortContentType } from "../lib/body";
import { utcTimestamp } from "../lib/flowViews";
@@ -21,6 +22,7 @@ import {
type Column,
type ColumnId,
gridTemplate,
gridWidth,
hostOf,
nextSort,
shownColumns,
@@ -66,7 +68,8 @@ interface Props {
/**
* 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 a column sorts the flows loaded and stops following.
* began. Sorting by a column sorts the flows loaded 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);
@@ -81,9 +84,11 @@ export function FlowList({ style, className }: Props) {
const [sort, setSort] = useAtom(flowSortAtom);
const proxyState = useAtomValue(proxyStateAtom);
const paused = proxyState?.state === "running" && proxyState.recording === false;
const containerRef = useRef<HTMLDivElement>(null);
const { width } = useContainerSize(containerRef);
const scrollRef = useRef<HTMLDivElement>(null);
const headerScrollRef = useRef<HTMLDivElement>(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<string | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
@@ -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 (
<div
ref={containerRef}
style={style}
className={classNames(className, "grid grid-rows-[auto_minmax(0,1fr)] min-h-0 text-sm")}
className={classNames(
className,
"grid grid-rows-[auto_minmax(0,1fr)] grid-cols-[minmax(0,1fr)] min-h-0 text-sm",
)}
>
<FlowListHeader
columns={columns}
template={template}
prefs={prefs}
sort={sort}
onSort={handleSort}
onSortByStart={follow}
/>
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- scrolls the list sideways */}
<div
ref={headerScrollRef}
onWheel={handleHeaderWheel}
style={{ marginRight: scrollbarWidth }}
className="overflow-hidden"
>
<FlowListHeader
columns={columns}
template={template}
width={contentWidth}
prefs={prefs}
sort={sort}
onSort={handleSort}
onSortByStart={follow}
/>
</div>
<div className="relative min-h-0">
<div
ref={scrollRef}
@@ -282,12 +329,17 @@ export function FlowList({ style, className }: Props) {
onScroll={handleScroll}
onWheel={handleWheel}
onKeyDown={handleKeyDown}
className="group/flows h-full overflow-y-auto px-1.5 outline-hidden"
className="group/flows h-full overflow-auto outline-hidden"
>
{flows.length === 0 ? (
<FlowListEmpty />
<div className="h-full px-1.5">
<FlowListEmpty />
</div>
) : (
<div className="relative" style={{ height: totalSize }}>
<div
className="relative"
style={{ height: totalSize, width: contentWidth, minWidth: "100%" }}
>
{virtualizer.getVirtualItems().map((item) => {
const flow = flows[item.index];
if (flow == null) return null;
@@ -311,7 +363,7 @@ export function FlowList({ style, className }: Props) {
<div
aria-hidden
style={{ top: dividerIndex * ROW_HEIGHT }}
className="pointer-events-none absolute inset-x-0 h-0 border-t border-border"
className="pointer-events-none absolute left-1.5 right-1.5 h-0 border-t border-border"
>
<span className="absolute -top-2 left-1 px-1 bg-surface text-2xs text-primary">
{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 (
<span className="flex items-center justify-center" title={title}>
const mark = (
<span className="flex items-center justify-center">
{flow.error != null ? (
<Icon icon="circle_alert" size="sm" color="danger" />
) : flow.cancelled ? (
@@ -487,10 +538,28 @@ function FlowMark({ flow }: { flow: Flow }) {
) : null}
</span>
);
if (states.length === 0) return mark;
return (
<Tooltip wrap content={states.join("\n")} delay={CONTROL_TOOLTIP_DELAY}>
{mark}
</Tooltip>
);
}
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 (
<Tooltip wrap onlyWhenTruncated content={text} delay={CONTROL_TOOLTIP_DELAY}>
{children}
</Tooltip>
);
}
/** 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 (
<span className={classNames(mono, "text-right text-text-subtlest")}>
<span className={classNames(cell, mono, "text-right text-text-subtlest")}>
{flow.number || ""}
</span>
);
case "app": {
if (flow.clientPath == null) return <span />;
const folder = folderOf(flow);
if (flow.clientPath == null) return <span className={cell} />;
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 (
<span className="flex items-center" title={folder == null ? name : `${name} in ${folder}`}>
<AppIcon path={flow.clientPath} />
</span>
<Tooltip
wrap
content={folder == null ? name : `${name}\n${folder}`}
delay={CONTROL_TOOLTIP_DELAY}
>
<span className={classNames(cell, "flex items-center")}>
<AppIcon path={flow.clientPath} />
</span>
</Tooltip>
);
}
case "method":
@@ -526,63 +602,74 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
<HttpMethodTagRaw
method={flow.method}
short
className={classNames(mono, !flow.cancelled && "text-text-subtle")}
className={classNames(cell, mono, !flow.cancelled && "text-text-subtle")}
/>
);
case "host":
case "host": {
const host = hostOf(flow.url);
// Cut from the front, so the part that names the service stays.
return (
<span className={classNames("rtl text-left", !flow.cancelled && "text-text-subtle")}>
<bdi>{hostOf(flow.url)}</bdi>
</span>
<Truncated text={host}>
<span
className={classNames(cell, "rtl text-left", !flow.cancelled && "text-text-subtle")}
>
<bdi>{host}</bdi>
</span>
</Truncated>
);
}
case "path": {
// A tunnel's only path is its host; what it carried is unread.
if (flow.tunnelled) return <span className="text-text-subtlest">Not decrypted</span>;
if (flow.tunnelled) {
return <span className={classNames(cell, "text-text-subtlest")}>Not decrypted</span>;
}
const { path, query } = splitPath(flow.url);
if (flow.streamKind == null) {
return (
<span>
{path}
<span className="text-text-subtlest">{query}</span>
</span>
<Truncated text={path + query}>
<span className={cell}>
{path}
<span className="text-text-subtlest">{query}</span>
</span>
</Truncated>
);
}
// A stream's message count rides the end of the path, so it survives the narrow width,
// ticking while the stream is open.
return (
<span className="flex items-center">
<span className="truncate">
{path}
<span className="text-text-subtlest">{query}</span>
<Truncated text={path + query}>
<span className={classNames(cell, "flex items-center")}>
<span className="truncate">
{path}
<span className="text-text-subtlest">{query}</span>
</span>
<CountBadge
count={flow.messageCount}
showZero
color={isOpenStream(flow) ? "info" : undefined}
className="shrink-0 !mb-0 tabular-nums"
/>
</span>
<CountBadge
count={flow.messageCount}
showZero
color={isOpenStream(flow) ? "info" : undefined}
className="shrink-0 !mb-0 tabular-nums"
/>
</span>
</Truncated>
);
}
case "status":
return (flow.cancelled || flow.tunnelled) && flow.status == null ? (
<span className={classNames(mono, "text-text-subtlest")}>--</span>
<span className={classNames(cell, mono, "text-text-subtlest")}>--</span>
) : (
<HttpStatusTagRaw status={flow.status ?? 0} short className={mono} />
<HttpStatusTagRaw status={flow.status ?? 0} short className={classNames(cell, mono)} />
);
case "type":
return failed && noResponse ? (
<span
className={classNames("text-danger text-xs", span && "col-span-2")}
title={flow.error ?? undefined}
>
{flow.error}
</span>
<Tooltip wrap content={flow.error ?? ""} delay={CONTROL_TOOLTIP_DELAY}>
<span className={classNames(cell, "text-danger text-xs", span && "col-span-2")}>
{flow.error}
</span>
</Tooltip>
) : flow.cancelled && noResponse ? (
<span className={classNames("text-xs", span && "col-span-2")}>Cancelled</span>
<span className={classNames(cell, "text-xs", span && "col-span-2")}>Cancelled</span>
) : (
<span className={classNames(mono, "text-text-subtlest")}>
<span className={classNames(cell, mono, "text-text-subtlest")}>
{shortContentType(flow.contentType) ?? "--"}
</span>
);
@@ -590,6 +677,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
return (
<span
className={classNames(
cell,
mono,
"text-right",
flow.responseTruncated ? "text-notice" : "text-text-subtle",
@@ -603,7 +691,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
if (isOpenStream(flow) || isOpenTunnel(flow)) {
const open = Date.now() - Date.parse(utcTimestamp(flow.startedAt));
return (
<span className={classNames(mono, "text-right text-text-subtlest")}>
<span className={classNames(cell, mono, "text-right text-text-subtlest")}>
{Number.isFinite(open) ? formatMillis(Math.max(0, open)) : "--"}
</span>
);
@@ -611,6 +699,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
return (
<span
className={classNames(
cell,
mono,
"text-right",
// A second or more stands out without a new colour.
@@ -624,11 +713,13 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
);
case "remote":
return (
<span className={classNames(mono, "text-text-subtle")}>{flow.remoteAddr ?? "--"}</span>
<span className={classNames(cell, mono, "text-text-subtle")}>
{flow.remoteAddr ?? "--"}
</span>
);
case "version":
return (
<span className={classNames(mono, "text-text-subtle")}>
<span className={classNames(cell, mono, "text-text-subtle")}>
{flow.upstreamHttpVersion ?? flow.clientHttpVersion}
</span>
);
+1 -1
View File
@@ -49,7 +49,7 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) {
<div
style={style}
className={classNames(
"absolute inset-x-0 flex items-center gap-2 h-xs px-3 mt-1",
"absolute left-1.5 right-1.5 flex items-center gap-2 h-xs px-3 mt-1",
"rounded-md border border-dashed border-border-subtle text-text-subtle text-sm",
)}
>
+60 -54
View File
@@ -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
<div
onContextMenu={openMenu}
style={{ gridTemplateColumns: template }}
className={classNames(
"group/header grid items-center gap-x-3 h-xs px-1.5 mx-1.5 select-none",
"text-xs text-text-subtlest border-b border-border-subtle",
)}
>
<span className="flex items-center justify-center">
<Dropdown items={items}>
<IconButton
size="xs"
icon="settings_2"
title="Columns"
iconColor="secondary"
className="opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 !h-5 !w-5"
/>
</Dropdown>
</span>
{columns.map((c) => (
<span key={c.id} className="relative h-full flex items-center min-w-0">
<button
type="button"
onClick={() => onSort(c.id)}
// No tooltip: the header sits under the search field, and a tooltip here covered it
aria-label={`Sort by ${c.label}`}
className={classNames(
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap hover:text-text-subtle",
c.align === "right" && "justify-end",
sort?.column === c.id && "text-text-subtle",
)}
>
{sort?.column === c.id && (
<Icon
icon="chevron_down"
size="xs"
className={classNames("shrink-0", !sort.descending && "rotate-180")}
/>
)}
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
</button>
{c.id !== "path" && (
// The same margins and padding as the rows, so each label sits over its cells.
<div style={{ width, minWidth: "100%" }} className="px-1.5">
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the same menu as the picker */}
<div
onContextMenu={openMenu}
style={{ gridTemplateColumns: template }}
className={classNames(
"group/header grid items-center h-xs px-1.5 select-none",
"text-xs text-text-subtlest border-b border-border-subtle",
)}
>
<span className="flex items-center justify-center">
<Dropdown items={items}>
<IconButton
size="xs"
icon="settings_2"
title="Columns"
tooltip
iconColor="secondary"
className="opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 !h-5 !w-5"
/>
</Dropdown>
</span>
{columns.map((c) => (
<span key={c.id} className="relative h-full flex items-center min-w-0 px-1.5">
<button
type="button"
onClick={() => onSort(c.id)}
// No tooltip: the header sits under the search field, and a tooltip here covered it
aria-label={`Sort by ${c.label}`}
className={classNames(
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap hover:text-text-subtle",
c.align === "right" && "justify-end",
sort?.column === c.id && "text-text-subtle",
)}
>
{sort?.column === c.id && (
<Icon
icon="chevron_down"
size="xs"
className={classNames("shrink-0", !sort.descending && "rotate-180")}
/>
)}
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
</button>
{/* On the column's right edge, the boundary with the next, resizing this one */}
<ResizeHandle
side="right"
justify="center"
className="absolute top-0 -right-[0.45rem] opacity-0 group-hover/header:opacity-100"
className="absolute top-0 translate-x-1/2"
onResizeStart={() => {
startWidth.current = c.current;
}}
onResizeMove={(e) =>
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + e.x - e.xStart))
}
onReset={() =>
setPrefs((p) => ({ ...p, widths: { ...p.widths, [c.id]: undefined } }))
}
onReset={() => setPrefs((p) => resetWidth(p, c.id))}
/>
)}
</span>
))}
<ContextMenu triggerPosition={menu} items={items} onClose={() => setMenu(null)} />
</span>
))}
<ContextMenu triggerPosition={menu} items={items} onClose={() => setMenu(null)} />
</div>
</div>
);
});
@@ -11,6 +11,8 @@ vi.mock("@yaakapp-internal/ui", () => ({
),
EmptyStateText: ({ children }: { children: ReactNode }) => <div>{children}</div>,
LoadingIcon: () => <span>LOADING</span>,
CONTROL_TOOLTIP_DELAY: 0,
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
const data = (truncated = false): FlowBodyData => ({
+12 -4
View File
@@ -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 (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<Banner color="notice" className="text-sm mb-2">
<span title="The app sent all of it">
First {formatSize(body.data.storedSize)} of {formatSize(requestSize)} kept
</span>
<Tooltip wrap content="The app sent all of it" delay={CONTROL_TOOLTIP_DELAY}>
<span>
First {formatSize(body.data.storedSize)} of {formatSize(requestSize)} kept
</span>
</Tooltip>
</Banner>
<div className="min-h-0">{editor}</div>
</div>
+17 -6
View File
@@ -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={<Icon icon={icon} color={color} size="sm" />}
content={
<span title={event.detail}>
{event.label}
{event.detail != null && (
<span className="text-text-subtlest">{` ${event.detail}`}</span>
)}
</span>
event.detail == null ? (
<span>{event.label}</span>
) : (
<Tooltip
wrap
onlyWhenTruncated
content={event.detail}
delay={CONTROL_TOOLTIP_DELAY}
>
<span>
{event.label}
<span className="text-text-subtlest">{` ${event.detail}`}</span>
</span>
</Tooltip>
)
}
columns="auto minmax(0,1fr) 6rem 6rem"
cells={{
+19 -7
View File
@@ -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 }) {
<div className="font-mono text-xs">{facts.join(" · ")}</div>
{flow.error != null && <div className="text-danger">{flow.error}</div>}
{actions.listed ? (
<div title="Still tunnelled? The app may pin its certificate">
Decrypting new connections
</div>
<Tooltip
wrap
content="Still tunnelled? The app may pin its certificate"
delay={CONTROL_TOOLTIP_DELAY}
>
<div>Decrypting new connections</div>
</Tooltip>
) : actions.blocked != null ? (
<div title="Remove it from the decrypt list to decrypt">
Excluded by <span className="font-mono text-xs">{actions.blocked}</span>
</div>
<Tooltip
wrap
content="Remove it from the decrypt list to decrypt"
delay={CONTROL_TOOLTIP_DELAY}
>
<div>
Excluded by <span className="font-mono text-xs">{actions.blocked}</span>
</div>
</Tooltip>
) : (
<div className="flex gap-2 mt-1">
{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)}
>
+7 -2
View File
@@ -63,13 +63,14 @@ export function ProxyLayout() {
included, falls through to the header, which drags the window. */}
<div
data-tauri-drag-region
className="grid grid-cols-[minmax(auto,1fr)_minmax(0,26rem)_minmax(auto,1fr)] gap-2 items-center w-full h-full pointer-events-none"
className="grid grid-cols-[minmax(auto,1fr)_minmax(0,44rem)_minmax(auto,1fr)] gap-2 items-center w-full h-full pointer-events-none"
>
<div data-tauri-drag-region className="flex items-center gap-1 pl-1 whitespace-nowrap">
<IconButton
className="pointer-events-auto"
size="sm"
title="Toggle sidebar"
tooltip
icon={isHidden ? "left_panel_hidden" : "left_panel_visible"}
iconColor="secondary"
onClick={() => {
@@ -93,6 +94,7 @@ export function ProxyLayout() {
className="pointer-events-auto"
icon={flowLayout === "horizontal" ? "columns_2" : "rows_2"}
title={`Change to ${flowLayout === "horizontal" ? "vertical" : "horizontal"} layout`}
tooltip
size="sm"
iconColor="secondary"
onClick={() =>
@@ -103,6 +105,7 @@ export function ProxyLayout() {
className="pointer-events-auto"
icon="settings"
title="Settings"
tooltip
size="sm"
iconColor="secondary"
onClick={() => openSettings()}
@@ -139,6 +142,7 @@ export function ProxyLayout() {
<IconButton
size="sm"
title="Toggle sidebar"
tooltip
icon="left_panel_visible"
iconColor="secondary"
onClick={() => setFloatingSidebarHidden(true)}
@@ -188,7 +192,8 @@ function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
return (
<SplitLayout
storageKey="proxy_detail"
className="gap-1"
// The card sits a wider gutter from the list side by side; the handle fills the gutter.
gutter={flowLayout === "vertical" ? 8 : 20}
layout={flowLayout}
defaultRatio={0.55}
firstSlot={({ style }) => <FlowList style={style} className="pt-1" />}
+10 -5
View File
@@ -6,11 +6,13 @@ import type {
} from "@yaakapp-internal/proxy-lib";
import {
Banner,
CONTROL_TOOLTIP_DELAY,
Dialog,
DialogFooter,
HttpMethodTagRaw,
LoadingIcon,
PlainInput,
Tooltip,
} from "@yaakapp-internal/ui";
import { unnamedRequestName } from "@yaakapp-internal/ui/resolvedModelName";
import { openUrl } from "@tauri-apps/plugin-opener";
@@ -181,13 +183,16 @@ function SaveForm({
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{p.droppedHeaders.map((h) => (
<span
<Tooltip
key={h.name}
title={`${h.name}: ${h.value}\n${reasonLabel(h.reason)}`}
className="font-mono text-xs leading-[22px] px-1.5 rounded-sm border border-border-subtle text-text-subtle line-through decoration-text-subtlest"
wrap
content={`${h.name}: ${h.value}\n${reasonLabel(h.reason)}`}
delay={CONTROL_TOOLTIP_DELAY}
>
{h.name}
</span>
<span className="font-mono text-xs leading-[22px] px-1.5 rounded-sm border border-border-subtle text-text-subtle line-through decoration-text-subtlest">
{h.name}
</span>
</Tooltip>
))}
</div>
</div>
+201 -63
View File
@@ -1,7 +1,7 @@
import { startCompletion } from "@codemirror/autocomplete";
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
import type { Extension } from "@codemirror/state";
import { Compartment } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { EditorView, tooltips, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import {
Dropdown,
Icon,
@@ -11,8 +11,17 @@ import {
LazyCodeEditor,
} from "@yaakapp-internal/ui";
import { queryFilter } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { memo, useEffect, useMemo, useRef } from "react";
import {
type CSSProperties,
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { loadSeenValues, savedCompletions, searchProblems } from "../lib/searchBox";
import {
@@ -34,7 +43,8 @@ const SEARCH_DEBOUNCE_MS = 200;
* the URL, method, status and content type, and `@field:value` terms the rest, highlighted and
* completed as Yaak's sidebar filter is, with the values the proxy has seen. The backend runs
* it and marks what it cannot. Its placeholder counts the flows. ⌘F focuses it and Escape
* clears it.
* clears it. Focused, it wraps a long query over as many lines as it needs, as Yaak's URL bar
* does, over what is below; blurred, it is one line again.
*/
export const SearchBar = memo(function SearchBar() {
const [text, setText] = useAtom(searchTextAtom);
@@ -48,6 +58,24 @@ export const SearchBar = memo(function SearchBar() {
const locked = useAtomValue(lockedAtom) != null;
const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema;
const handleRef = useRef<InputHandle | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
const [focused, setFocused] = useState(false);
// Where the box is while focused, so it grows over the list from the header's place for it
const [place, setPlace] = useState<CSSProperties | null>(null);
useLayoutEffect(() => {
if (!focused) {
setPlace(null);
return;
}
const measure = () => {
const rect = boxRef.current?.getBoundingClientRect();
if (rect != null) setPlace({ top: rect.top, left: rect.left, width: rect.width });
};
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [focused]);
useEffect(() => {
const timer = setTimeout(() => setTerms(text), SEARCH_DEBOUNCE_MS);
@@ -81,7 +109,7 @@ export const SearchBar = memo(function SearchBar() {
}),
[schema, store],
);
const extensions = useMemo(() => compartment.current.of(filter), [filter]);
const extensions = useMemo(() => [compartment.current.of(filter), completionAcrossBox], [filter]);
useEffect(() => {
handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) });
}, [filter]);
@@ -93,67 +121,177 @@ export const SearchBar = memo(function SearchBar() {
};
return (
<Input
renderEditor={(props) => (
<LazyCodeEditor {...props} keyDownHandler={startCompletionOnCtrlSpace} />
)}
setRef={(h) => {
handleRef.current = h;
}}
label="Search flows"
hideLabel
size="sm"
className="font-mono text-xs"
stateKey={null}
language={null}
wrapLines={false}
placeholder={locked ? "Traffic is locked" : placeholderFor(total)}
disabled={locked}
defaultValue={text}
forceUpdateKey={String(version)}
onChange={setText}
extensions={extensions}
onKeyDown={(e) => {
if (e.key === "Escape" && handleRef.current?.value() !== "") {
e.stopPropagation();
clear();
}
}}
leftSlot={
<Icon icon="search" size="sm" className="ml-2 mr-1 self-center text-text-subtlest" />
}
rightSlot={
text === "" ? (
<kbd className="self-center ml-2 mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
⌘F
</kbd>
) : (
<div className="flex items-center self-center ml-1 mr-0.5">
<Dropdown
items={[
{
label: "Save search",
leftSlot: <Icon icon="bookmark" />,
onSelect: () => setSavedDialog({ mode: "save", query: text.trim() }),
},
]}
>
<IconButton size="xs" icon="ellipsis" title="Search actions" iconColor="secondary" />
</Dropdown>
<IconButton
size="xs"
icon="x"
title="Clear search"
iconColor="secondary"
onClick={clear}
/>
</div>
)
}
/>
// Holds the header's place for the box, which floats over the list while focused
<div ref={boxRef} className="relative h-sm">
<div
style={place ?? undefined}
className={classNames(place == null ? "absolute inset-x-0 top-0" : "fixed z-30")}
>
<Input
renderEditor={(props) => (
<LazyCodeEditor {...props} keyDownHandler={startCompletionOnCtrlSpace} />
)}
setRef={(h) => {
handleRef.current = h;
}}
label="Search flows"
hideLabel
size="sm"
className="font-mono text-xs"
containerClassName={classNames(focused && "bg-surface shadow-lg")}
stateKey={null}
language={null}
wrapLines={focused}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={locked ? "Traffic is locked" : placeholderFor(total)}
disabled={locked}
defaultValue={text}
forceUpdateKey={String(version)}
onChange={setText}
extensions={extensions}
onKeyDown={(e) => {
if (e.key === "Escape" && handleRef.current?.value() !== "") {
e.stopPropagation();
clear();
}
}}
leftSlot={
<Icon icon="search" size="sm" className="ml-2 mr-1 self-center text-text-subtlest" />
}
rightSlot={
text === "" ? (
<kbd className="self-center ml-2 mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
⌘F
</kbd>
) : (
<div className="flex items-center self-center ml-1 mr-0.5">
<Dropdown
items={[
{
label: "Save search",
leftSlot: <Icon icon="bookmark" />,
onSelect: () => setSavedDialog({ mode: "save", query: text.trim() }),
},
]}
>
<IconButton
size="xs"
icon="ellipsis"
title="Search actions"
tooltip
iconColor="secondary"
/>
</Dropdown>
<IconButton
size="xs"
icon="x"
title="Clear search"
tooltip
iconColor="secondary"
onClick={clear}
/>
</div>
)
}
/>
</div>
</div>
);
});
/** The search box's width, which its completions take */
const BOX_WIDTH_VAR = "--proxy-search-width";
/** The search box an editor is in */
function boxOf(view: EditorView): HTMLElement | null {
return view.dom.closest<HTMLElement>(".x-theme-input");
}
/** Pixels between a completion's info and the window's edge */
const INFO_MARGIN = 8;
/**
* Where a completion's info goes: beside the list, in the window's room either side of the box,
* or, with none, under the option as CodeMirror puts it in a narrow space
*/
function positionInfo(
view: EditorView,
list: { left: number; right: number; top: number; bottom: number },
option: { top: number; bottom: number },
info: { left: number; right: number; top: number; bottom: number },
): { style: string; class: string } {
const win = view.dom.ownerDocument.defaultView ?? window;
const width = info.right - info.left;
const height = info.bottom - info.top;
const right = win.innerWidth - list.right - INFO_MARGIN;
const left = list.left - INFO_MARGIN;
if (Math.max(left, right) >= width) {
const top = Math.max(0, Math.min(option.top, win.innerHeight - height)) - list.top;
const onRight = right >= width;
return {
style: `top: ${top}px; max-width: ${onRight ? right : left}px`,
class: onRight ? "cm-completionInfo-right" : "cm-completionInfo-left",
};
}
return {
style: `top: ${option.bottom - list.top}px; max-width: 400px`,
class: "cm-completionInfo-right-narrow",
};
}
/**
* The completions under the search box, as wide as it and level with its left edge, rather
* than a narrow list wherever the cursor is
*/
const completionAcrossBox: Extension = [
autocompletion({ positionInfo }),
tooltips({
tooltipSpace: (view) => {
const win = view.dom.ownerDocument.defaultView ?? window;
const rect = boxOf(view)?.getBoundingClientRect();
return {
top: 0,
bottom: win.innerHeight,
left: rect?.left ?? 0,
right: rect?.right ?? win.innerWidth,
};
},
}),
ViewPlugin.fromClass(
class {
observer: ResizeObserver | null = null;
constructor(view: EditorView) {
this.watch(view);
}
// The editor is put in the page after it is made, so the box may only be found later.
update(update: ViewUpdate) {
if (this.observer == null) this.watch(update.view);
}
watch(view: EditorView) {
const box = boxOf(view);
if (box == null) return;
const root = view.dom.ownerDocument.documentElement;
this.observer = new ResizeObserver(() => {
root.style.setProperty(BOX_WIDTH_VAR, `${box.getBoundingClientRect().width}px`);
});
this.observer.observe(box);
}
destroy() {
this.observer?.disconnect();
}
},
),
EditorView.theme({
".cm-tooltip.cm-tooltip-autocomplete": { width: `var(${BOX_WIDTH_VAR})` },
".cm-tooltip.cm-tooltip-autocomplete > ul": { minWidth: "0", maxWidth: "none" },
}),
];
/**
* Ctrl+Space lists what can be typed, fields and saved searches on an empty spot, as it does in
* Yaak's editors, whose shared editor leaves the key to each app
+49 -4
View File
@@ -1,8 +1,10 @@
import {
ContextMenu,
CONTROL_TOOLTIP_DELAY,
type DropdownItem,
Icon,
IconButton,
Tooltip,
Tree,
type TreeProps,
} from "@yaakapp-internal/ui";
@@ -14,9 +16,11 @@ import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { SIDEBAR_TREE_ID } from "../lib/atoms";
import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt";
import { revealLabel, revealPath } from "../lib/opener";
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree";
import { hideSystemAtom, noteSidebarInput, sidebarTreeAtom } from "../lib/store";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { AppIcon } from "./AppIcon";
import { savedSearchDialogAtom } from "./SavedSearchDialog";
@@ -68,7 +72,14 @@ function ItemInner({ item }: { item: SidebarItem }) {
{item.kind === "saved" && <Icon icon="bookmark" size="sm" className="text-text-subtle" />}
{item.kind === "folder" && <Icon icon="folder" size="sm" className="text-text-subtle" />}
{item.appPath != null && <AppIcon path={item.appPath} />}
<span className="truncate">{item.label}</span>
{item.kind === "folder" && item.path != null ? (
// Named by its last part; the whole path on hover
<Tooltip wrap content={item.path} delay={CONTROL_TOOLTIP_DELAY}>
<span className="truncate">{item.label}</span>
</Tooltip>
) : (
<span className="truncate">{item.label}</span>
)}
{item.sublabel != null && (
<span className="truncate text-xs text-text-subtlest">{item.sublabel}</span>
)}
@@ -89,7 +100,9 @@ function DecryptedMark({ item }: { item: SidebarItem }) {
const list = useDecryptList();
if (item.decrypt == null || !isDecrypted(list, item.decrypt)) return null;
return (
<Icon icon="lock_open" size="xs" title="Decrypted" className="shrink-0 text-text-subtlest" />
<Tooltip wrap content="Decrypted" delay={CONTROL_TOOLTIP_DELAY}>
<Icon icon="lock_open" size="xs" className="shrink-0 text-text-subtlest" />
</Tooltip>
);
}
@@ -107,6 +120,7 @@ function HideSystemToggle() {
icon={hidden ? "eye_closed" : "eye"}
iconColor="secondary"
title={hidden ? "Show System in All traffic" : "Hide System from All traffic"}
tooltip
className={classNames(
"!h-4 !w-4 mr-1",
!hidden && "opacity-0 [.tree-item:hover_&]:opacity-100 focus-visible:opacity-100",
@@ -135,10 +149,39 @@ export function Sidebar() {
onError: (err) => showToast({ id: "saved-search", message: err.message, color: "danger" }),
});
// A program's folder, or the program, is shown in Finder, and a folder's path copied.
const fail = useCallback(
(message: string) => showToast({ id: "sidebar-path", message, color: "danger" }),
[showToast],
);
const pathItems = useCallback(
(item: SidebarItem | undefined): DropdownItem[] => {
const path = item?.path;
if (item == null || path == null) return [];
return [
{
label: revealLabel(getOsType()),
leftSlot: <Icon icon="folder_open" />,
onSelect: () => void revealPath(path, fail),
},
{
label: "Copy path",
leftSlot: <Icon icon="copy" />,
hidden: item.kind !== "folder",
onSelect: () => {
navigator.clipboard.writeText(path).catch((err) => fail(String(err)));
},
},
];
},
[fail],
);
// Decrypt or Stop Decrypting the selected hosts, sites and apps, from new connections on.
// A saved search is renamed or deleted.
const getContextMenu = useCallback(
(items: SidebarItem[]): DropdownItem[] => {
const paths = items.length === 1 ? pathItems(items[0]) : [];
const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }]));
if (saved.length > 0) {
const [first] = saved;
@@ -164,8 +207,10 @@ export function Sidebar() {
}
const subjects = items.flatMap((i) => (i.decrypt == null ? [] : [i.decrypt]));
const action = decryptMenuAction(decryptList, subjects);
if (action == null) return [];
if (action == null) return paths;
return [
...paths,
...(paths.length > 0 ? [{ type: "separator" } as const] : []),
{
label: action.label,
icon: action.on ? "lock_open" : "lock",
@@ -187,7 +232,7 @@ export function Sidebar() {
},
];
},
[decryptList, saveDecryptList, showToast, setSavedDialog, deleteSaved],
[decryptList, saveDecryptList, showToast, setSavedDialog, deleteSaved, pathItems],
);
const renderContextMenu = useCallback<NonNullable<TreeProps<SidebarItem>["renderContextMenu"]>>(
({ items, position, onClose }) =>
+70 -3
View File
@@ -1,4 +1,4 @@
import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib";
import type { Flow, GetProxyStateResponse } from "@yaakapp-internal/proxy-lib";
import { fixtures } from "./fixtures";
/**
@@ -42,7 +42,9 @@ function answer(cmd: string, payload: unknown): unknown {
const req = (payload ?? {}) as Record<string, unknown>;
switch (cmd) {
case "get_flow_body": {
const fixture = fixtures.find((f) => f.flow.id === req.flowId);
// A copy from `?many` reads its original's body
const id = String(req.flowId).split("~")[0];
const fixture = fixtures.find((f) => f.flow.id === id);
if (fixture == null) throw new Error(`No fixture for ${String(req.flowId)}`);
return req.which === "request" ? fixture.requestBody : fixture.responseBody;
}
@@ -86,12 +88,77 @@ function answer(cmd: string, payload: unknown): unknown {
case "list_actions":
return { actions: [] };
case "list_flows": {
const flows = fixtures.map((f, i) => ({ ...f.flow, number: 1401 + i })).reverse();
const many = Number(new URLSearchParams(location.search).get("many") ?? 0);
const flows = (many > 0 ? manyFlows(many) : fixtures.map((f) => f.flow))
.map((f, i) => ({ ...f, number: 1401 + i }))
.reverse();
return { flows, total: flows.length, matching: null };
}
case "list_seen":
return { hosts: [], apps: [] };
// A few of the engine's fields, so the search box highlights and completes
case "query_schema":
return {
schema: {
fields: [
{
name: "host",
kind: { type: "text" },
info: "The request's host",
example: "@host:api.example.com",
},
{
name: "method",
kind: { type: "text" },
info: "GET, POST, …",
example: "@method:post",
},
{
name: "status",
kind: { type: "number", units: [] },
info: "The response's status",
example: "@status:>=400",
},
{
name: "header",
kind: { type: "text" },
map: true,
info: "A request or response header",
example: "@header.accept:*json*",
},
],
},
};
case "query_values":
return [];
case "validate_query":
return [];
default:
throw new Error(`Not in the harness: ${cmd}`);
}
}
/**
* `?many=3000`: the fixtures over and over, a second apart, for checking the list with a lot of
* traffic. Every fourth has no response yet, and a program runs in a few working folders.
*/
function manyFlows(count: number): Flow[] {
const start = Date.parse("2026-11-09T09:00:00Z");
const folders = ["/Users/you/src/yaak", "/Users/you/.codex/worktrees/1a79/yaak", null];
return Array.from({ length: count }, (_, i) => {
const base = fixtures[i % fixtures.length]?.flow as Flow;
const pending = i % 4 === 3;
return {
...base,
id: `${base.id}~${i}`,
startedAt: new Date(start + i * 1000).toISOString().replace("Z", ""),
url:
i % 7 === 0 ? `${base.url}/a/rather/long/path/segment/${i}?page=${i}&q=search` : base.url,
status: pending ? null : base.status,
contentType: pending ? null : base.contentType,
responseSize: pending ? 0 : base.responseSize,
elapsedMs: pending ? null : base.elapsedMs,
clientCwd: base.clientBundleId == null ? (folders[i % folders.length] ?? null) : null,
};
});
}
+57 -30
View File
@@ -4,14 +4,15 @@ import {
type ColumnPrefs,
defaultColumnPrefs,
gridTemplate,
gridWidth,
nextSort,
parseColumnPrefs,
resetWidth,
resetWidths,
resizeColumn,
shownColumns,
sortFlows,
toggleColumn,
WIDE_MIN_WIDTH,
} from "./columns";
function flow(number: number, extra: Partial<Flow> = {}): Flow {
@@ -33,11 +34,11 @@ function flow(number: number, extra: Partial<Flow> = {}): Flow {
} as Flow;
}
const ids = (prefs: ColumnPrefs, width: number) => shownColumns(prefs, width).map((c) => c.id);
const ids = (prefs: ColumnPrefs) => shownColumns(prefs).map((c) => c.id);
describe("shownColumns", () => {
test("shows the wide set when there is room, and the narrow set when not", () => {
expect(ids(defaultColumnPrefs, WIDE_MIN_WIDTH)).toEqual([
test("shows every visible column, however narrow the list", () => {
expect(ids(defaultColumnPrefs)).toEqual([
"number",
"app",
"method",
@@ -48,39 +49,31 @@ describe("shownColumns", () => {
"size",
"time",
]);
expect(ids(defaultColumnPrefs, WIDE_MIN_WIDTH - 1)).toEqual([
"app",
"method",
"host",
"path",
"status",
"time",
]);
});
test("follows the picker, and always keeps the path", () => {
let prefs = toggleColumn(defaultColumnPrefs, "remote");
prefs = toggleColumn(prefs, "type");
prefs = toggleColumn(prefs, "path");
expect(ids(prefs, 2000)).toContain("remote");
expect(ids(prefs, 2000)).not.toContain("type");
expect(ids(prefs, 2000)).toContain("path");
// Off by default, and outside the narrow set.
expect(ids(prefs, 400)).not.toContain("remote");
expect(ids(prefs)).toContain("remote");
expect(ids(prefs)).not.toContain("type");
expect(ids(prefs)).toContain("path");
});
test("lays out fixed widths with the path taking the rest", () => {
const prefs = resizeColumn(defaultColumnPrefs, "host", 240.4);
const template = gridTemplate(shownColumns(prefs, 1200));
expect(template).toBe("22px 46px 18px 50px 240px minmax(80px,1fr) 46px 50px 72px 64px");
test("lays out every column at a fixed width, the path included", () => {
let prefs = resizeColumn(defaultColumnPrefs, "host", 240.4);
prefs = resizeColumn(prefs, "path", 500);
const columns = shownColumns(prefs);
const pad = (w: number) => `calc(${w}px + 0.75rem)`;
expect(gridTemplate(columns)).toBe(
["24px", ...[46, 18, 50, 240, 500, 46, 50, 72, 64].map(pad)].join(" "),
);
expect(gridWidth(columns)).toBe("calc(1110px + 9 * 0.75rem)");
});
test("lets the host give way to the path in a narrow list", () => {
const host = (width: number) =>
shownColumns(defaultColumnPrefs, width).find((c) => c.id === "host");
expect(host(2000)?.current).toBe(190);
expect(host(500)?.current).toBe(120);
expect(host(100)?.current).toBe(60);
test("keeps the host's width in a narrow list", () => {
const host = shownColumns(defaultColumnPrefs).find((c) => c.id === "host");
expect(host?.current).toBe(190);
});
});
@@ -96,19 +89,36 @@ describe("column preferences", () => {
expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs);
expect(resetWidths(prefs).widths).toEqual({});
});
test("a double-click resets one column's width", () => {
let prefs = resizeColumn(defaultColumnPrefs, "host", 300);
prefs = resizeColumn(prefs, "path", 400);
expect(resetWidth(prefs, "path").widths).toEqual({ host: 300 });
});
});
describe("sorting", () => {
test("sorts numbers largest first and names from A, then reverses", () => {
test("sorts numbers largest first and names from A, reverses, then goes back to start", () => {
expect(nextSort(null, "size")).toEqual({ column: "size", descending: true });
expect(nextSort(null, "host")).toEqual({ column: "host", descending: false });
expect(nextSort({ column: "host", descending: false }, "host")).toEqual({
column: "host",
descending: true,
});
expect(nextSort({ column: "host", descending: true }, "host")).toBeNull();
expect(nextSort({ column: "size", descending: true }, "size")).toEqual({
column: "size",
descending: false,
});
expect(nextSort({ column: "size", descending: false }, "size")).toBeNull();
// Another column starts over.
expect(nextSort({ column: "host", descending: true }, "size")).toEqual({
column: "size",
descending: true,
});
});
test("sorts the loaded flows, those without a value last, ties in arrival order", () => {
test("sorts the loaded flows, those without a value lowest, ties in arrival order", () => {
const a = flow(1, { responseSize: 10, elapsedMs: 5, url: "https://b.com/" });
const b = flow(2, { responseSize: 300, elapsedMs: null, url: "https://a.com/" });
const c = flow(3, { responseSize: 10, elapsedMs: 50, url: "https://c.com/" });
@@ -117,12 +127,29 @@ describe("sorting", () => {
const by = (column: Parameters<typeof nextSort>[1], descending: boolean) =>
sortFlows(flows, { column, descending }).map((f) => f.number);
expect(by("size", true)).toEqual([2, 1, 3, 4]);
expect(by("size", false)).toEqual([1, 3, 2, 4]);
expect(by("size", false)).toEqual([4, 1, 3, 2]);
expect(by("time", true)).toEqual([3, 1, 2, 4]);
expect(by("time", false)).toEqual([2, 4, 1, 3]);
expect(by("host", false)).toEqual([2, 4, 1, 3]);
expect(by("number", true)).toEqual([4, 3, 2, 1]);
// Failures sort below every status.
expect(by("status", true)).toEqual([1, 2, 3, 4]);
expect(by("status", false)).toEqual([4, 1, 2, 3]);
expect(sortFlows(flows, null)).toBe(flows);
});
test("flips flows with no status, type, size or time to the other end", () => {
const done = flow(1, { status: 200, contentType: "text/html", responseSize: 5, elapsedMs: 9 });
const pending = flow(2, { status: null, contentType: null, responseSize: 0, elapsedMs: null });
const cancelled = flow(3, { status: null, cancelled: true, contentType: "" });
const flows = [done, pending, cancelled];
for (const column of ["status", "type", "size", "time"] as const) {
expect(sortFlows(flows, { column, descending: true }).map((f) => f.number)).toEqual([
1, 2, 3,
]);
expect(sortFlows(flows, { column, descending: false }).map((f) => f.number)).toEqual([
2, 3, 1,
]);
}
});
});
+43 -57
View File
@@ -18,14 +18,12 @@ export interface ColumnDef {
id: ColumnId;
/** In the header and the picker */
label: string;
/** Pixels, at the default interface size; the path takes what is left */
/** Pixels of content, at the default interface size; each cell adds `CELL_PADDING` */
width: number;
minWidth: number;
align: "left" | "right";
/** Shown until the person hides it */
defaultVisible: boolean;
/** Kept when the list is narrow; the rest drop out below `WIDE_MIN_WIDTH` */
narrow: boolean;
}
export const COLUMNS: ColumnDef[] = [
@@ -36,7 +34,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 30,
align: "right",
defaultVisible: true,
narrow: false,
},
{
id: "app",
@@ -45,7 +42,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 18,
align: "left",
defaultVisible: true,
narrow: true,
},
{
id: "method",
@@ -54,7 +50,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 40,
align: "left",
defaultVisible: true,
narrow: true,
},
{
id: "host",
@@ -63,16 +58,14 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 60,
align: "left",
defaultVisible: true,
narrow: true,
},
{
id: "path",
label: "Path",
width: 0,
width: 360,
minWidth: 80,
align: "left",
defaultVisible: true,
narrow: true,
},
{
id: "status",
@@ -81,7 +74,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 34,
align: "left",
defaultVisible: true,
narrow: true,
},
{
id: "type",
@@ -90,7 +82,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 34,
align: "left",
defaultVisible: true,
narrow: false,
},
{
id: "size",
@@ -99,7 +90,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 44,
align: "right",
defaultVisible: true,
narrow: false,
},
{
id: "time",
@@ -108,7 +98,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 44,
align: "right",
defaultVisible: true,
narrow: true,
},
{
id: "remote",
@@ -117,7 +106,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 60,
align: "left",
defaultVisible: false,
narrow: false,
},
{
id: "version",
@@ -126,7 +114,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 44,
align: "left",
defaultVisible: false,
narrow: false,
},
];
@@ -138,11 +125,11 @@ export function columnDef(id: ColumnId): ColumnDef {
return def;
}
/** Below this width the list keeps only the narrow set: what a glance needs */
export const WIDE_MIN_WIDTH = 760;
/** The mark column, first and fixed */
export const MARK_WIDTH = 22;
export const MARK_WIDTH = 24;
/** Each cell's padding, both sides together, added to its column's width; `px-1.5` per side */
export const CELL_PADDING = "0.75rem";
/** What the person chose: which columns show, and the widths they dragged, by id */
export interface ColumnPrefs {
@@ -153,32 +140,18 @@ export interface ColumnPrefs {
export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {} };
export interface Column extends ColumnDef {
/** Its width now, in pixels; zero for the path, which takes the rest */
/** Its width now, in pixels of content */
current: number;
}
/**
* The columns shown at `listWidth`, in order: the visible ones, less those outside the narrow set
* when the list is narrower than `WIDE_MIN_WIDTH`. The path always shows. Measured, not by
* layout, so a wide window side by side still gets size and type.
* The columns shown, in order: the visible ones, the path always. Each keeps its width however
* narrow the list is; the list scrolls sideways instead.
*/
export function shownColumns(prefs: ColumnPrefs, listWidth: number): Column[] {
const wide = listWidth >= WIDE_MIN_WIDTH;
return COLUMNS.filter((c) => {
if (c.id === "path") return true;
if (!(prefs.visible[c.id] ?? c.defaultVisible)) return false;
return wide || c.narrow;
}).map((c) => ({ ...c, current: currentWidth(prefs, c.id, listWidth) }));
}
/** The host gives way to the path in a narrow list, at most this share of its width */
const HOST_SHARE = 0.24;
function currentWidth(prefs: ColumnPrefs, id: ColumnId, listWidth: number): number {
if (id === "path") return 0;
const width = columnWidth(prefs, id);
if (id !== "host" || listWidth <= 0) return width;
return Math.max(columnDef(id).minWidth, Math.min(width, Math.round(listWidth * HOST_SHARE)));
export function shownColumns(prefs: ColumnPrefs): Column[] {
return COLUMNS.filter((c) => c.id === "path" || (prefs.visible[c.id] ?? c.defaultVisible)).map(
(c) => ({ ...c, current: columnWidth(prefs, c.id) }),
);
}
export function columnWidth(prefs: ColumnPrefs, id: ColumnId): number {
@@ -186,14 +159,18 @@ export function columnWidth(prefs: ColumnPrefs, id: ColumnId): number {
return Math.max(def.minWidth, Math.round(prefs.widths[id] ?? def.width));
}
/** The grid template for the header and every row */
/** The grid template for the header and every row: the mark, then each column and its padding */
export function gridTemplate(columns: Column[]): string {
const cells = columns.map((c) =>
c.id === "path" ? `minmax(${c.minWidth}px,1fr)` : `${c.current}px`,
);
const cells = columns.map((c) => `calc(${c.current}px + ${CELL_PADDING})`);
return [`${MARK_WIDTH}px`, ...cells].join(" ");
}
/** The width of a row's grid, as CSS: the list scrolls sideways when it is wider than the list */
export function gridWidth(columns: Column[]): string {
const px = columns.reduce((sum, c) => sum + c.current, MARK_WIDTH);
return `calc(${px}px + ${columns.length} * ${CELL_PADDING})`;
}
export function toggleColumn(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs {
if (id === "path") return prefs;
const shown = prefs.visible[id] ?? columnDef(id).defaultVisible;
@@ -201,7 +178,6 @@ export function toggleColumn(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs {
}
export function resizeColumn(prefs: ColumnPrefs, id: ColumnId, width: number): ColumnPrefs {
if (id === "path") return prefs;
const w = Math.max(columnDef(id).minWidth, Math.round(width));
return { ...prefs, widths: { ...prefs.widths, [id]: w } };
}
@@ -210,6 +186,12 @@ export function resetWidths(prefs: ColumnPrefs): ColumnPrefs {
return { ...prefs, widths: {} };
}
/** One column back to its default width */
export function resetWidth(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs {
const { [id]: _, ...widths } = prefs.widths;
return { ...prefs, widths };
}
/**
* Reads stored preferences, dropping what no longer applies: unknown ids, and values of the
* wrong type, so a change to the columns never breaks the list.
@@ -238,10 +220,15 @@ export function firstDirection(column: ColumnId): boolean {
return column === "number" || column === "size" || column === "time" || column === "status";
}
/** A click on a column's label: sort by it, or reverse the sort it already has */
export function nextSort(sort: FlowSort | null, column: ColumnId): FlowSort {
if (sort?.column === column) return { column, descending: !sort.descending };
return { column, descending: firstDirection(column) };
/**
* A click on a column's label: sort by it, reverse the sort it has, then, from the reversed sort,
* go back to start time
*/
export function nextSort(sort: FlowSort | null, column: ColumnId): FlowSort | null {
const first = firstDirection(column);
if (sort?.column !== column) return { column, descending: first };
if (sort.descending === first) return { column, descending: !first };
return null;
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
@@ -276,20 +263,19 @@ function sortKey(flow: Flow, column: ColumnId): Key {
}
/**
* The flows in `sort`'s order, without a value last either way, then by start so equal values
* keep arrival order. Sorts the flows given, which are the ones loaded, not the whole database.
* The flows in `sort`'s order, those without a value (none, empty or zero) lowest, so first
* ascending and last descending, then by start so equal values keep arrival order. Sorts the
* flows given, which are the ones loaded, not the whole database: the database only ever reads
* by start time, so no `ORDER BY` follows the column sort.
*/
export function sortFlows(flows: Flow[], sort: FlowSort | null): Flow[] {
if (sort == null) return flows;
const dir = sort.descending ? -1 : 1;
const keyed = flows.map((flow, i) => ({ flow, i, key: sortKey(flow, sort.column) }));
const keyed = flows.map((flow, i) => ({ flow, i, key: sortKey(flow, sort.column) || null }));
keyed.sort((a, b) => {
if (a.key == null || b.key == null) {
if (a.key == null && b.key == null) return a.i - b.i;
return a.key == null ? 1 : -1;
}
let c: number;
if (typeof a.key === "number" && typeof b.key === "number") c = a.key - b.key;
if (a.key == null || b.key == null) c = Number(b.key == null) - Number(a.key == null);
else if (typeof a.key === "number" && typeof b.key === "number") c = a.key - b.key;
else c = collator.compare(String(a.key), String(b.key));
return c === 0 ? a.i - b.i : c * dir;
});
+7 -1
View File
@@ -6,7 +6,7 @@ const opener = vi.hoisted(() => ({
}));
vi.mock("@tauri-apps/plugin-opener", () => opener);
const { openLink, revealPath } = await import("./opener");
const { openLink, revealLabel, revealPath } = await import("./opener");
describe("opener", () => {
beforeEach(() => {
@@ -39,4 +39,10 @@ describe("opener", () => {
await openLink("https://yaak.app", report);
expect(report).toHaveBeenCalledWith("Failed to open https://yaak.app: Error: denied");
});
test("names the file manager by platform", () => {
expect(revealLabel("macos")).toBe("Reveal in Finder");
expect(revealLabel("windows")).toBe("Show in Explorer");
expect(revealLabel("linux")).toBe("Show in Folder");
});
});
+7
View File
@@ -20,3 +20,10 @@ export async function openLink(url: string, report: ReportFailure): Promise<void
report(`Failed to open ${url}: ${String(err)}`);
}
}
/** What the platform calls showing a file in its file manager */
export function revealLabel(os: string): string {
if (os === "macos") return "Reveal in Finder";
if (os === "windows") return "Show in Explorer";
return "Show in Folder";
}
+13
View File
@@ -159,6 +159,14 @@ describe("programs outside an app", () => {
expect(items.get("app:curl|site:example.com")?.kind).toBe("site");
});
test("folder rows carry their full path, and programs their executable", () => {
expect(items.get("app:claude|cwd:yaak")?.path).toBe("/src/yaak");
expect(items.get("app:claude|cwd:fieldwork")?.path).toBe("/src/fieldwork");
expect(items.get("app:claude")?.path).toBe("/usr/local/bin/claude");
// Hosts and paths under a folder are not on disk.
expect(items.get("app:claude|cwd:yaak|host:statsig.anthropic.com")?.path).toBeUndefined();
});
test("a folder row selects that program in that folder", () => {
const query = items.get("app:claude|cwd:yaak")?.query ?? "";
expect(query).toBe("@app:claude @cwd:yaak");
@@ -169,6 +177,11 @@ describe("programs outside an app", () => {
});
});
test("an app in a bundle has no path to reveal", () => {
const items = itemsById(buildSidebarTree([flow("https://slack.com/", slack)], 1));
expect(items.get("app:Slack")?.path).toBeUndefined();
});
describe("decrypting from the sidebar", () => {
const tunnel = flow("https://bank.example.com", {
...slack,
+18 -1
View File
@@ -37,6 +37,11 @@ export interface SidebarItem {
decrypt?: DecryptSubject;
/** The saved search a Saved row is, for renaming and deleting it */
savedId?: string;
/**
* On disk: a folder row's working directory, in full, and a program's executable, for Reveal
* in Finder and, on a folder, Copy path
*/
path?: string;
}
/** A saved search as the sidebar lists it, with how many flows it covers */
@@ -62,6 +67,8 @@ interface PlaceRow {
label: string;
query: string;
decrypt?: DecryptSubject;
/** A folder row's working directory, in full */
path?: string;
}
/**
@@ -128,8 +135,15 @@ function placeOf(flow: Flow): Place {
} else {
const folderId = `app:${app}|cwd:${folder}`;
const folderQuery = `${appQuery} ${fieldTerm("cwd", folder)}`;
const folderRow: PlaceRow = {
id: folderId,
kind: "folder",
label: folder,
query: folderQuery,
};
if (flow.clientCwd != null) folderRow.path = flow.clientCwd;
appRows = [
{ id: folderId, kind: "folder", label: folder, query: folderQuery },
folderRow,
...rows.map((r) => ({
...r,
id: `${folderId}|${r.id}`,
@@ -169,6 +183,7 @@ function child(parent: Trie, row: PlaceRow): Trie {
children: new Map(),
};
if (row.decrypt != null) node.item.decrypt = row.decrypt;
if (row.path != null) node.item.path = row.path;
parent.children.set(row.id, node);
}
node.item.count++;
@@ -257,6 +272,8 @@ export function buildSidebarTree(
}
if (folder != null) item.sublabel = folder;
if (flow.clientPath != null) item.appPath = flow.clientPath;
// A program outside an app bundle: its executable
if (flow.clientBundleId == null && flow.clientPath != null) item.path = flow.clientPath;
app = { item, children: new Map() };
parent.children.set(id, app);
}
+19 -2
View File
@@ -4,6 +4,10 @@ import type { HTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react";
import { Icon } from "./Icon";
import { LoadingIcon } from "./LoadingIcon";
import { Tooltip } from "./Tooltip";
/** How long the pointer rests on a control before its tooltip shows, in milliseconds */
export const CONTROL_TOOLTIP_DELAY = 300;
type ButtonVariant = "border" | "solid" | "input";
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
@@ -22,6 +26,8 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onC
forDropdown?: boolean;
disabled?: boolean;
title?: string;
/** Shows `title` in Yaak's tooltip, quickly, rather than the browser's */
tooltip?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
@@ -43,6 +49,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
rightSlot,
disabled,
title,
tooltip,
onClick,
...props
}: ButtonProps,
@@ -50,8 +57,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
) {
const resolvedColor = color ?? tone ?? "default";
const isDisabled = disabled || isLoading;
const withTooltip = tooltip === true && title != null && title !== "";
return (
const button = (
<button
ref={ref}
type={type}
@@ -99,7 +107,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
onDoubleClick={(e) => {
e.stopPropagation();
}}
title={title}
title={withTooltip ? undefined : title}
aria-label={withTooltip ? title : undefined}
{...props}
>
{isLoading ? (
@@ -126,4 +135,12 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
)}
</button>
);
return withTooltip ? (
<Tooltip wrap content={title} delay={CONTROL_TOOLTIP_DELAY}>
{button}
</Tooltip>
) : (
button
);
});
+18 -2
View File
@@ -19,6 +19,8 @@ interface Props {
onReset?: () => void;
side: "left" | "right" | "top";
justify: "center" | "end" | "start";
/** Fills the space it is given, all of it taking the pointer, with its bar centred in it */
fill?: boolean;
}
export function ResizeHandle({
@@ -30,6 +32,7 @@ export function ResizeHandle({
onResizeMove,
onReset,
side,
fill = false,
}: Props) {
const vertical = side === "top";
const [isResizing, setIsResizing] = useState<boolean>(false);
@@ -89,8 +92,12 @@ export function ResizeHandle({
onPointerDown={handlePointerDown}
className={classNames(
className,
"group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full",
vertical ? "w-full h-1.5 cursor-row-resize" : "h-full w-1.5 cursor-col-resize",
"group z-10 flex select-none",
vertical ? "cursor-row-resize" : "cursor-col-resize",
fill
? "w-full h-full items-center"
: "transition-colors hover:bg-surface-active rounded-full",
!fill && (vertical ? "w-full h-1.5" : "h-full w-1.5"),
justify === "center" && "justify-center",
justify === "end" && "justify-end",
justify === "start" && "justify-start",
@@ -99,6 +106,15 @@ export function ResizeHandle({
side === "top" && "top-0",
)}
>
{fill && (
<div
className={classNames(
"rounded-full transition-colors group-hover:bg-surface-active",
vertical ? "w-full h-1.5" : "h-full w-1.5",
isResizing && "bg-surface-active",
)}
/>
)}
{isResizing && (
<div
className={classNames(
+28 -9
View File
@@ -26,6 +26,11 @@ interface Props {
minWidthPx?: number;
layout?: SplitLayoutLayout;
resizeHandleClassName?: string;
/**
* Pixels between the panes, all of it the handle, its bar centred. Without it the panes meet
* and the handle straddles their edge.
*/
gutter?: number;
}
const baseProperties = { minHeight: 0, minWidth: 0 };
@@ -43,6 +48,7 @@ export function SplitLayout({
storageKey,
layout = "responsive",
resizeHandleClassName,
gutter = 0,
defaultRatio = 0.5,
minHeightPx = 10,
minWidthPx = 10,
@@ -56,6 +62,7 @@ export function SplitLayout({
if (!secondSlot) {
height = 0;
minHeightPx = 0;
gutter = 0;
}
const size = useContainerSize(containerRef);
@@ -67,19 +74,22 @@ export function SplitLayout({
const styles = useMemo<CSSProperties>(
() => ({
...style,
gridTemplate: splitGridTemplate(vertical, vertical ? renderedHeight : renderedWidth),
gridTemplate: splitGridTemplate(vertical, vertical ? renderedHeight : renderedWidth, gutter),
}),
[style, vertical, renderedHeight, renderedWidth],
[style, vertical, renderedHeight, renderedWidth, gutter],
);
// While dragging, the split is written straight to the DOM and only saved (and rendered) when
// the drag ends, so the panes are not re-rendered on every move.
const liveDrag = useRef<SplitDrag | null>(null);
const applySplit = useCallback((vertical: boolean, ratio: number) => {
if (containerRef.current == null) return;
containerRef.current.style.gridTemplate = splitGridTemplate(vertical, ratio);
}, []);
const applySplit = useCallback(
(vertical: boolean, ratio: number) => {
if (containerRef.current == null) return;
containerRef.current.style.gridTemplate = splitGridTemplate(vertical, ratio, gutter);
},
[gutter],
);
// Something else re-rendering mid-drag (a response arriving, say) may write the saved split
// back; put the live one back before it is painted.
@@ -102,9 +112,16 @@ export function SplitLayout({
const $c = containerRef.current;
if ($c == null) return 0;
const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle($c);
// The panes share what the gutter leaves
return vertical
? $c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom)
: $c.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight);
? $c.clientHeight -
Number.parseFloat(paddingTop) -
Number.parseFloat(paddingBottom) -
gutter
: $c.clientWidth -
Number.parseFloat(paddingLeft) -
Number.parseFloat(paddingRight) -
gutter;
},
apply: (ratio) => applySplit(vertical, ratio),
commit: (ratio) => (vertical ? setHeight(ratio) : setWidth(ratio)),
@@ -115,6 +132,7 @@ export function SplitLayout({
renderedWidth,
minHeightPx,
minWidthPx,
gutter,
applySplit,
setHeight,
setWidth,
@@ -151,8 +169,9 @@ export function SplitLayout({
style={areaD}
className={classNames(
resizeHandleClassName,
vertical ? "-translate-y-1" : "-translate-x-1",
gutter === 0 && (vertical ? "-translate-y-1" : "-translate-x-1"),
)}
fill={gutter > 0}
onResizeStart={handleResizeStart}
onResizeMove={handleResizeMove}
onResizeEnd={handleResizeEnd}
+120 -50
View File
@@ -1,6 +1,6 @@
import classNames from "classnames";
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
import { useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { generateId } from "@yaakapp-internal/lib/generateId";
import { Portal } from "./Portal";
@@ -10,6 +10,15 @@ export interface TooltipProps {
tabIndex?: number;
size?: "md" | "lg";
className?: string;
/** How long the pointer rests before it shows, in milliseconds */
delay?: number;
/**
* Wraps a control or a cell without a box of its own: shown on hover alone, hidden on a press,
* and placed against the first element inside, which keeps its own focus and clicks
*/
wrap?: boolean;
/** Shown only while something inside is cut short, as a long host in a narrow column is */
onlyWhenTruncated?: boolean;
}
const hiddenStyles: CSSProperties = {
@@ -27,27 +36,46 @@ interface TooltipOpenState {
position: TooltipPosition;
}
export function Tooltip({ children, className, content, tabIndex, size = "md" }: TooltipProps) {
export function Tooltip({
children,
className,
content,
tabIndex,
size = "md",
delay = 500,
wrap = false,
onlyWhenTruncated = false,
}: TooltipProps) {
// Open, then placed once measured; the tooltip is only in the page while open.
const [open, setOpen] = useState(false);
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const triggerRef = useRef<HTMLSpanElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleOpenImmediate = () => {
if (triggerRef.current == null || tooltipRef.current == null) return;
clearTimeout(showTimeout.current);
const triggerRect = triggerRef.current.getBoundingClientRect();
useEffect(() => () => clearTimeout(showTimeout.current), []);
useLayoutEffect(() => {
if (!open) {
setOpenState(null);
return;
}
const trigger = anchorOf(triggerRef.current, wrap);
if (trigger == null || tooltipRef.current == null) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const viewportHeight = document.documentElement.clientHeight;
const viewportWidth = document.documentElement.clientWidth;
const margin = 8;
const spaceAbove = Math.max(0, triggerRect.top - margin);
const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin);
const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove;
const position: TooltipPosition = preferBottom ? "bottom" : "top";
const centred = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
const styles: CSSProperties = {
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
left: Math.max(0, Math.min(centred, viewportWidth - tooltipRect.width)),
maxHeight: position === "top" ? spaceAbove : spaceBelow,
...(position === "top"
? { bottom: viewportHeight - triggerRect.top }
@@ -55,25 +83,31 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }:
};
setOpenState({ styles, position });
}, [open, wrap]);
const handleOpenImmediate = () => {
clearTimeout(showTimeout.current);
if (onlyWhenTruncated && !isTruncated(anchorOf(triggerRef.current, wrap))) return;
setOpen(true);
};
const handleOpen = () => {
clearTimeout(showTimeout.current);
showTimeout.current = setTimeout(handleOpenImmediate, 500);
showTimeout.current = setTimeout(handleOpenImmediate, delay);
};
const handleClose = () => {
clearTimeout(showTimeout.current);
setOpenState(null);
setOpen(false);
};
const handleToggleImmediate = () => {
if (openState) handleClose();
if (open) handleClose();
else handleOpenImmediate();
};
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (openState && e.key === "Escape") {
const handleKeyDown = (e: KeyboardEvent<HTMLSpanElement>) => {
if (open && e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
handleClose();
@@ -84,52 +118,88 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }:
return (
<>
<Portal name="tooltip">
<div
ref={tooltipRef}
style={openState?.styles ?? hiddenStyles}
id={id.current}
role="tooltip"
aria-hidden={openState == null}
onMouseEnter={handleOpenImmediate}
onMouseLeave={handleClose}
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
>
{open && (
<Portal name="tooltip">
<div
ref={tooltipRef}
style={openState?.styles ?? hiddenStyles}
id={id.current}
role="tooltip"
onMouseEnter={wrap ? undefined : handleOpenImmediate}
onMouseLeave={wrap ? undefined : handleClose}
className={classNames(
"bg-surface-highlight rounded-md px-3 py-2 z-50 border border-border overflow-auto",
size === "md" && "max-w-sm",
size === "lg" && "max-w-md",
"p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]",
wrap && "pointer-events-none",
)}
>
{content}
<div
className={classNames(
"bg-surface-highlight rounded-md px-3 py-2 z-50 border border-border overflow-auto",
// Text keeps its line breaks, for a tooltip listing a few things
typeof content === "string" && "break-words whitespace-pre-line",
size === "md" && "max-w-sm",
size === "lg" && "max-w-md",
)}
>
{content}
</div>
<Triangle
className="text-border"
position={openState?.position === "bottom" ? "top" : "bottom"}
/>
</div>
<Triangle
className="text-border"
position={openState?.position === "bottom" ? "top" : "bottom"}
/>
</div>
</Portal>
{/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Needs to be usable in other buttons */}
<span
ref={triggerRef}
role="button"
aria-describedby={openState ? id.current : undefined}
tabIndex={tabIndex ?? -1}
className={classNames(className, "grow-0 flex items-center")}
onClick={handleToggleImmediate}
onMouseEnter={handleOpen}
onMouseLeave={handleClose}
onFocus={handleOpenImmediate}
onBlur={handleClose}
onKeyDown={handleKeyDown}
>
{children}
</span>
</Portal>
)}
{wrap ? (
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- hover only; what it wraps keeps its own focus and clicks
<span
ref={triggerRef}
aria-describedby={openState ? id.current : undefined}
className={classNames(className, "contents")}
onMouseEnter={handleOpen}
onMouseLeave={handleClose}
onMouseDown={handleClose}
>
{children}
</span>
) : (
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Needs to be usable in other buttons
<span
ref={triggerRef}
role="button"
aria-describedby={openState ? id.current : undefined}
tabIndex={tabIndex ?? -1}
className={classNames(className, "grow-0 flex items-center")}
onClick={handleToggleImmediate}
onMouseEnter={handleOpen}
onMouseLeave={handleClose}
onFocus={handleOpenImmediate}
onBlur={handleClose}
onKeyDown={handleKeyDown}
>
{children}
</span>
)}
</>
);
}
/** What the tooltip is placed against: the trigger, or a wrapping one's first element */
function anchorOf(trigger: HTMLElement | null, wrap: boolean): Element | null {
if (trigger == null) return null;
return wrap ? (trigger.firstElementChild ?? trigger) : trigger;
}
/** Whether `el`, or anything in it, is cut short */
function isTruncated(el: Element | null): boolean {
if (el == null) return false;
if (el.scrollWidth > el.clientWidth + 1) return true;
for (const child of el.querySelectorAll("*")) {
if (child.scrollWidth > child.clientWidth + 1) return true;
}
return false;
}
function Triangle({ className, position }: { className?: string; position: "top" | "bottom" }) {
const isBottom = position === "bottom";
@@ -6,6 +6,7 @@ import {
createHandleDrag,
createSidebarDrag,
createSplitDrag,
splitGridTemplate,
} from "./resizeDrag";
let frames: Map<number, FrameRequestCallback>;
@@ -225,6 +226,23 @@ describe("clampSplitRatio", () => {
});
});
describe("splitGridTemplate", () => {
const tracks = (t: string) => t.replace(/\s+/g, " ").trim();
test("puts the gutter between the panes, the handle's track", () => {
expect(tracks(splitGridTemplate(false, 0.4, 20))).toBe(
"' left drag right' minmax(0,1fr) / 0.6fr 20px 0.4fr",
);
expect(tracks(splitGridTemplate(true, 0.5, 8))).toBe(
"' left' minmax(0,0.5fr) ' drag' 8px ' right' minmax(0,0.5fr) / 1fr",
);
});
test("lets the panes meet without one", () => {
expect(tracks(splitGridTemplate(false, 0.5))).toContain("0.5fr 0px 0.5fr");
});
});
describe("createSidebarDrag", () => {
function sidebar() {
const apply = vi.fn();
+4 -3
View File
@@ -93,17 +93,18 @@ export function createHandleDrag({
};
}
export function splitGridTemplate(vertical: boolean, ratio: number): string {
/** The split's grid: the two panes by `ratio`, and between them `gutter` pixels for the handle */
export function splitGridTemplate(vertical: boolean, ratio: number, gutter = 0): string {
return vertical
? `
' left' minmax(0,${1 - ratio}fr)
' drag' 0
' drag' ${gutter}px
' right' minmax(0,${ratio}fr)
/ 1fr
`
: `
' left drag right' minmax(0,1fr)
/ ${1 - ratio}fr 0 ${ratio}fr
/ ${1 - ratio}fr ${gutter}px ${ratio}fr
`;
}
+1 -1
View File
@@ -1,7 +1,7 @@
export type { BannerProps } from "./components/Banner";
export { Banner } from "./components/Banner";
export type { ButtonProps } from "./components/Button";
export { Button } from "./components/Button";
export { Button, CONTROL_TOOLTIP_DELAY } from "./components/Button";
export type { CheckboxProps } from "./components/Checkbox";
export { Checkbox } from "./components/Checkbox";
export type { CopyIconButtonProps } from "./components/CopyIconButton";