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" className="pointer-events-auto"
icon="trash" icon="trash"
title={locked != null ? "Clear flows (traffic is locked)" : "Clear flows (none yet)"} title={locked != null ? "Clear flows (traffic is locked)" : "Clear flows (none yet)"}
tooltip
size="sm" size="sm"
iconColor="secondary" iconColor="secondary"
disabled disabled
@@ -67,6 +68,7 @@ export function ClearFlowsButton() {
className="pointer-events-auto" className="pointer-events-auto"
icon="trash" icon="trash"
title="Clear flows" title="Clear flows"
tooltip
size="sm" size="sm"
iconColor="secondary" iconColor="secondary"
/> />
@@ -1,7 +1,7 @@
import { Prec } from "@codemirror/state"; import { Prec } from "@codemirror/state";
import { type EditorView, keymap } from "@codemirror/view"; import { type EditorView, keymap } from "@codemirror/view";
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib"; 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 { filterAutocomplete } from "@yaakapp-internal/ui/editor";
import classNames from "classnames"; import classNames from "classnames";
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
@@ -71,28 +71,29 @@ function EntryRow({
entry.builtIn && "text-text-subtlest", entry.builtIn && "text-text-subtlest",
)} )}
> >
<Icon <Tooltip wrap content={negated ? "Excluded" : "Decrypted"} delay={CONTROL_TOOLTIP_DELAY}>
icon={negated ? "lock" : "lock_open"} <Icon
size="sm" icon={negated ? "lock" : "lock_open"}
className={classNames("shrink-0", entry.builtIn ? "opacity-60" : "text-text-subtle")} size="sm"
title={negated ? "Excluded" : "Decrypted"} className={classNames("shrink-0", entry.builtIn ? "opacity-60" : "text-text-subtle")}
/> />
</Tooltip>
<span className={classNames("truncate", entry.entry !== "*" && "font-mono text-xs")}> <span className={classNames("truncate", entry.entry !== "*" && "font-mono text-xs")}>
{entryLabel(entry.entry)} {entryLabel(entry.entry)}
</span> </span>
{entry.builtIn && ( {entry.builtIn && (
<span <Tooltip wrap content="Ships with Yaak Proxy" delay={CONTROL_TOOLTIP_DELAY}>
title="Ships with Yaak Proxy" <span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
className="shrink-0 text-2xs rounded px-1 border border-border-subtle" built-in
> </span>
built-in </Tooltip>
</span>
)} )}
<IconButton <IconButton
size="xs" size="xs"
icon="x" icon="x"
iconColor="secondary" iconColor="secondary"
title={`Remove ${entryLabel(entry.entry)}`} title={`Remove ${entryLabel(entry.entry)}`}
tooltip
disabled={disabled} disabled={disabled}
className="ml-auto opacity-0 group-hover/entry:opacity-100 focus-visible:opacity-100" className="ml-auto opacity-0 group-hover/entry:opacity-100 focus-visible:opacity-100"
onClick={onRemove} onClick={onRemove}
@@ -151,18 +152,20 @@ function EntryInput({
}, [seenRef]); }, [seenRef]);
return ( return (
<div className="grid gap-1" title="Applies to new connections"> <Tooltip wrap content="Applies to new connections" delay={CONTROL_TOOLTIP_DELAY}>
<Input <div className="grid gap-1">
label="Add entry" <Input
hideLabel label="Add entry"
size="sm" hideLabel
stateKey={null} size="sm"
language={null} stateKey={null}
placeholder="Add host, app or *" language={null}
extensions={extensions} placeholder="Add host, app or *"
onChange={() => setError(null)} extensions={extensions}
/> onChange={() => setError(null)}
{error != null && <div className="text-xs text-danger">{error}</div>} />
</div> {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 classNames from "classnames";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -127,9 +134,17 @@ export function FirstRun() {
title="What to decrypt" title="What to decrypt"
description={ description={
everything ? ( 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={ action={
+17 -13
View File
@@ -1,19 +1,21 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize"; import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib"; import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
import { import {
CONTROL_TOOLTIP_DELAY,
CountBadge, CountBadge,
Dropdown, Dropdown,
EmptyStateText, EmptyStateText,
type ExtraPaneTab, type ExtraPaneTab,
type HttpBodyResult,
HttpRequestPaneView, HttpRequestPaneView,
HttpResponsePaneView, HttpResponsePaneView,
type HttpBodyResult,
Icon, Icon,
IconButton, IconButton,
parseCookieHeaders, parseCookieHeaders,
ResponseCookies, ResponseCookies,
type ResponseViewMode, type ResponseViewMode,
SplitLayout, SplitLayout,
Tooltip,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useSetAtom } from "jotai"; 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 */ /** The method and URL, at the status line's height so the two header lines align */
function UrlLine({ flow }: { flow: Flow }) { function UrlLine({ flow }: { flow: Flow }) {
return ( return (
<div // The whole URL on hover, when it runs past the card
className={classNames( <Tooltip wrap onlyWhenTruncated content={flow.url} delay={CONTROL_TOOLTIP_DELAY}>
"flex items-center gap-2 min-h-[calc(var(--height-sm)+0.25rem)] -mb-1.5", <div
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars", 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 className="text-text-subtle shrink-0">{flow.method}</span>
</span> <span className="text-text select-text cursor-text">{flow.url}</span>
</div> </div>
</Tooltip>
); );
} }
@@ -295,10 +298,11 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
icon="bookmark" icon="bookmark"
iconColor={saved ? "primary" : "secondary"} iconColor={saved ? "primary" : "secondary"}
title={saved ? "Saved to Yaak" : "Save to Yaak"} title={saved ? "Saved to Yaak" : "Save to Yaak"}
tooltip
onClick={() => openSave(flow)} onClick={() => openSave(flow)}
/> />
<Dropdown items={flowActions(flow).slice(2)}> <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> </Dropdown>
</div> </div>
} }
+3 -3
View File
@@ -7,8 +7,8 @@ import { FlowCard } from "./FlowCard";
import { NotDecrypted } from "./NotDecrypted"; import { NotDecrypted } from "./NotDecrypted";
/** /**
* The detail pane: the selected flow's card, or what the pane holds before one is selected. Side * The detail pane: the selected flow's card, or what the pane holds before one is selected. The
* by side, a wider gutter separates the card from the list and it sits a little under the header. * 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 }) { export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideBySide: boolean }) {
const flow = useAtomValue(selectedFlowAtom); const flow = useAtomValue(selectedFlowAtom);
@@ -16,7 +16,7 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
return ( return (
<div <div
style={style} 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 <div
className={classNames( 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 type { Flow } from "@yaakapp-internal/proxy-lib";
import { import {
ContextMenu, ContextMenu,
CONTROL_TOOLTIP_DELAY,
CountBadge, CountBadge,
formatMillis, formatMillis,
HttpMethodTagRaw, HttpMethodTagRaw,
HttpStatusTagRaw, HttpStatusTagRaw,
Icon, Icon,
useContainerSize, Tooltip,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import type { CSSProperties, KeyboardEvent, MouseEvent, WheelEvent } from "react"; import type { CSSProperties, KeyboardEvent, MouseEvent, ReactElement, WheelEvent } from "react";
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms"; import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms";
import { shortContentType } from "../lib/body"; import { shortContentType } from "../lib/body";
import { utcTimestamp } from "../lib/flowViews"; import { utcTimestamp } from "../lib/flowViews";
@@ -21,6 +22,7 @@ import {
type Column, type Column,
type ColumnId, type ColumnId,
gridTemplate, gridTemplate,
gridWidth,
hostOf, hostOf,
nextSort, nextSort,
shownColumns, shownColumns,
@@ -66,7 +68,8 @@ interface Props {
/** /**
* Follows new traffic until a flow is selected or the list is scrolled, then holds. Scrolling to * 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 * 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) { export function FlowList({ style, className }: Props) {
const flows = useAtomValue(shownFlowsAtom); const flows = useAtomValue(shownFlowsAtom);
@@ -81,9 +84,11 @@ export function FlowList({ style, className }: Props) {
const [sort, setSort] = useAtom(flowSortAtom); const [sort, setSort] = useAtom(flowSortAtom);
const proxyState = useAtomValue(proxyStateAtom); const proxyState = useAtomValue(proxyStateAtom);
const paused = proxyState?.state === "running" && proxyState.recording === false; const paused = proxyState?.state === "running" && proxyState.recording === false;
const containerRef = useRef<HTMLDivElement>(null);
const { width } = useContainerSize(containerRef);
const scrollRef = useRef<HTMLDivElement>(null); 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 ignoreScroll = useRef(false);
const [following, setFollowing] = useAtom(followAtom); const [following, setFollowing] = useAtom(followAtom);
const [heldAt, setHeldAt] = useState<string | null>(null); 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. // Where the platform cannot tell which app sent a flow, there is no App column.
const hasApps = useAtomValue(hasAppsAtom); const hasApps = useAtomValue(hasAppsAtom);
const columns = useMemo( const columns = useMemo(
() => shownColumns(prefs, width).filter((c) => hasApps || c.id !== "app"), () => shownColumns(prefs).filter((c) => hasApps || c.id !== "app"),
[prefs, width, hasApps], [prefs, hasApps],
); );
const template = useMemo(() => gridTemplate(columns), [columns]); 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 arrivals = useArrivals(flows, following && sort == null);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
@@ -173,13 +180,29 @@ export function FlowList({ style, className }: Props) {
if (latest.current.sort == null) void loadOlder(); if (latest.current.sort == null) void loadOlder();
}, [hold, 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 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) { if (ignoreScroll.current) {
ignoreScroll.current = false; ignoreScroll.current = false;
return; return;
} }
const el = scrollRef.current; if (!moved) return;
if (el == null) return;
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 2; const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 2;
if (atEnd && latest.current.sort == null) { if (atEnd && latest.current.sort == null) {
setFollowing(true); setFollowing(true);
@@ -192,6 +215,13 @@ export function FlowList({ style, className }: Props) {
} }
}, [hold, loadOlder, setFollowing, ROW_HEIGHT]); }, [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. // A list too short to scroll still loads older flows on scrolling up.
const handleWheel = useCallback( const handleWheel = useCallback(
(e: WheelEvent<HTMLDivElement>) => { (e: WheelEvent<HTMLDivElement>) => {
@@ -247,12 +277,18 @@ export function FlowList({ style, className }: Props) {
[select], [select],
); );
// A third click on a column goes back to start time, following as Sort by Start Time does.
const handleSort = useCallback( const handleSort = useCallback(
(column: ColumnId) => { (column: ColumnId) => {
const next = nextSort(latest.current.sort, column);
if (next == null) {
follow();
return;
}
hold(); hold();
setSort((prev) => nextSort(prev, column)); setSort(next);
}, },
[hold, setSort], [follow, hold, setSort],
); );
const newCount = following || sort != null ? 0 : countNewFlows(flows, heldAt); const newCount = following || sort != null ? 0 : countNewFlows(flows, heldAt);
@@ -261,18 +297,29 @@ export function FlowList({ style, className }: Props) {
return ( return (
<div <div
ref={containerRef}
style={style} 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 {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- scrolls the list sideways */}
columns={columns} <div
template={template} ref={headerScrollRef}
prefs={prefs} onWheel={handleHeaderWheel}
sort={sort} style={{ marginRight: scrollbarWidth }}
onSort={handleSort} className="overflow-hidden"
onSortByStart={follow} >
/> <FlowListHeader
columns={columns}
template={template}
width={contentWidth}
prefs={prefs}
sort={sort}
onSort={handleSort}
onSortByStart={follow}
/>
</div>
<div className="relative min-h-0"> <div className="relative min-h-0">
<div <div
ref={scrollRef} ref={scrollRef}
@@ -282,12 +329,17 @@ export function FlowList({ style, className }: Props) {
onScroll={handleScroll} onScroll={handleScroll}
onWheel={handleWheel} onWheel={handleWheel}
onKeyDown={handleKeyDown} 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 ? ( {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) => { {virtualizer.getVirtualItems().map((item) => {
const flow = flows[item.index]; const flow = flows[item.index];
if (flow == null) return null; if (flow == null) return null;
@@ -311,7 +363,7 @@ export function FlowList({ style, className }: Props) {
<div <div
aria-hidden aria-hidden
style={{ top: dividerIndex * ROW_HEIGHT }} 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"> <span className="absolute -top-2 left-1 px-1 bg-surface text-2xs text-primary">
{divider.count === 1 ? "1 new" : `${divider.count} new`} {divider.count === 1 ? "1 new" : `${divider.count} new`}
@@ -436,8 +488,8 @@ const FlowRow = memo(function FlowRow({
: {}), : {}),
}} }}
className={classNames( className={classNames(
"absolute top-0 inset-x-0 grid items-center gap-x-3 px-1.5 rounded-md whitespace-nowrap", "absolute top-0 left-1.5 right-1.5 grid items-center px-1.5 rounded-md whitespace-nowrap",
"[&>*]:overflow-hidden [&>*]:text-ellipsis [&>*]:min-w-0 tabular-nums", "tabular-nums",
flow.cancelled && "text-text-subtlest", flow.cancelled && "text-text-subtlest",
selected selected
? "text-text bg-surface-highlight group-focus-within/flows:bg-surface-active" ? "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 (open) states.push("Streaming");
if (flow.responseTruncated) states.push("Body cut at the capture limit"); if (flow.responseTruncated) states.push("Body cut at the capture limit");
if (flow.savedRequestId != null) states.push("Saved to Yaak"); if (flow.savedRequestId != null) states.push("Saved to Yaak");
const title = states.length === 0 ? undefined : states.join("\n"); const mark = (
return ( <span className="flex items-center justify-center">
<span className="flex items-center justify-center" title={title}>
{flow.error != null ? ( {flow.error != null ? (
<Icon icon="circle_alert" size="sm" color="danger" /> <Icon icon="circle_alert" size="sm" color="danger" />
) : flow.cancelled ? ( ) : flow.cancelled ? (
@@ -487,10 +538,28 @@ function FlowMark({ flow }: { flow: Flow }) {
) : null} ) : null}
</span> </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"; 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 */ /** An undecrypted tunnel still open: written as it opens, done once it closes */
function isOpenTunnel(flow: Flow): boolean { function isOpenTunnel(flow: Flow): boolean {
return flow.tunnelled && flow.elapsedMs == null && flow.error == null; 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) { switch (column) {
case "number": case "number":
return ( return (
<span className={classNames(mono, "text-right text-text-subtlest")}> <span className={classNames(cell, mono, "text-right text-text-subtlest")}>
{flow.number || ""} {flow.number || ""}
</span> </span>
); );
case "app": { case "app": {
if (flow.clientPath == null) return <span />; if (flow.clientPath == null) return <span className={cell} />;
const folder = folderOf(flow);
const name = flow.clientName ?? flow.clientPath; 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 ( return (
<span className="flex items-center" title={folder == null ? name : `${name} in ${folder}`}> <Tooltip
<AppIcon path={flow.clientPath} /> wrap
</span> 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": case "method":
@@ -526,63 +602,74 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
<HttpMethodTagRaw <HttpMethodTagRaw
method={flow.method} method={flow.method}
short 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. // Cut from the front, so the part that names the service stays.
return ( return (
<span className={classNames("rtl text-left", !flow.cancelled && "text-text-subtle")}> <Truncated text={host}>
<bdi>{hostOf(flow.url)}</bdi> <span
</span> className={classNames(cell, "rtl text-left", !flow.cancelled && "text-text-subtle")}
>
<bdi>{host}</bdi>
</span>
</Truncated>
); );
}
case "path": { case "path": {
// A tunnel's only path is its host; what it carried is unread. // 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); const { path, query } = splitPath(flow.url);
if (flow.streamKind == null) { if (flow.streamKind == null) {
return ( return (
<span> <Truncated text={path + query}>
{path} <span className={cell}>
<span className="text-text-subtlest">{query}</span> {path}
</span> <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, // A stream's message count rides the end of the path, so it survives the narrow width,
// ticking while the stream is open. // ticking while the stream is open.
return ( return (
<span className="flex items-center"> <Truncated text={path + query}>
<span className="truncate"> <span className={classNames(cell, "flex items-center")}>
{path} <span className="truncate">
<span className="text-text-subtlest">{query}</span> {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> </span>
<CountBadge </Truncated>
count={flow.messageCount}
showZero
color={isOpenStream(flow) ? "info" : undefined}
className="shrink-0 !mb-0 tabular-nums"
/>
</span>
); );
} }
case "status": case "status":
return (flow.cancelled || flow.tunnelled) && flow.status == null ? ( 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": case "type":
return failed && noResponse ? ( return failed && noResponse ? (
<span <Tooltip wrap content={flow.error ?? ""} delay={CONTROL_TOOLTIP_DELAY}>
className={classNames("text-danger text-xs", span && "col-span-2")} <span className={classNames(cell, "text-danger text-xs", span && "col-span-2")}>
title={flow.error ?? undefined} {flow.error}
> </span>
{flow.error} </Tooltip>
</span>
) : flow.cancelled && noResponse ? ( ) : 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) ?? "--"} {shortContentType(flow.contentType) ?? "--"}
</span> </span>
); );
@@ -590,6 +677,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
return ( return (
<span <span
className={classNames( className={classNames(
cell,
mono, mono,
"text-right", "text-right",
flow.responseTruncated ? "text-notice" : "text-text-subtle", 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)) { if (isOpenStream(flow) || isOpenTunnel(flow)) {
const open = Date.now() - Date.parse(utcTimestamp(flow.startedAt)); const open = Date.now() - Date.parse(utcTimestamp(flow.startedAt));
return ( 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)) : "--"} {Number.isFinite(open) ? formatMillis(Math.max(0, open)) : "--"}
</span> </span>
); );
@@ -611,6 +699,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
return ( return (
<span <span
className={classNames( className={classNames(
cell,
mono, mono,
"text-right", "text-right",
// A second or more stands out without a new colour. // 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": case "remote":
return ( return (
<span className={classNames(mono, "text-text-subtle")}>{flow.remoteAddr ?? "--"}</span> <span className={classNames(cell, mono, "text-text-subtle")}>
{flow.remoteAddr ?? "--"}
</span>
); );
case "version": case "version":
return ( return (
<span className={classNames(mono, "text-text-subtle")}> <span className={classNames(cell, mono, "text-text-subtle")}>
{flow.upstreamHttpVersion ?? flow.clientHttpVersion} {flow.upstreamHttpVersion ?? flow.clientHttpVersion}
</span> </span>
); );
+1 -1
View File
@@ -49,7 +49,7 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) {
<div <div
style={style} style={style}
className={classNames( 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", "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 ColumnId,
type ColumnPrefs, type ColumnPrefs,
type FlowSort, type FlowSort,
resetWidth,
resetWidths, resetWidths,
resizeColumn, resizeColumn,
toggleColumn, toggleColumn,
@@ -25,6 +26,8 @@ import { columnPrefsAtom } from "../lib/store";
interface Props { interface Props {
columns: Column[]; columns: Column[];
template: string; template: string;
/** The rows' width, as CSS, so the header scrolls sideways with them */
width: string;
prefs: ColumnPrefs; prefs: ColumnPrefs;
sort: FlowSort | null; sort: FlowSort | null;
onSort: (column: ColumnId) => void; 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, * The list's only control surface: a click on a label sorts by it, a second reverses it and a
* dragging the edge after a label resizes its column, and the picker sits in the empty corner on * third goes back to start time. Dragging the edge after a label resizes its column and a
* hover, or anywhere on a right-click. * 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({ export const FlowListHeader = memo(function FlowListHeader({
columns, columns,
template, template,
width,
prefs, prefs,
sort, sort,
onSort, onSort,
@@ -82,67 +87,68 @@ export const FlowListHeader = memo(function FlowListHeader({
}, []); }, []);
return ( return (
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the same menu as the picker // The same margins and padding as the rows, so each label sits over its cells.
<div <div style={{ width, minWidth: "100%" }} className="px-1.5">
onContextMenu={openMenu} {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the same menu as the picker */}
style={{ gridTemplateColumns: template }} <div
className={classNames( onContextMenu={openMenu}
"group/header grid items-center gap-x-3 h-xs px-1.5 mx-1.5 select-none", style={{ gridTemplateColumns: template }}
"text-xs text-text-subtlest border-b border-border-subtle", 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 <span className="flex items-center justify-center">
size="xs" <Dropdown items={items}>
icon="settings_2" <IconButton
title="Columns" size="xs"
iconColor="secondary" icon="settings_2"
className="opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 !h-5 !w-5" title="Columns"
/> tooltip
</Dropdown> iconColor="secondary"
</span> className="opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 !h-5 !w-5"
{columns.map((c) => ( />
<span key={c.id} className="relative h-full flex items-center min-w-0"> </Dropdown>
<button </span>
type="button" {columns.map((c) => (
onClick={() => onSort(c.id)} <span key={c.id} className="relative h-full flex items-center min-w-0 px-1.5">
// No tooltip: the header sits under the search field, and a tooltip here covered it <button
aria-label={`Sort by ${c.label}`} type="button"
className={classNames( onClick={() => onSort(c.id)}
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap hover:text-text-subtle", // No tooltip: the header sits under the search field, and a tooltip here covered it
c.align === "right" && "justify-end", aria-label={`Sort by ${c.label}`}
sort?.column === c.id && "text-text-subtle", 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 && ( sort?.column === c.id && "text-text-subtle",
<Icon )}
icon="chevron_down" >
size="xs" {sort?.column === c.id && (
className={classNames("shrink-0", !sort.descending && "rotate-180")} <Icon
/> icon="chevron_down"
)} size="xs"
<span className="truncate">{c.id === "app" ? "" : c.label}</span> className={classNames("shrink-0", !sort.descending && "rotate-180")}
</button> />
{c.id !== "path" && ( )}
<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 <ResizeHandle
side="right" side="right"
justify="center" 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={() => { onResizeStart={() => {
startWidth.current = c.current; startWidth.current = c.current;
}} }}
onResizeMove={(e) => onResizeMove={(e) =>
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + e.x - e.xStart)) setPrefs((p) => resizeColumn(p, c.id, startWidth.current + e.x - e.xStart))
} }
onReset={() => onReset={() => setPrefs((p) => resetWidth(p, c.id))}
setPrefs((p) => ({ ...p, widths: { ...p.widths, [c.id]: undefined } }))
}
/> />
)} </span>
</span> ))}
))} <ContextMenu triggerPosition={menu} items={items} onClose={() => setMenu(null)} />
<ContextMenu triggerPosition={menu} items={items} onClose={() => setMenu(null)} /> </div>
</div> </div>
); );
}); });
@@ -11,6 +11,8 @@ vi.mock("@yaakapp-internal/ui", () => ({
), ),
EmptyStateText: ({ children }: { children: ReactNode }) => <div>{children}</div>, EmptyStateText: ({ children }: { children: ReactNode }) => <div>{children}</div>,
LoadingIcon: () => <span>LOADING</span>, LoadingIcon: () => <span>LOADING</span>,
CONTROL_TOOLTIP_DELAY: 0,
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
})); }));
const data = (truncated = false): FlowBodyData => ({ const data = (truncated = false): FlowBodyData => ({
+12 -4
View File
@@ -1,5 +1,11 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize"; 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 { HttpBodyResult } from "@yaakapp-internal/ui/httpTypes";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { FlowBodyData } from "../lib/flowViews"; import type { FlowBodyData } from "../lib/flowViews";
@@ -39,9 +45,11 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<Banner color="notice" className="text-sm mb-2"> <Banner color="notice" className="text-sm mb-2">
<span title="The app sent all of it"> <Tooltip wrap content="The app sent all of it" delay={CONTROL_TOOLTIP_DELAY}>
First {formatSize(body.data.storedSize)} of {formatSize(requestSize)} kept <span>
</span> First {formatSize(body.data.storedSize)} of {formatSize(requestSize)} kept
</span>
</Tooltip>
</Banner> </Banner>
<div className="min-h-0">{editor}</div> <div className="min-h-0">{editor}</div>
</div> </div>
+17 -6
View File
@@ -1,10 +1,12 @@
import type { Flow } from "@yaakapp-internal/proxy-lib"; import type { Flow } from "@yaakapp-internal/proxy-lib";
import { import {
CONTROL_TOOLTIP_DELAY,
EventViewer, EventViewer,
EventViewerRow, EventViewerRow,
formatMillis, formatMillis,
Icon, Icon,
type IconProps, type IconProps,
Tooltip,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import { useMemo } from "react"; import { useMemo } from "react";
import { type FlowPhase, flowPhases, type PhaseKind } from "../lib/phases"; import { type FlowPhase, flowPhases, type PhaseKind } from "../lib/phases";
@@ -35,12 +37,21 @@ export function FlowTimeline({ flow }: { flow: Flow }) {
onClick={onClick} onClick={onClick}
icon={<Icon icon={icon} color={color} size="sm" />} icon={<Icon icon={icon} color={color} size="sm" />}
content={ content={
<span title={event.detail}> event.detail == null ? (
{event.label} <span>{event.label}</span>
{event.detail != null && ( ) : (
<span className="text-text-subtlest">{` ${event.detail}`}</span> <Tooltip
)} wrap
</span> 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" columns="auto minmax(0,1fr) 6rem 6rem"
cells={{ cells={{
+19 -7
View File
@@ -1,6 +1,6 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize"; import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib"; 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 { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { type DecryptSubject, detailActions, listEdit, setDecrypted } from "../lib/decrypt"; import { type DecryptSubject, detailActions, listEdit, setDecrypted } from "../lib/decrypt";
import { sidebarPath } from "../lib/search"; import { sidebarPath } from "../lib/search";
@@ -46,13 +46,23 @@ export function NotDecrypted({ flow }: { flow: Flow }) {
<div className="font-mono text-xs">{facts.join(" · ")}</div> <div className="font-mono text-xs">{facts.join(" · ")}</div>
{flow.error != null && <div className="text-danger">{flow.error}</div>} {flow.error != null && <div className="text-danger">{flow.error}</div>}
{actions.listed ? ( {actions.listed ? (
<div title="Still tunnelled? The app may pin its certificate"> <Tooltip
Decrypting new connections wrap
</div> content="Still tunnelled? The app may pin its certificate"
delay={CONTROL_TOOLTIP_DELAY}
>
<div>Decrypting new connections</div>
</Tooltip>
) : actions.blocked != null ? ( ) : actions.blocked != null ? (
<div title="Remove it from the decrypt list to decrypt"> <Tooltip
Excluded by <span className="font-mono text-xs">{actions.blocked}</span> wrap
</div> 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"> <div className="flex gap-2 mt-1">
{actions.host && ( {actions.host && (
@@ -60,6 +70,7 @@ export function NotDecrypted({ flow }: { flow: Flow }) {
size="xs" size="xs"
variant="border" variant="border"
title={`Decrypt new connections to ${host}`} title={`Decrypt new connections to ${host}`}
tooltip
disabled={save.isPending} disabled={save.isPending}
onClick={() => decrypt(hostSubject)} onClick={() => decrypt(hostSubject)}
> >
@@ -71,6 +82,7 @@ export function NotDecrypted({ flow }: { flow: Flow }) {
size="xs" size="xs"
variant="border" variant="border"
title={`Decrypt new connections from ${app}`} title={`Decrypt new connections from ${app}`}
tooltip
disabled={save.isPending} disabled={save.isPending}
onClick={() => decrypt(appSubject)} onClick={() => decrypt(appSubject)}
> >
+7 -2
View File
@@ -63,13 +63,14 @@ export function ProxyLayout() {
included, falls through to the header, which drags the window. */} included, falls through to the header, which drags the window. */}
<div <div
data-tauri-drag-region 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"> <div data-tauri-drag-region className="flex items-center gap-1 pl-1 whitespace-nowrap">
<IconButton <IconButton
className="pointer-events-auto" className="pointer-events-auto"
size="sm" size="sm"
title="Toggle sidebar" title="Toggle sidebar"
tooltip
icon={isHidden ? "left_panel_hidden" : "left_panel_visible"} icon={isHidden ? "left_panel_hidden" : "left_panel_visible"}
iconColor="secondary" iconColor="secondary"
onClick={() => { onClick={() => {
@@ -93,6 +94,7 @@ export function ProxyLayout() {
className="pointer-events-auto" className="pointer-events-auto"
icon={flowLayout === "horizontal" ? "columns_2" : "rows_2"} icon={flowLayout === "horizontal" ? "columns_2" : "rows_2"}
title={`Change to ${flowLayout === "horizontal" ? "vertical" : "horizontal"} layout`} title={`Change to ${flowLayout === "horizontal" ? "vertical" : "horizontal"} layout`}
tooltip
size="sm" size="sm"
iconColor="secondary" iconColor="secondary"
onClick={() => onClick={() =>
@@ -103,6 +105,7 @@ export function ProxyLayout() {
className="pointer-events-auto" className="pointer-events-auto"
icon="settings" icon="settings"
title="Settings" title="Settings"
tooltip
size="sm" size="sm"
iconColor="secondary" iconColor="secondary"
onClick={() => openSettings()} onClick={() => openSettings()}
@@ -139,6 +142,7 @@ export function ProxyLayout() {
<IconButton <IconButton
size="sm" size="sm"
title="Toggle sidebar" title="Toggle sidebar"
tooltip
icon="left_panel_visible" icon="left_panel_visible"
iconColor="secondary" iconColor="secondary"
onClick={() => setFloatingSidebarHidden(true)} onClick={() => setFloatingSidebarHidden(true)}
@@ -188,7 +192,8 @@ function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
return ( return (
<SplitLayout <SplitLayout
storageKey="proxy_detail" 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} layout={flowLayout}
defaultRatio={0.55} defaultRatio={0.55}
firstSlot={({ style }) => <FlowList style={style} className="pt-1" />} firstSlot={({ style }) => <FlowList style={style} className="pt-1" />}
+10 -5
View File
@@ -6,11 +6,13 @@ import type {
} from "@yaakapp-internal/proxy-lib"; } from "@yaakapp-internal/proxy-lib";
import { import {
Banner, Banner,
CONTROL_TOOLTIP_DELAY,
Dialog, Dialog,
DialogFooter, DialogFooter,
HttpMethodTagRaw, HttpMethodTagRaw,
LoadingIcon, LoadingIcon,
PlainInput, PlainInput,
Tooltip,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import { unnamedRequestName } from "@yaakapp-internal/ui/resolvedModelName"; import { unnamedRequestName } from "@yaakapp-internal/ui/resolvedModelName";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
@@ -181,13 +183,16 @@ function SaveForm({
</div> </div>
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{p.droppedHeaders.map((h) => ( {p.droppedHeaders.map((h) => (
<span <Tooltip
key={h.name} key={h.name}
title={`${h.name}: ${h.value}\n${reasonLabel(h.reason)}`} wrap
className="font-mono text-xs leading-[22px] px-1.5 rounded-sm border border-border-subtle text-text-subtle line-through decoration-text-subtlest" content={`${h.name}: ${h.value}\n${reasonLabel(h.reason)}`}
delay={CONTROL_TOOLTIP_DELAY}
> >
{h.name} <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">
</span> {h.name}
</span>
</Tooltip>
))} ))}
</div> </div>
</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 type { Extension } from "@codemirror/state";
import { Compartment } from "@codemirror/state"; import { Compartment } from "@codemirror/state";
import type { EditorView } from "@codemirror/view"; import { EditorView, tooltips, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { import {
Dropdown, Dropdown,
Icon, Icon,
@@ -11,8 +11,17 @@ import {
LazyCodeEditor, LazyCodeEditor,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import { queryFilter } from "@yaakapp-internal/ui/editor"; import { queryFilter } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; 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 { useRpcQuery } from "../hooks/useRpcQuery";
import { loadSeenValues, savedCompletions, searchProblems } from "../lib/searchBox"; import { loadSeenValues, savedCompletions, searchProblems } from "../lib/searchBox";
import { 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 * the URL, method, status and content type, and `@field:value` terms the rest, highlighted and
* completed as Yaak's sidebar filter is, with the values the proxy has seen. The backend runs * completed as Yaak's sidebar filter is, with the values the proxy has seen. The backend runs
* it and marks what it cannot. Its placeholder counts the flows. ⌘F focuses it and Escape * 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() { export const SearchBar = memo(function SearchBar() {
const [text, setText] = useAtom(searchTextAtom); const [text, setText] = useAtom(searchTextAtom);
@@ -48,6 +58,24 @@ export const SearchBar = memo(function SearchBar() {
const locked = useAtomValue(lockedAtom) != null; const locked = useAtomValue(lockedAtom) != null;
const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema; const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema;
const handleRef = useRef<InputHandle | null>(null); 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(() => { useEffect(() => {
const timer = setTimeout(() => setTerms(text), SEARCH_DEBOUNCE_MS); const timer = setTimeout(() => setTerms(text), SEARCH_DEBOUNCE_MS);
@@ -81,7 +109,7 @@ export const SearchBar = memo(function SearchBar() {
}), }),
[schema, store], [schema, store],
); );
const extensions = useMemo(() => compartment.current.of(filter), [filter]); const extensions = useMemo(() => [compartment.current.of(filter), completionAcrossBox], [filter]);
useEffect(() => { useEffect(() => {
handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) }); handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) });
}, [filter]); }, [filter]);
@@ -93,67 +121,177 @@ export const SearchBar = memo(function SearchBar() {
}; };
return ( return (
<Input // Holds the header's place for the box, which floats over the list while focused
renderEditor={(props) => ( <div ref={boxRef} className="relative h-sm">
<LazyCodeEditor {...props} keyDownHandler={startCompletionOnCtrlSpace} /> <div
)} style={place ?? undefined}
setRef={(h) => { className={classNames(place == null ? "absolute inset-x-0 top-0" : "fixed z-30")}
handleRef.current = h; >
}} <Input
label="Search flows" renderEditor={(props) => (
hideLabel <LazyCodeEditor {...props} keyDownHandler={startCompletionOnCtrlSpace} />
size="sm" )}
className="font-mono text-xs" setRef={(h) => {
stateKey={null} handleRef.current = h;
language={null} }}
wrapLines={false} label="Search flows"
placeholder={locked ? "Traffic is locked" : placeholderFor(total)} hideLabel
disabled={locked} size="sm"
defaultValue={text} className="font-mono text-xs"
forceUpdateKey={String(version)} containerClassName={classNames(focused && "bg-surface shadow-lg")}
onChange={setText} stateKey={null}
extensions={extensions} language={null}
onKeyDown={(e) => { wrapLines={focused}
if (e.key === "Escape" && handleRef.current?.value() !== "") { onFocus={() => setFocused(true)}
e.stopPropagation(); onBlur={() => setFocused(false)}
clear(); placeholder={locked ? "Traffic is locked" : placeholderFor(total)}
} disabled={locked}
}} defaultValue={text}
leftSlot={ forceUpdateKey={String(version)}
<Icon icon="search" size="sm" className="ml-2 mr-1 self-center text-text-subtlest" /> onChange={setText}
} extensions={extensions}
rightSlot={ onKeyDown={(e) => {
text === "" ? ( if (e.key === "Escape" && handleRef.current?.value() !== "") {
<kbd className="self-center ml-2 mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest"> e.stopPropagation();
⌘F clear();
</kbd> }
) : ( }}
<div className="flex items-center self-center ml-1 mr-0.5"> leftSlot={
<Dropdown <Icon icon="search" size="sm" className="ml-2 mr-1 self-center text-text-subtlest" />
items={[ }
{ rightSlot={
label: "Save search", text === "" ? (
leftSlot: <Icon icon="bookmark" />, <kbd className="self-center ml-2 mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
onSelect: () => setSavedDialog({ mode: "save", query: text.trim() }), ⌘F
}, </kbd>
]} ) : (
> <div className="flex items-center self-center ml-1 mr-0.5">
<IconButton size="xs" icon="ellipsis" title="Search actions" iconColor="secondary" /> <Dropdown
</Dropdown> items={[
<IconButton {
size="xs" label: "Save search",
icon="x" leftSlot: <Icon icon="bookmark" />,
title="Clear search" onSelect: () => setSavedDialog({ mode: "save", query: text.trim() }),
iconColor="secondary" },
onClick={clear} ]}
/> >
</div> <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 * 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 * Yaak's editors, whose shared editor leaves the key to each app
+49 -4
View File
@@ -1,8 +1,10 @@
import { import {
ContextMenu, ContextMenu,
CONTROL_TOOLTIP_DELAY,
type DropdownItem, type DropdownItem,
Icon, Icon,
IconButton, IconButton,
Tooltip,
Tree, Tree,
type TreeProps, type TreeProps,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
@@ -14,9 +16,11 @@ import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { useRpcMutation } from "../hooks/useRpcMutation"; import { useRpcMutation } from "../hooks/useRpcMutation";
import { SIDEBAR_TREE_ID } from "../lib/atoms"; import { SIDEBAR_TREE_ID } from "../lib/atoms";
import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt"; 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 { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree"; import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree";
import { hideSystemAtom, noteSidebarInput, sidebarTreeAtom } from "../lib/store"; import { hideSystemAtom, noteSidebarInput, sidebarTreeAtom } from "../lib/store";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast"; import { useShowToast } from "../lib/toast";
import { AppIcon } from "./AppIcon"; import { AppIcon } from "./AppIcon";
import { savedSearchDialogAtom } from "./SavedSearchDialog"; 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 === "saved" && <Icon icon="bookmark" size="sm" className="text-text-subtle" />}
{item.kind === "folder" && <Icon icon="folder" 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} />} {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 && ( {item.sublabel != null && (
<span className="truncate text-xs text-text-subtlest">{item.sublabel}</span> <span className="truncate text-xs text-text-subtlest">{item.sublabel}</span>
)} )}
@@ -89,7 +100,9 @@ function DecryptedMark({ item }: { item: SidebarItem }) {
const list = useDecryptList(); const list = useDecryptList();
if (item.decrypt == null || !isDecrypted(list, item.decrypt)) return null; if (item.decrypt == null || !isDecrypted(list, item.decrypt)) return null;
return ( 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"} icon={hidden ? "eye_closed" : "eye"}
iconColor="secondary" iconColor="secondary"
title={hidden ? "Show System in All traffic" : "Hide System from All traffic"} title={hidden ? "Show System in All traffic" : "Hide System from All traffic"}
tooltip
className={classNames( className={classNames(
"!h-4 !w-4 mr-1", "!h-4 !w-4 mr-1",
!hidden && "opacity-0 [.tree-item:hover_&]:opacity-100 focus-visible:opacity-100", !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" }), 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. // Decrypt or Stop Decrypting the selected hosts, sites and apps, from new connections on.
// A saved search is renamed or deleted. // A saved search is renamed or deleted.
const getContextMenu = useCallback( const getContextMenu = useCallback(
(items: SidebarItem[]): DropdownItem[] => { (items: SidebarItem[]): DropdownItem[] => {
const paths = items.length === 1 ? pathItems(items[0]) : [];
const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }])); const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }]));
if (saved.length > 0) { if (saved.length > 0) {
const [first] = saved; const [first] = saved;
@@ -164,8 +207,10 @@ export function Sidebar() {
} }
const subjects = items.flatMap((i) => (i.decrypt == null ? [] : [i.decrypt])); const subjects = items.flatMap((i) => (i.decrypt == null ? [] : [i.decrypt]));
const action = decryptMenuAction(decryptList, subjects); const action = decryptMenuAction(decryptList, subjects);
if (action == null) return []; if (action == null) return paths;
return [ return [
...paths,
...(paths.length > 0 ? [{ type: "separator" } as const] : []),
{ {
label: action.label, label: action.label,
icon: action.on ? "lock_open" : "lock", 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"]>>( const renderContextMenu = useCallback<NonNullable<TreeProps<SidebarItem>["renderContextMenu"]>>(
({ items, position, onClose }) => ({ 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"; import { fixtures } from "./fixtures";
/** /**
@@ -42,7 +42,9 @@ function answer(cmd: string, payload: unknown): unknown {
const req = (payload ?? {}) as Record<string, unknown>; const req = (payload ?? {}) as Record<string, unknown>;
switch (cmd) { switch (cmd) {
case "get_flow_body": { 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)}`); if (fixture == null) throw new Error(`No fixture for ${String(req.flowId)}`);
return req.which === "request" ? fixture.requestBody : fixture.responseBody; return req.which === "request" ? fixture.requestBody : fixture.responseBody;
} }
@@ -86,12 +88,77 @@ function answer(cmd: string, payload: unknown): unknown {
case "list_actions": case "list_actions":
return { actions: [] }; return { actions: [] };
case "list_flows": { 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 }; return { flows, total: flows.length, matching: null };
} }
case "list_seen": case "list_seen":
return { hosts: [], apps: [] }; 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: default:
throw new Error(`Not in the harness: ${cmd}`); 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, type ColumnPrefs,
defaultColumnPrefs, defaultColumnPrefs,
gridTemplate, gridTemplate,
gridWidth,
nextSort, nextSort,
parseColumnPrefs, parseColumnPrefs,
resetWidth,
resetWidths, resetWidths,
resizeColumn, resizeColumn,
shownColumns, shownColumns,
sortFlows, sortFlows,
toggleColumn, toggleColumn,
WIDE_MIN_WIDTH,
} from "./columns"; } from "./columns";
function flow(number: number, extra: Partial<Flow> = {}): Flow { function flow(number: number, extra: Partial<Flow> = {}): Flow {
@@ -33,11 +34,11 @@ function flow(number: number, extra: Partial<Flow> = {}): Flow {
} as 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", () => { describe("shownColumns", () => {
test("shows the wide set when there is room, and the narrow set when not", () => { test("shows every visible column, however narrow the list", () => {
expect(ids(defaultColumnPrefs, WIDE_MIN_WIDTH)).toEqual([ expect(ids(defaultColumnPrefs)).toEqual([
"number", "number",
"app", "app",
"method", "method",
@@ -48,39 +49,31 @@ describe("shownColumns", () => {
"size", "size",
"time", "time",
]); ]);
expect(ids(defaultColumnPrefs, WIDE_MIN_WIDTH - 1)).toEqual([
"app",
"method",
"host",
"path",
"status",
"time",
]);
}); });
test("follows the picker, and always keeps the path", () => { test("follows the picker, and always keeps the path", () => {
let prefs = toggleColumn(defaultColumnPrefs, "remote"); let prefs = toggleColumn(defaultColumnPrefs, "remote");
prefs = toggleColumn(prefs, "type"); prefs = toggleColumn(prefs, "type");
prefs = toggleColumn(prefs, "path"); prefs = toggleColumn(prefs, "path");
expect(ids(prefs, 2000)).toContain("remote"); expect(ids(prefs)).toContain("remote");
expect(ids(prefs, 2000)).not.toContain("type"); expect(ids(prefs)).not.toContain("type");
expect(ids(prefs, 2000)).toContain("path"); expect(ids(prefs)).toContain("path");
// Off by default, and outside the narrow set.
expect(ids(prefs, 400)).not.toContain("remote");
}); });
test("lays out fixed widths with the path taking the rest", () => { test("lays out every column at a fixed width, the path included", () => {
const prefs = resizeColumn(defaultColumnPrefs, "host", 240.4); let prefs = resizeColumn(defaultColumnPrefs, "host", 240.4);
const template = gridTemplate(shownColumns(prefs, 1200)); prefs = resizeColumn(prefs, "path", 500);
expect(template).toBe("22px 46px 18px 50px 240px minmax(80px,1fr) 46px 50px 72px 64px"); 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", () => { test("keeps the host's width in a narrow list", () => {
const host = (width: number) => const host = shownColumns(defaultColumnPrefs).find((c) => c.id === "host");
shownColumns(defaultColumnPrefs, width).find((c) => c.id === "host"); expect(host?.current).toBe(190);
expect(host(2000)?.current).toBe(190);
expect(host(500)?.current).toBe(120);
expect(host(100)?.current).toBe(60);
}); });
}); });
@@ -96,19 +89,36 @@ describe("column preferences", () => {
expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs); expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs);
expect(resetWidths(prefs).widths).toEqual({}); 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", () => { 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, "size")).toEqual({ column: "size", descending: true });
expect(nextSort(null, "host")).toEqual({ column: "host", descending: false }); expect(nextSort(null, "host")).toEqual({ column: "host", descending: false });
expect(nextSort({ column: "host", descending: false }, "host")).toEqual({ expect(nextSort({ column: "host", descending: false }, "host")).toEqual({
column: "host", column: "host",
descending: true, 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 a = flow(1, { responseSize: 10, elapsedMs: 5, url: "https://b.com/" });
const b = flow(2, { responseSize: 300, elapsedMs: null, url: "https://a.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/" }); 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) => const by = (column: Parameters<typeof nextSort>[1], descending: boolean) =>
sortFlows(flows, { column, descending }).map((f) => f.number); sortFlows(flows, { column, descending }).map((f) => f.number);
expect(by("size", true)).toEqual([2, 1, 3, 4]); 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", 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("host", false)).toEqual([2, 4, 1, 3]);
expect(by("number", true)).toEqual([4, 3, 2, 1]); expect(by("number", true)).toEqual([4, 3, 2, 1]);
// Failures sort below every status. // Failures sort below every status.
expect(by("status", true)).toEqual([1, 2, 3, 4]); expect(by("status", true)).toEqual([1, 2, 3, 4]);
expect(by("status", false)).toEqual([4, 1, 2, 3]);
expect(sortFlows(flows, null)).toBe(flows); 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; id: ColumnId;
/** In the header and the picker */ /** In the header and the picker */
label: string; 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; width: number;
minWidth: number; minWidth: number;
align: "left" | "right"; align: "left" | "right";
/** Shown until the person hides it */ /** Shown until the person hides it */
defaultVisible: boolean; defaultVisible: boolean;
/** Kept when the list is narrow; the rest drop out below `WIDE_MIN_WIDTH` */
narrow: boolean;
} }
export const COLUMNS: ColumnDef[] = [ export const COLUMNS: ColumnDef[] = [
@@ -36,7 +34,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 30, minWidth: 30,
align: "right", align: "right",
defaultVisible: true, defaultVisible: true,
narrow: false,
}, },
{ {
id: "app", id: "app",
@@ -45,7 +42,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 18, minWidth: 18,
align: "left", align: "left",
defaultVisible: true, defaultVisible: true,
narrow: true,
}, },
{ {
id: "method", id: "method",
@@ -54,7 +50,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 40, minWidth: 40,
align: "left", align: "left",
defaultVisible: true, defaultVisible: true,
narrow: true,
}, },
{ {
id: "host", id: "host",
@@ -63,16 +58,14 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 60, minWidth: 60,
align: "left", align: "left",
defaultVisible: true, defaultVisible: true,
narrow: true,
}, },
{ {
id: "path", id: "path",
label: "Path", label: "Path",
width: 0, width: 360,
minWidth: 80, minWidth: 80,
align: "left", align: "left",
defaultVisible: true, defaultVisible: true,
narrow: true,
}, },
{ {
id: "status", id: "status",
@@ -81,7 +74,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 34, minWidth: 34,
align: "left", align: "left",
defaultVisible: true, defaultVisible: true,
narrow: true,
}, },
{ {
id: "type", id: "type",
@@ -90,7 +82,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 34, minWidth: 34,
align: "left", align: "left",
defaultVisible: true, defaultVisible: true,
narrow: false,
}, },
{ {
id: "size", id: "size",
@@ -99,7 +90,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 44, minWidth: 44,
align: "right", align: "right",
defaultVisible: true, defaultVisible: true,
narrow: false,
}, },
{ {
id: "time", id: "time",
@@ -108,7 +98,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 44, minWidth: 44,
align: "right", align: "right",
defaultVisible: true, defaultVisible: true,
narrow: true,
}, },
{ {
id: "remote", id: "remote",
@@ -117,7 +106,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 60, minWidth: 60,
align: "left", align: "left",
defaultVisible: false, defaultVisible: false,
narrow: false,
}, },
{ {
id: "version", id: "version",
@@ -126,7 +114,6 @@ export const COLUMNS: ColumnDef[] = [
minWidth: 44, minWidth: 44,
align: "left", align: "left",
defaultVisible: false, defaultVisible: false,
narrow: false,
}, },
]; ];
@@ -138,11 +125,11 @@ export function columnDef(id: ColumnId): ColumnDef {
return def; 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 */ /** 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 */ /** What the person chose: which columns show, and the widths they dragged, by id */
export interface ColumnPrefs { export interface ColumnPrefs {
@@ -153,32 +140,18 @@ export interface ColumnPrefs {
export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {} }; export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {} };
export interface Column extends ColumnDef { 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; current: number;
} }
/** /**
* The columns shown at `listWidth`, in order: the visible ones, less those outside the narrow set * The columns shown, in order: the visible ones, the path always. Each keeps its width however
* when the list is narrower than `WIDE_MIN_WIDTH`. The path always shows. Measured, not by * narrow the list is; the list scrolls sideways instead.
* layout, so a wide window side by side still gets size and type.
*/ */
export function shownColumns(prefs: ColumnPrefs, listWidth: number): Column[] { export function shownColumns(prefs: ColumnPrefs): Column[] {
const wide = listWidth >= WIDE_MIN_WIDTH; return COLUMNS.filter((c) => c.id === "path" || (prefs.visible[c.id] ?? c.defaultVisible)).map(
return COLUMNS.filter((c) => { (c) => ({ ...c, current: columnWidth(prefs, c.id) }),
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 columnWidth(prefs: ColumnPrefs, id: ColumnId): number { 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)); 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 { export function gridTemplate(columns: Column[]): string {
const cells = columns.map((c) => const cells = columns.map((c) => `calc(${c.current}px + ${CELL_PADDING})`);
c.id === "path" ? `minmax(${c.minWidth}px,1fr)` : `${c.current}px`,
);
return [`${MARK_WIDTH}px`, ...cells].join(" "); 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 { export function toggleColumn(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs {
if (id === "path") return prefs; if (id === "path") return prefs;
const shown = prefs.visible[id] ?? columnDef(id).defaultVisible; 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 { 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)); const w = Math.max(columnDef(id).minWidth, Math.round(width));
return { ...prefs, widths: { ...prefs.widths, [id]: w } }; return { ...prefs, widths: { ...prefs.widths, [id]: w } };
} }
@@ -210,6 +186,12 @@ export function resetWidths(prefs: ColumnPrefs): ColumnPrefs {
return { ...prefs, widths: {} }; 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 * 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. * 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"; 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 { * A click on a column's label: sort by it, reverse the sort it has, then, from the reversed sort,
if (sort?.column === column) return { column, descending: !sort.descending }; * go back to start time
return { column, descending: firstDirection(column) }; */
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" }); 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 * The flows in `sort`'s order, those without a value (none, empty or zero) lowest, so first
* keep arrival order. Sorts the flows given, which are the ones loaded, not the whole database. * 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[] { export function sortFlows(flows: Flow[], sort: FlowSort | null): Flow[] {
if (sort == null) return flows; if (sort == null) return flows;
const dir = sort.descending ? -1 : 1; 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) => { 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; 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)); else c = collator.compare(String(a.key), String(b.key));
return c === 0 ? a.i - b.i : c * dir; 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); vi.mock("@tauri-apps/plugin-opener", () => opener);
const { openLink, revealPath } = await import("./opener"); const { openLink, revealLabel, revealPath } = await import("./opener");
describe("opener", () => { describe("opener", () => {
beforeEach(() => { beforeEach(() => {
@@ -39,4 +39,10 @@ describe("opener", () => {
await openLink("https://yaak.app", report); await openLink("https://yaak.app", report);
expect(report).toHaveBeenCalledWith("Failed to open https://yaak.app: Error: denied"); 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)}`); 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"); 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", () => { test("a folder row selects that program in that folder", () => {
const query = items.get("app:claude|cwd:yaak")?.query ?? ""; const query = items.get("app:claude|cwd:yaak")?.query ?? "";
expect(query).toBe("@app:claude @cwd:yaak"); 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", () => { describe("decrypting from the sidebar", () => {
const tunnel = flow("https://bank.example.com", { const tunnel = flow("https://bank.example.com", {
...slack, ...slack,
+18 -1
View File
@@ -37,6 +37,11 @@ export interface SidebarItem {
decrypt?: DecryptSubject; decrypt?: DecryptSubject;
/** The saved search a Saved row is, for renaming and deleting it */ /** The saved search a Saved row is, for renaming and deleting it */
savedId?: string; 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 */ /** A saved search as the sidebar lists it, with how many flows it covers */
@@ -62,6 +67,8 @@ interface PlaceRow {
label: string; label: string;
query: string; query: string;
decrypt?: DecryptSubject; decrypt?: DecryptSubject;
/** A folder row's working directory, in full */
path?: string;
} }
/** /**
@@ -128,8 +135,15 @@ function placeOf(flow: Flow): Place {
} else { } else {
const folderId = `app:${app}|cwd:${folder}`; const folderId = `app:${app}|cwd:${folder}`;
const folderQuery = `${appQuery} ${fieldTerm("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 = [ appRows = [
{ id: folderId, kind: "folder", label: folder, query: folderQuery }, folderRow,
...rows.map((r) => ({ ...rows.map((r) => ({
...r, ...r,
id: `${folderId}|${r.id}`, id: `${folderId}|${r.id}`,
@@ -169,6 +183,7 @@ function child(parent: Trie, row: PlaceRow): Trie {
children: new Map(), children: new Map(),
}; };
if (row.decrypt != null) node.item.decrypt = row.decrypt; if (row.decrypt != null) node.item.decrypt = row.decrypt;
if (row.path != null) node.item.path = row.path;
parent.children.set(row.id, node); parent.children.set(row.id, node);
} }
node.item.count++; node.item.count++;
@@ -257,6 +272,8 @@ export function buildSidebarTree(
} }
if (folder != null) item.sublabel = folder; if (folder != null) item.sublabel = folder;
if (flow.clientPath != null) item.appPath = flow.clientPath; 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() }; app = { item, children: new Map() };
parent.children.set(id, app); parent.children.set(id, app);
} }
+19 -2
View File
@@ -4,6 +4,10 @@ import type { HTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react"; import { forwardRef } from "react";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { LoadingIcon } from "./LoadingIcon"; 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 ButtonVariant = "border" | "solid" | "input";
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto"; type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
@@ -22,6 +26,8 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onC
forDropdown?: boolean; forDropdown?: boolean;
disabled?: boolean; disabled?: boolean;
title?: string; title?: string;
/** Shows `title` in Yaak's tooltip, quickly, rather than the browser's */
tooltip?: boolean;
leftSlot?: ReactNode; leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
}; };
@@ -43,6 +49,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
rightSlot, rightSlot,
disabled, disabled,
title, title,
tooltip,
onClick, onClick,
...props ...props
}: ButtonProps, }: ButtonProps,
@@ -50,8 +57,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
) { ) {
const resolvedColor = color ?? tone ?? "default"; const resolvedColor = color ?? tone ?? "default";
const isDisabled = disabled || isLoading; const isDisabled = disabled || isLoading;
const withTooltip = tooltip === true && title != null && title !== "";
return ( const button = (
<button <button
ref={ref} ref={ref}
type={type} type={type}
@@ -99,7 +107,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
title={title} title={withTooltip ? undefined : title}
aria-label={withTooltip ? title : undefined}
{...props} {...props}
> >
{isLoading ? ( {isLoading ? (
@@ -126,4 +135,12 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
)} )}
</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; onReset?: () => void;
side: "left" | "right" | "top"; side: "left" | "right" | "top";
justify: "center" | "end" | "start"; 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({ export function ResizeHandle({
@@ -30,6 +32,7 @@ export function ResizeHandle({
onResizeMove, onResizeMove,
onReset, onReset,
side, side,
fill = false,
}: Props) { }: Props) {
const vertical = side === "top"; const vertical = side === "top";
const [isResizing, setIsResizing] = useState<boolean>(false); const [isResizing, setIsResizing] = useState<boolean>(false);
@@ -89,8 +92,12 @@ export function ResizeHandle({
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
className={classNames( className={classNames(
className, className,
"group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full", "group z-10 flex select-none",
vertical ? "w-full h-1.5 cursor-row-resize" : "h-full w-1.5 cursor-col-resize", 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 === "center" && "justify-center",
justify === "end" && "justify-end", justify === "end" && "justify-end",
justify === "start" && "justify-start", justify === "start" && "justify-start",
@@ -99,6 +106,15 @@ export function ResizeHandle({
side === "top" && "top-0", 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 && ( {isResizing && (
<div <div
className={classNames( className={classNames(
+28 -9
View File
@@ -26,6 +26,11 @@ interface Props {
minWidthPx?: number; minWidthPx?: number;
layout?: SplitLayoutLayout; layout?: SplitLayoutLayout;
resizeHandleClassName?: string; 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 }; const baseProperties = { minHeight: 0, minWidth: 0 };
@@ -43,6 +48,7 @@ export function SplitLayout({
storageKey, storageKey,
layout = "responsive", layout = "responsive",
resizeHandleClassName, resizeHandleClassName,
gutter = 0,
defaultRatio = 0.5, defaultRatio = 0.5,
minHeightPx = 10, minHeightPx = 10,
minWidthPx = 10, minWidthPx = 10,
@@ -56,6 +62,7 @@ export function SplitLayout({
if (!secondSlot) { if (!secondSlot) {
height = 0; height = 0;
minHeightPx = 0; minHeightPx = 0;
gutter = 0;
} }
const size = useContainerSize(containerRef); const size = useContainerSize(containerRef);
@@ -67,19 +74,22 @@ export function SplitLayout({
const styles = useMemo<CSSProperties>( const styles = useMemo<CSSProperties>(
() => ({ () => ({
...style, ...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 // 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. // the drag ends, so the panes are not re-rendered on every move.
const liveDrag = useRef<SplitDrag | null>(null); const liveDrag = useRef<SplitDrag | null>(null);
const applySplit = useCallback((vertical: boolean, ratio: number) => { const applySplit = useCallback(
if (containerRef.current == null) return; (vertical: boolean, ratio: number) => {
containerRef.current.style.gridTemplate = splitGridTemplate(vertical, ratio); 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 // 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. // back; put the live one back before it is painted.
@@ -102,9 +112,16 @@ export function SplitLayout({
const $c = containerRef.current; const $c = containerRef.current;
if ($c == null) return 0; if ($c == null) return 0;
const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle($c); const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle($c);
// The panes share what the gutter leaves
return vertical return vertical
? $c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom) ? $c.clientHeight -
: $c.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight); Number.parseFloat(paddingTop) -
Number.parseFloat(paddingBottom) -
gutter
: $c.clientWidth -
Number.parseFloat(paddingLeft) -
Number.parseFloat(paddingRight) -
gutter;
}, },
apply: (ratio) => applySplit(vertical, ratio), apply: (ratio) => applySplit(vertical, ratio),
commit: (ratio) => (vertical ? setHeight(ratio) : setWidth(ratio)), commit: (ratio) => (vertical ? setHeight(ratio) : setWidth(ratio)),
@@ -115,6 +132,7 @@ export function SplitLayout({
renderedWidth, renderedWidth,
minHeightPx, minHeightPx,
minWidthPx, minWidthPx,
gutter,
applySplit, applySplit,
setHeight, setHeight,
setWidth, setWidth,
@@ -151,8 +169,9 @@ export function SplitLayout({
style={areaD} style={areaD}
className={classNames( className={classNames(
resizeHandleClassName, resizeHandleClassName,
vertical ? "-translate-y-1" : "-translate-x-1", gutter === 0 && (vertical ? "-translate-y-1" : "-translate-x-1"),
)} )}
fill={gutter > 0}
onResizeStart={handleResizeStart} onResizeStart={handleResizeStart}
onResizeMove={handleResizeMove} onResizeMove={handleResizeMove}
onResizeEnd={handleResizeEnd} onResizeEnd={handleResizeEnd}
+120 -50
View File
@@ -1,6 +1,6 @@
import classNames from "classnames"; import classNames from "classnames";
import type { CSSProperties, KeyboardEvent, ReactNode } from "react"; 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 { generateId } from "@yaakapp-internal/lib/generateId";
import { Portal } from "./Portal"; import { Portal } from "./Portal";
@@ -10,6 +10,15 @@ export interface TooltipProps {
tabIndex?: number; tabIndex?: number;
size?: "md" | "lg"; size?: "md" | "lg";
className?: string; 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 = { const hiddenStyles: CSSProperties = {
@@ -27,27 +36,46 @@ interface TooltipOpenState {
position: TooltipPosition; 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 [openState, setOpenState] = useState<TooltipOpenState | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLSpanElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null); const tooltipRef = useRef<HTMLDivElement>(null);
const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined); const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleOpenImmediate = () => { useEffect(() => () => clearTimeout(showTimeout.current), []);
if (triggerRef.current == null || tooltipRef.current == null) return;
clearTimeout(showTimeout.current); useLayoutEffect(() => {
const triggerRect = triggerRef.current.getBoundingClientRect(); 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 tooltipRect = tooltipRef.current.getBoundingClientRect();
const viewportHeight = document.documentElement.clientHeight; const viewportHeight = document.documentElement.clientHeight;
const viewportWidth = document.documentElement.clientWidth;
const margin = 8; const margin = 8;
const spaceAbove = Math.max(0, triggerRect.top - margin); const spaceAbove = Math.max(0, triggerRect.top - margin);
const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin); const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin);
const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove; const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove;
const position: TooltipPosition = preferBottom ? "bottom" : "top"; const position: TooltipPosition = preferBottom ? "bottom" : "top";
const centred = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
const styles: CSSProperties = { 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, maxHeight: position === "top" ? spaceAbove : spaceBelow,
...(position === "top" ...(position === "top"
? { bottom: viewportHeight - triggerRect.top } ? { bottom: viewportHeight - triggerRect.top }
@@ -55,25 +83,31 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }:
}; };
setOpenState({ styles, position }); setOpenState({ styles, position });
}, [open, wrap]);
const handleOpenImmediate = () => {
clearTimeout(showTimeout.current);
if (onlyWhenTruncated && !isTruncated(anchorOf(triggerRef.current, wrap))) return;
setOpen(true);
}; };
const handleOpen = () => { const handleOpen = () => {
clearTimeout(showTimeout.current); clearTimeout(showTimeout.current);
showTimeout.current = setTimeout(handleOpenImmediate, 500); showTimeout.current = setTimeout(handleOpenImmediate, delay);
}; };
const handleClose = () => { const handleClose = () => {
clearTimeout(showTimeout.current); clearTimeout(showTimeout.current);
setOpenState(null); setOpen(false);
}; };
const handleToggleImmediate = () => { const handleToggleImmediate = () => {
if (openState) handleClose(); if (open) handleClose();
else handleOpenImmediate(); else handleOpenImmediate();
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLSpanElement>) => {
if (openState && e.key === "Escape") { if (open && e.key === "Escape") {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleClose(); handleClose();
@@ -84,52 +118,88 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }:
return ( return (
<> <>
<Portal name="tooltip"> {open && (
<div <Portal name="tooltip">
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)]"
>
<div <div
ref={tooltipRef}
style={openState?.styles ?? hiddenStyles}
id={id.current}
role="tooltip"
onMouseEnter={wrap ? undefined : handleOpenImmediate}
onMouseLeave={wrap ? undefined : handleClose}
className={classNames( className={classNames(
"bg-surface-highlight rounded-md px-3 py-2 z-50 border border-border overflow-auto", "p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]",
size === "md" && "max-w-sm", wrap && "pointer-events-none",
size === "lg" && "max-w-md",
)} )}
> >
{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> </div>
<Triangle </Portal>
className="text-border" )}
position={openState?.position === "bottom" ? "top" : "bottom"} {wrap ? (
/> // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- hover only; what it wraps keeps its own focus and clicks
</div> <span
</Portal> ref={triggerRef}
{/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Needs to be usable in other buttons */} aria-describedby={openState ? id.current : undefined}
<span className={classNames(className, "contents")}
ref={triggerRef} onMouseEnter={handleOpen}
role="button" onMouseLeave={handleClose}
aria-describedby={openState ? id.current : undefined} onMouseDown={handleClose}
tabIndex={tabIndex ?? -1} >
className={classNames(className, "grow-0 flex items-center")} {children}
onClick={handleToggleImmediate} </span>
onMouseEnter={handleOpen} ) : (
onMouseLeave={handleClose} // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Needs to be usable in other buttons
onFocus={handleOpenImmediate} <span
onBlur={handleClose} ref={triggerRef}
onKeyDown={handleKeyDown} role="button"
> aria-describedby={openState ? id.current : undefined}
{children} tabIndex={tabIndex ?? -1}
</span> 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" }) { function Triangle({ className, position }: { className?: string; position: "top" | "bottom" }) {
const isBottom = position === "bottom"; const isBottom = position === "bottom";
@@ -6,6 +6,7 @@ import {
createHandleDrag, createHandleDrag,
createSidebarDrag, createSidebarDrag,
createSplitDrag, createSplitDrag,
splitGridTemplate,
} from "./resizeDrag"; } from "./resizeDrag";
let frames: Map<number, FrameRequestCallback>; 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", () => { describe("createSidebarDrag", () => {
function sidebar() { function sidebar() {
const apply = vi.fn(); 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 return vertical
? ` ? `
' left' minmax(0,${1 - ratio}fr) ' left' minmax(0,${1 - ratio}fr)
' drag' 0 ' drag' ${gutter}px
' right' minmax(0,${ratio}fr) ' right' minmax(0,${ratio}fr)
/ 1fr / 1fr
` `
: ` : `
' left drag right' minmax(0,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 type { BannerProps } from "./components/Banner";
export { Banner } from "./components/Banner"; export { Banner } from "./components/Banner";
export type { ButtonProps } from "./components/Button"; 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 type { CheckboxProps } from "./components/Checkbox";
export { Checkbox } from "./components/Checkbox"; export { Checkbox } from "./components/Checkbox";
export type { CopyIconButtonProps } from "./components/CopyIconButton"; export type { CopyIconButtonProps } from "./components/CopyIconButton";