From c78732ecafc44f5f789af09476322b6292ece756 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 9 Nov 2026 10:45:46 -0700 Subject: [PATCH] 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 --- .../components/ClearFlowsButton.tsx | 2 + .../components/DecryptListEditor.tsx | 55 ++-- apps/yaak-proxy/components/FirstRun.tsx | 21 +- apps/yaak-proxy/components/FlowCard.tsx | 30 +- apps/yaak-proxy/components/FlowDetail.tsx | 6 +- apps/yaak-proxy/components/FlowList.tsx | 233 +++++++++++----- apps/yaak-proxy/components/FlowListEmpty.tsx | 2 +- apps/yaak-proxy/components/FlowListHeader.tsx | 114 ++++---- .../components/FlowRequestBody.test.tsx | 2 + .../yaak-proxy/components/FlowRequestBody.tsx | 16 +- apps/yaak-proxy/components/FlowTimeline.tsx | 23 +- apps/yaak-proxy/components/NotDecrypted.tsx | 26 +- apps/yaak-proxy/components/ProxyLayout.tsx | 9 +- apps/yaak-proxy/components/SaveDialog.tsx | 15 +- apps/yaak-proxy/components/SearchBar.tsx | 264 +++++++++++++----- apps/yaak-proxy/components/Sidebar.tsx | 53 +++- apps/yaak-proxy/harness/backend.ts | 73 ++++- apps/yaak-proxy/lib/columns.test.ts | 87 ++++-- apps/yaak-proxy/lib/columns.ts | 100 +++---- apps/yaak-proxy/lib/opener.test.ts | 8 +- apps/yaak-proxy/lib/opener.ts | 7 + apps/yaak-proxy/lib/sidebarTree.test.ts | 13 + apps/yaak-proxy/lib/sidebarTree.ts | 19 +- packages/ui/src/components/Button.tsx | 21 +- packages/ui/src/components/ResizeHandle.tsx | 20 +- packages/ui/src/components/SplitLayout.tsx | 37 ++- packages/ui/src/components/Tooltip.tsx | 170 +++++++---- packages/ui/src/components/resizeDrag.test.ts | 18 ++ packages/ui/src/components/resizeDrag.ts | 7 +- packages/ui/src/index.ts | 2 +- 30 files changed, 1032 insertions(+), 421 deletions(-) diff --git a/apps/yaak-proxy/components/ClearFlowsButton.tsx b/apps/yaak-proxy/components/ClearFlowsButton.tsx index 08cc5cd2a..3e0058f89 100644 --- a/apps/yaak-proxy/components/ClearFlowsButton.tsx +++ b/apps/yaak-proxy/components/ClearFlowsButton.tsx @@ -54,6 +54,7 @@ export function ClearFlowsButton() { className="pointer-events-auto" icon="trash" title={locked != null ? "Clear flows (traffic is locked)" : "Clear flows (none yet)"} + tooltip size="sm" iconColor="secondary" disabled @@ -67,6 +68,7 @@ export function ClearFlowsButton() { className="pointer-events-auto" icon="trash" title="Clear flows" + tooltip size="sm" iconColor="secondary" /> diff --git a/apps/yaak-proxy/components/DecryptListEditor.tsx b/apps/yaak-proxy/components/DecryptListEditor.tsx index fa788ee95..779b4d323 100644 --- a/apps/yaak-proxy/components/DecryptListEditor.tsx +++ b/apps/yaak-proxy/components/DecryptListEditor.tsx @@ -1,7 +1,7 @@ import { Prec } from "@codemirror/state"; import { type EditorView, keymap } from "@codemirror/view"; import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib"; -import { Icon, IconButton, Input } from "@yaakapp-internal/ui"; +import { CONTROL_TOOLTIP_DELAY, Icon, IconButton, Input, Tooltip } from "@yaakapp-internal/ui"; import { filterAutocomplete } from "@yaakapp-internal/ui/editor"; import classNames from "classnames"; import { useMemo, useRef, useState } from "react"; @@ -71,28 +71,29 @@ function EntryRow({ entry.builtIn && "text-text-subtlest", )} > - + + + {entryLabel(entry.entry)} {entry.builtIn && ( - - built-in - + + + built-in + + )} - setError(null)} - /> - {error != null &&
{error}
} - + +
+ setError(null)} + /> + {error != null &&
{error}
} +
+
); } diff --git a/apps/yaak-proxy/components/FirstRun.tsx b/apps/yaak-proxy/components/FirstRun.tsx index a4b8faec4..f6efbc927 100644 --- a/apps/yaak-proxy/components/FirstRun.tsx +++ b/apps/yaak-proxy/components/FirstRun.tsx @@ -1,4 +1,11 @@ -import { Button, Icon, InlineCode, LoadingIcon } from "@yaakapp-internal/ui"; +import { + Button, + CONTROL_TOOLTIP_DELAY, + Icon, + InlineCode, + LoadingIcon, + Tooltip, +} from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import type { ReactNode } from "react"; @@ -127,9 +134,17 @@ export function FirstRun() { title="What to decrypt" description={ everything ? ( - Everything + + Everything + ) : ( - Pick as they appear + + Pick as they appear + ) } action={ diff --git a/apps/yaak-proxy/components/FlowCard.tsx b/apps/yaak-proxy/components/FlowCard.tsx index 977b04326..6fd906c9c 100644 --- a/apps/yaak-proxy/components/FlowCard.tsx +++ b/apps/yaak-proxy/components/FlowCard.tsx @@ -1,19 +1,21 @@ import { formatSize } from "@yaakapp-internal/lib/formatSize"; import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib"; import { + CONTROL_TOOLTIP_DELAY, CountBadge, Dropdown, EmptyStateText, type ExtraPaneTab, + type HttpBodyResult, HttpRequestPaneView, HttpResponsePaneView, - type HttpBodyResult, Icon, IconButton, parseCookieHeaders, ResponseCookies, type ResponseViewMode, SplitLayout, + Tooltip, } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useSetAtom } from "jotai"; @@ -181,17 +183,18 @@ function FlowRequestHalf({ flow }: { flow: Flow }) { /** The method and URL, at the status line's height so the two header lines align */ function UrlLine({ flow }: { flow: Flow }) { return ( -
- {flow.method} - - {flow.url} - -
+ // The whole URL on hover, when it runs past the card + +
+ {flow.method} + {flow.url} +
+
); } @@ -295,10 +298,11 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } icon="bookmark" iconColor={saved ? "primary" : "secondary"} title={saved ? "Saved to Yaak" : "Save to Yaak"} + tooltip onClick={() => openSave(flow)} /> - + } diff --git a/apps/yaak-proxy/components/FlowDetail.tsx b/apps/yaak-proxy/components/FlowDetail.tsx index 5c31ac522..60fe3199e 100644 --- a/apps/yaak-proxy/components/FlowDetail.tsx +++ b/apps/yaak-proxy/components/FlowDetail.tsx @@ -7,8 +7,8 @@ import { FlowCard } from "./FlowCard"; import { NotDecrypted } from "./NotDecrypted"; /** - * The detail pane: the selected flow's card, or what the pane holds before one is selected. Side - * by side, a wider gutter separates the card from the list and it sits a little under the header. + * The detail pane: the selected flow's card, or what the pane holds before one is selected. The + * split's gutter separates it from the list; side by side it sits a little under the header. */ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideBySide: boolean }) { const flow = useAtomValue(selectedFlowAtom); @@ -16,7 +16,7 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB return (
(null); - const { width } = useContainerSize(containerRef); const scrollRef = useRef(null); + const headerScrollRef = useRef(null); + const lastScrollTop = useRef(0); + // The list's vertical scrollbar, which the header leaves room for so the two scroll as far + const [scrollbarWidth, setScrollbarWidth] = useState(0); const ignoreScroll = useRef(false); const [following, setFollowing] = useAtom(followAtom); const [heldAt, setHeldAt] = useState(null); @@ -101,10 +106,12 @@ export function FlowList({ style, className }: Props) { // Where the platform cannot tell which app sent a flow, there is no App column. const hasApps = useAtomValue(hasAppsAtom); const columns = useMemo( - () => shownColumns(prefs, width).filter((c) => hasApps || c.id !== "app"), - [prefs, width, hasApps], + () => shownColumns(prefs).filter((c) => hasApps || c.id !== "app"), + [prefs, hasApps], ); const template = useMemo(() => gridTemplate(columns), [columns]); + // A row's grid, its padding and the list's margins: `px-1.5` and `left-1.5 right-1.5` + const contentWidth = useMemo(() => `calc(${gridWidth(columns)} + 1.5rem)`, [columns]); const arrivals = useArrivals(flows, following && sort == null); const virtualizer = useVirtualizer({ @@ -173,13 +180,29 @@ export function FlowList({ style, className }: Props) { if (latest.current.sort == null) void loadOlder(); }, [hold, loadOlder]); + useEffect(() => { + const el = scrollRef.current; + if (el == null) return; + const measure = () => setScrollbarWidth(el.offsetWidth - el.clientWidth); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (el == null) return; + if (headerScrollRef.current != null) headerScrollRef.current.scrollLeft = el.scrollLeft; + // Sideways only: following and holding go by the vertical position. + const top = el.scrollTop; + const moved = top !== lastScrollTop.current; + lastScrollTop.current = top; if (ignoreScroll.current) { ignoreScroll.current = false; return; } - const el = scrollRef.current; - if (el == null) return; + if (!moved) return; const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 2; if (atEnd && latest.current.sort == null) { setFollowing(true); @@ -192,6 +215,13 @@ export function FlowList({ style, className }: Props) { } }, [hold, loadOlder, setFollowing, ROW_HEIGHT]); + // The header scrolls the list sideways, as it has no scrollbar of its own. + const handleHeaderWheel = useCallback((e: WheelEvent) => { + const el = scrollRef.current; + if (el == null || e.deltaX === 0) return; + el.scrollLeft += e.deltaX; + }, []); + // A list too short to scroll still loads older flows on scrolling up. const handleWheel = useCallback( (e: WheelEvent) => { @@ -247,12 +277,18 @@ export function FlowList({ style, className }: Props) { [select], ); + // A third click on a column goes back to start time, following as Sort by Start Time does. const handleSort = useCallback( (column: ColumnId) => { + const next = nextSort(latest.current.sort, column); + if (next == null) { + follow(); + return; + } hold(); - setSort((prev) => nextSort(prev, column)); + setSort(next); }, - [hold, setSort], + [follow, hold, setSort], ); const newCount = following || sort != null ? 0 : countNewFlows(flows, heldAt); @@ -261,18 +297,29 @@ export function FlowList({ style, className }: Props) { return (
- + {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- scrolls the list sideways */} +
+ +
{flows.length === 0 ? ( - +
+ +
) : ( -
+
{virtualizer.getVirtualItems().map((item) => { const flow = flows[item.index]; if (flow == null) return null; @@ -311,7 +363,7 @@ export function FlowList({ style, className }: Props) {
{divider.count === 1 ? "1 new" : `${divider.count} new`} @@ -436,8 +488,8 @@ const FlowRow = memo(function FlowRow({ : {}), }} className={classNames( - "absolute top-0 inset-x-0 grid items-center gap-x-3 px-1.5 rounded-md whitespace-nowrap", - "[&>*]:overflow-hidden [&>*]:text-ellipsis [&>*]:min-w-0 tabular-nums", + "absolute top-0 left-1.5 right-1.5 grid items-center px-1.5 rounded-md whitespace-nowrap", + "tabular-nums", flow.cancelled && "text-text-subtlest", selected ? "text-text bg-surface-highlight group-focus-within/flows:bg-surface-active" @@ -469,9 +521,8 @@ function FlowMark({ flow }: { flow: Flow }) { if (open) states.push("Streaming"); if (flow.responseTruncated) states.push("Body cut at the capture limit"); if (flow.savedRequestId != null) states.push("Saved to Yaak"); - const title = states.length === 0 ? undefined : states.join("\n"); - return ( - + const mark = ( + {flow.error != null ? ( ) : flow.cancelled ? ( @@ -487,10 +538,28 @@ function FlowMark({ flow }: { flow: Flow }) { ) : null} ); + if (states.length === 0) return mark; + return ( + + {mark} + + ); } const mono = "font-mono text-xs"; +/** Every cell: its padding matching the header's, and its text cut short at its width */ +const cell = "min-w-0 px-1.5 overflow-hidden text-ellipsis"; + +/** A cell with its full text in a tooltip, shown when the cell cuts it short */ +function Truncated({ text, children }: { text: string; children: ReactElement }) { + return ( + + {children} + + ); +} + /** An undecrypted tunnel still open: written as it opens, done once it closes */ function isOpenTunnel(flow: Flow): boolean { return flow.tunnelled && flow.elapsedMs == null && flow.error == null; @@ -507,18 +576,25 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: switch (column) { case "number": return ( - + {flow.number || ""} ); case "app": { - if (flow.clientPath == null) return ; - const folder = folderOf(flow); + if (flow.clientPath == null) return ; const name = flow.clientName ?? flow.clientPath; + // A program outside an app says which folder it ran in, in full. + const folder = folderOf(flow) == null ? null : flow.clientCwd; return ( - - - + + + + + ); } case "method": @@ -526,63 +602,74 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: ); - case "host": + case "host": { + const host = hostOf(flow.url); // Cut from the front, so the part that names the service stays. return ( - - {hostOf(flow.url)} - + + + {host} + + ); + } case "path": { // A tunnel's only path is its host; what it carried is unread. - if (flow.tunnelled) return Not decrypted; + if (flow.tunnelled) { + return Not decrypted; + } const { path, query } = splitPath(flow.url); if (flow.streamKind == null) { return ( - - {path} - {query} - + + + {path} + {query} + + ); } // A stream's message count rides the end of the path, so it survives the narrow width, // ticking while the stream is open. return ( - - - {path} - {query} + + + + {path} + {query} + + - - + ); } case "status": return (flow.cancelled || flow.tunnelled) && flow.status == null ? ( - -- + -- ) : ( - + ); case "type": return failed && noResponse ? ( - - {flow.error} - + + + {flow.error} + + ) : flow.cancelled && noResponse ? ( - Cancelled + Cancelled ) : ( - + {shortContentType(flow.contentType) ?? "--"} ); @@ -590,6 +677,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: return ( + {Number.isFinite(open) ? formatMillis(Math.max(0, open)) : "--"} ); @@ -611,6 +699,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: return ( {flow.remoteAddr ?? "--"} + + {flow.remoteAddr ?? "--"} + ); case "version": return ( - + {flow.upstreamHttpVersion ?? flow.clientHttpVersion} ); diff --git a/apps/yaak-proxy/components/FlowListEmpty.tsx b/apps/yaak-proxy/components/FlowListEmpty.tsx index 6c51e596a..8003f045c 100644 --- a/apps/yaak-proxy/components/FlowListEmpty.tsx +++ b/apps/yaak-proxy/components/FlowListEmpty.tsx @@ -49,7 +49,7 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) {
diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx index 45971fe0c..61bcb8847 100644 --- a/apps/yaak-proxy/components/FlowListHeader.tsx +++ b/apps/yaak-proxy/components/FlowListHeader.tsx @@ -15,6 +15,7 @@ import { type ColumnId, type ColumnPrefs, type FlowSort, + resetWidth, resetWidths, resizeColumn, toggleColumn, @@ -25,6 +26,8 @@ import { columnPrefsAtom } from "../lib/store"; interface Props { columns: Column[]; template: string; + /** The rows' width, as CSS, so the header scrolls sideways with them */ + width: string; prefs: ColumnPrefs; sort: FlowSort | null; onSort: (column: ColumnId) => void; @@ -32,13 +35,15 @@ interface Props { } /** - * The list's only control surface: a click on a label sorts by it and a second reverses it, - * dragging the edge after a label resizes its column, and the picker sits in the empty corner on - * hover, or anywhere on a right-click. + * The list's only control surface: a click on a label sorts by it, a second reverses it and a + * third goes back to start time. Dragging the edge after a label resizes its column and a + * double-click on it resets it. The picker sits in the empty corner on hover, or anywhere on a + * right-click. */ export const FlowListHeader = memo(function FlowListHeader({ columns, template, + width, prefs, sort, onSort, @@ -82,67 +87,68 @@ export const FlowListHeader = memo(function FlowListHeader({ }, []); return ( - // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the same menu as the picker -
- - - - - - {columns.map((c) => ( - - - {c.id !== "path" && ( + // The same margins and padding as the rows, so each label sits over its cells. +
+ {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the same menu as the picker */} +
+ + + + + + {columns.map((c) => ( + + + {/* On the column's right edge, the boundary with the next, resizing this one */} { startWidth.current = c.current; }} onResizeMove={(e) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + e.x - e.xStart)) } - onReset={() => - setPrefs((p) => ({ ...p, widths: { ...p.widths, [c.id]: undefined } })) - } + onReset={() => setPrefs((p) => resetWidth(p, c.id))} /> - )} - - ))} - setMenu(null)} /> + + ))} + setMenu(null)} /> +
); }); diff --git a/apps/yaak-proxy/components/FlowRequestBody.test.tsx b/apps/yaak-proxy/components/FlowRequestBody.test.tsx index a4656d337..b6d4f3a5d 100644 --- a/apps/yaak-proxy/components/FlowRequestBody.test.tsx +++ b/apps/yaak-proxy/components/FlowRequestBody.test.tsx @@ -11,6 +11,8 @@ vi.mock("@yaakapp-internal/ui", () => ({ ), EmptyStateText: ({ children }: { children: ReactNode }) =>
{children}
, LoadingIcon: () => LOADING, + CONTROL_TOOLTIP_DELAY: 0, + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, })); const data = (truncated = false): FlowBodyData => ({ diff --git a/apps/yaak-proxy/components/FlowRequestBody.tsx b/apps/yaak-proxy/components/FlowRequestBody.tsx index af41d3bc8..162a0adcd 100644 --- a/apps/yaak-proxy/components/FlowRequestBody.tsx +++ b/apps/yaak-proxy/components/FlowRequestBody.tsx @@ -1,5 +1,11 @@ import { formatSize } from "@yaakapp-internal/lib/formatSize"; -import { Banner, EmptyStateText, LoadingIcon } from "@yaakapp-internal/ui"; +import { + Banner, + CONTROL_TOOLTIP_DELAY, + EmptyStateText, + LoadingIcon, + Tooltip, +} from "@yaakapp-internal/ui"; import type { HttpBodyResult } from "@yaakapp-internal/ui/httpTypes"; import type { ReactNode } from "react"; import type { FlowBodyData } from "../lib/flowViews"; @@ -39,9 +45,11 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro return (
- - First {formatSize(body.data.storedSize)} of {formatSize(requestSize)} kept - + + + First {formatSize(body.data.storedSize)} of {formatSize(requestSize)} kept + +
{editor}
diff --git a/apps/yaak-proxy/components/FlowTimeline.tsx b/apps/yaak-proxy/components/FlowTimeline.tsx index a0b0927b4..064baef73 100644 --- a/apps/yaak-proxy/components/FlowTimeline.tsx +++ b/apps/yaak-proxy/components/FlowTimeline.tsx @@ -1,10 +1,12 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; import { + CONTROL_TOOLTIP_DELAY, EventViewer, EventViewerRow, formatMillis, Icon, type IconProps, + Tooltip, } from "@yaakapp-internal/ui"; import { useMemo } from "react"; import { type FlowPhase, flowPhases, type PhaseKind } from "../lib/phases"; @@ -35,12 +37,21 @@ export function FlowTimeline({ flow }: { flow: Flow }) { onClick={onClick} icon={} content={ - - {event.label} - {event.detail != null && ( - {` ${event.detail}`} - )} - + event.detail == null ? ( + {event.label} + ) : ( + + + {event.label} + {` ${event.detail}`} + + + ) } columns="auto minmax(0,1fr) 6rem 6rem" cells={{ diff --git a/apps/yaak-proxy/components/NotDecrypted.tsx b/apps/yaak-proxy/components/NotDecrypted.tsx index 13b28ab3f..b45e4bd8f 100644 --- a/apps/yaak-proxy/components/NotDecrypted.tsx +++ b/apps/yaak-proxy/components/NotDecrypted.tsx @@ -1,6 +1,6 @@ import { formatSize } from "@yaakapp-internal/lib/formatSize"; import type { Flow } from "@yaakapp-internal/proxy-lib"; -import { Button, formatMillis, Icon } from "@yaakapp-internal/ui"; +import { Button, CONTROL_TOOLTIP_DELAY, formatMillis, Icon, Tooltip } from "@yaakapp-internal/ui"; import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; import { type DecryptSubject, detailActions, listEdit, setDecrypted } from "../lib/decrypt"; import { sidebarPath } from "../lib/search"; @@ -46,13 +46,23 @@ export function NotDecrypted({ flow }: { flow: Flow }) {
{facts.join(" · ")}
{flow.error != null &&
{flow.error}
} {actions.listed ? ( -
- Decrypting new connections -
+ +
Decrypting new connections
+
) : actions.blocked != null ? ( -
- Excluded by {actions.blocked} -
+ +
+ Excluded by {actions.blocked} +
+
) : (
{actions.host && ( @@ -60,6 +70,7 @@ export function NotDecrypted({ flow }: { flow: Flow }) { size="xs" variant="border" title={`Decrypt new connections to ${host}`} + tooltip disabled={save.isPending} onClick={() => decrypt(hostSubject)} > @@ -71,6 +82,7 @@ export function NotDecrypted({ flow }: { flow: Flow }) { size="xs" variant="border" title={`Decrypt new connections from ${app}`} + tooltip disabled={save.isPending} onClick={() => decrypt(appSubject)} > diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index 95a4b181b..4f4ffd8be 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -63,13 +63,14 @@ export function ProxyLayout() { included, falls through to the header, which drags the window. */}
{ @@ -93,6 +94,7 @@ export function ProxyLayout() { className="pointer-events-auto" icon={flowLayout === "horizontal" ? "columns_2" : "rows_2"} title={`Change to ${flowLayout === "horizontal" ? "vertical" : "horizontal"} layout`} + tooltip size="sm" iconColor="secondary" onClick={() => @@ -103,6 +105,7 @@ export function ProxyLayout() { className="pointer-events-auto" icon="settings" title="Settings" + tooltip size="sm" iconColor="secondary" onClick={() => openSettings()} @@ -139,6 +142,7 @@ export function ProxyLayout() { setFloatingSidebarHidden(true)} @@ -188,7 +192,8 @@ function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) { return ( } diff --git a/apps/yaak-proxy/components/SaveDialog.tsx b/apps/yaak-proxy/components/SaveDialog.tsx index 653632474..229a6f5a6 100644 --- a/apps/yaak-proxy/components/SaveDialog.tsx +++ b/apps/yaak-proxy/components/SaveDialog.tsx @@ -6,11 +6,13 @@ import type { } from "@yaakapp-internal/proxy-lib"; import { Banner, + CONTROL_TOOLTIP_DELAY, Dialog, DialogFooter, HttpMethodTagRaw, LoadingIcon, PlainInput, + Tooltip, } from "@yaakapp-internal/ui"; import { unnamedRequestName } from "@yaakapp-internal/ui/resolvedModelName"; import { openUrl } from "@tauri-apps/plugin-opener"; @@ -181,13 +183,16 @@ function SaveForm({
{p.droppedHeaders.map((h) => ( - - {h.name} - + + {h.name} + + ))}
diff --git a/apps/yaak-proxy/components/SearchBar.tsx b/apps/yaak-proxy/components/SearchBar.tsx index cdcbf7271..77c45fe94 100644 --- a/apps/yaak-proxy/components/SearchBar.tsx +++ b/apps/yaak-proxy/components/SearchBar.tsx @@ -1,7 +1,7 @@ -import { startCompletion } from "@codemirror/autocomplete"; +import { autocompletion, startCompletion } from "@codemirror/autocomplete"; import type { Extension } from "@codemirror/state"; import { Compartment } from "@codemirror/state"; -import type { EditorView } from "@codemirror/view"; +import { EditorView, tooltips, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { Dropdown, Icon, @@ -11,8 +11,17 @@ import { LazyCodeEditor, } from "@yaakapp-internal/ui"; import { queryFilter } from "@yaakapp-internal/ui/editor"; +import classNames from "classnames"; import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; -import { memo, useEffect, useMemo, useRef } from "react"; +import { + type CSSProperties, + memo, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useRpcQuery } from "../hooks/useRpcQuery"; import { loadSeenValues, savedCompletions, searchProblems } from "../lib/searchBox"; import { @@ -34,7 +43,8 @@ const SEARCH_DEBOUNCE_MS = 200; * the URL, method, status and content type, and `@field:value` terms the rest, highlighted and * completed as Yaak's sidebar filter is, with the values the proxy has seen. The backend runs * it and marks what it cannot. Its placeholder counts the flows. ⌘F focuses it and Escape - * clears it. + * clears it. Focused, it wraps a long query over as many lines as it needs, as Yaak's URL bar + * does, over what is below; blurred, it is one line again. */ export const SearchBar = memo(function SearchBar() { const [text, setText] = useAtom(searchTextAtom); @@ -48,6 +58,24 @@ export const SearchBar = memo(function SearchBar() { const locked = useAtomValue(lockedAtom) != null; const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema; const handleRef = useRef(null); + const boxRef = useRef(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(null); + + useLayoutEffect(() => { + if (!focused) { + setPlace(null); + return; + } + const measure = () => { + const rect = boxRef.current?.getBoundingClientRect(); + if (rect != null) setPlace({ top: rect.top, left: rect.left, width: rect.width }); + }; + measure(); + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, [focused]); useEffect(() => { const timer = setTimeout(() => setTerms(text), SEARCH_DEBOUNCE_MS); @@ -81,7 +109,7 @@ export const SearchBar = memo(function SearchBar() { }), [schema, store], ); - const extensions = useMemo(() => compartment.current.of(filter), [filter]); + const extensions = useMemo(() => [compartment.current.of(filter), completionAcrossBox], [filter]); useEffect(() => { handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) }); }, [filter]); @@ -93,67 +121,177 @@ export const SearchBar = memo(function SearchBar() { }; return ( - ( - - )} - setRef={(h) => { - handleRef.current = h; - }} - label="Search flows" - hideLabel - size="sm" - className="font-mono text-xs" - stateKey={null} - language={null} - wrapLines={false} - placeholder={locked ? "Traffic is locked" : placeholderFor(total)} - disabled={locked} - defaultValue={text} - forceUpdateKey={String(version)} - onChange={setText} - extensions={extensions} - onKeyDown={(e) => { - if (e.key === "Escape" && handleRef.current?.value() !== "") { - e.stopPropagation(); - clear(); - } - }} - leftSlot={ - - } - rightSlot={ - text === "" ? ( - - ⌘F - - ) : ( -
- , - onSelect: () => setSavedDialog({ mode: "save", query: text.trim() }), - }, - ]} - > - - - -
- ) - } - /> + // Holds the header's place for the box, which floats over the list while focused +
+
+ ( + + )} + setRef={(h) => { + handleRef.current = h; + }} + label="Search flows" + hideLabel + size="sm" + className="font-mono text-xs" + containerClassName={classNames(focused && "bg-surface shadow-lg")} + stateKey={null} + language={null} + wrapLines={focused} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + placeholder={locked ? "Traffic is locked" : placeholderFor(total)} + disabled={locked} + defaultValue={text} + forceUpdateKey={String(version)} + onChange={setText} + extensions={extensions} + onKeyDown={(e) => { + if (e.key === "Escape" && handleRef.current?.value() !== "") { + e.stopPropagation(); + clear(); + } + }} + leftSlot={ + + } + rightSlot={ + text === "" ? ( + + ⌘F + + ) : ( +
+ , + onSelect: () => setSavedDialog({ mode: "save", query: text.trim() }), + }, + ]} + > + + + +
+ ) + } + /> +
+
); }); +/** 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(".x-theme-input"); +} + +/** Pixels between a completion's info and the window's edge */ +const INFO_MARGIN = 8; + +/** + * Where a completion's info goes: beside the list, in the window's room either side of the box, + * or, with none, under the option as CodeMirror puts it in a narrow space + */ +function positionInfo( + view: EditorView, + list: { left: number; right: number; top: number; bottom: number }, + option: { top: number; bottom: number }, + info: { left: number; right: number; top: number; bottom: number }, +): { style: string; class: string } { + const win = view.dom.ownerDocument.defaultView ?? window; + const width = info.right - info.left; + const height = info.bottom - info.top; + const right = win.innerWidth - list.right - INFO_MARGIN; + const left = list.left - INFO_MARGIN; + if (Math.max(left, right) >= width) { + const top = Math.max(0, Math.min(option.top, win.innerHeight - height)) - list.top; + const onRight = right >= width; + return { + style: `top: ${top}px; max-width: ${onRight ? right : left}px`, + class: onRight ? "cm-completionInfo-right" : "cm-completionInfo-left", + }; + } + return { + style: `top: ${option.bottom - list.top}px; max-width: 400px`, + class: "cm-completionInfo-right-narrow", + }; +} + +/** + * The completions under the search box, as wide as it and level with its left edge, rather + * than a narrow list wherever the cursor is + */ +const completionAcrossBox: Extension = [ + autocompletion({ positionInfo }), + tooltips({ + tooltipSpace: (view) => { + const win = view.dom.ownerDocument.defaultView ?? window; + const rect = boxOf(view)?.getBoundingClientRect(); + return { + top: 0, + bottom: win.innerHeight, + left: rect?.left ?? 0, + right: rect?.right ?? win.innerWidth, + }; + }, + }), + ViewPlugin.fromClass( + class { + observer: ResizeObserver | null = null; + + constructor(view: EditorView) { + this.watch(view); + } + + // The editor is put in the page after it is made, so the box may only be found later. + update(update: ViewUpdate) { + if (this.observer == null) this.watch(update.view); + } + + watch(view: EditorView) { + const box = boxOf(view); + if (box == null) return; + const root = view.dom.ownerDocument.documentElement; + this.observer = new ResizeObserver(() => { + root.style.setProperty(BOX_WIDTH_VAR, `${box.getBoundingClientRect().width}px`); + }); + this.observer.observe(box); + } + + destroy() { + this.observer?.disconnect(); + } + }, + ), + EditorView.theme({ + ".cm-tooltip.cm-tooltip-autocomplete": { width: `var(${BOX_WIDTH_VAR})` }, + ".cm-tooltip.cm-tooltip-autocomplete > ul": { minWidth: "0", maxWidth: "none" }, + }), +]; + /** * Ctrl+Space lists what can be typed, fields and saved searches on an empty spot, as it does in * Yaak's editors, whose shared editor leaves the key to each app diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx index 23f3be64e..6b7eb8043 100644 --- a/apps/yaak-proxy/components/Sidebar.tsx +++ b/apps/yaak-proxy/components/Sidebar.tsx @@ -1,8 +1,10 @@ import { ContextMenu, + CONTROL_TOOLTIP_DELAY, type DropdownItem, Icon, IconButton, + Tooltip, Tree, type TreeProps, } from "@yaakapp-internal/ui"; @@ -14,9 +16,11 @@ import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; import { useRpcMutation } from "../hooks/useRpcMutation"; import { SIDEBAR_TREE_ID } from "../lib/atoms"; import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt"; +import { revealLabel, revealPath } from "../lib/opener"; import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse"; import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree"; import { hideSystemAtom, noteSidebarInput, sidebarTreeAtom } from "../lib/store"; +import { getOsType } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; import { AppIcon } from "./AppIcon"; import { savedSearchDialogAtom } from "./SavedSearchDialog"; @@ -68,7 +72,14 @@ function ItemInner({ item }: { item: SidebarItem }) { {item.kind === "saved" && } {item.kind === "folder" && } {item.appPath != null && } - {item.label} + {item.kind === "folder" && item.path != null ? ( + // Named by its last part; the whole path on hover + + {item.label} + + ) : ( + {item.label} + )} {item.sublabel != null && ( {item.sublabel} )} @@ -89,7 +100,9 @@ function DecryptedMark({ item }: { item: SidebarItem }) { const list = useDecryptList(); if (item.decrypt == null || !isDecrypted(list, item.decrypt)) return null; return ( - + + + ); } @@ -107,6 +120,7 @@ function HideSystemToggle() { icon={hidden ? "eye_closed" : "eye"} iconColor="secondary" title={hidden ? "Show System in All traffic" : "Hide System from All traffic"} + tooltip className={classNames( "!h-4 !w-4 mr-1", !hidden && "opacity-0 [.tree-item:hover_&]:opacity-100 focus-visible:opacity-100", @@ -135,10 +149,39 @@ export function Sidebar() { onError: (err) => showToast({ id: "saved-search", message: err.message, color: "danger" }), }); + // A program's folder, or the program, is shown in Finder, and a folder's path copied. + const fail = useCallback( + (message: string) => showToast({ id: "sidebar-path", message, color: "danger" }), + [showToast], + ); + const pathItems = useCallback( + (item: SidebarItem | undefined): DropdownItem[] => { + const path = item?.path; + if (item == null || path == null) return []; + return [ + { + label: revealLabel(getOsType()), + leftSlot: , + onSelect: () => void revealPath(path, fail), + }, + { + label: "Copy path", + leftSlot: , + hidden: item.kind !== "folder", + onSelect: () => { + navigator.clipboard.writeText(path).catch((err) => fail(String(err))); + }, + }, + ]; + }, + [fail], + ); + // Decrypt or Stop Decrypting the selected hosts, sites and apps, from new connections on. // A saved search is renamed or deleted. const getContextMenu = useCallback( (items: SidebarItem[]): DropdownItem[] => { + const paths = items.length === 1 ? pathItems(items[0]) : []; const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }])); if (saved.length > 0) { const [first] = saved; @@ -164,8 +207,10 @@ export function Sidebar() { } const subjects = items.flatMap((i) => (i.decrypt == null ? [] : [i.decrypt])); const action = decryptMenuAction(decryptList, subjects); - if (action == null) return []; + if (action == null) return paths; return [ + ...paths, + ...(paths.length > 0 ? [{ type: "separator" } as const] : []), { label: action.label, icon: action.on ? "lock_open" : "lock", @@ -187,7 +232,7 @@ export function Sidebar() { }, ]; }, - [decryptList, saveDecryptList, showToast, setSavedDialog, deleteSaved], + [decryptList, saveDecryptList, showToast, setSavedDialog, deleteSaved, pathItems], ); const renderContextMenu = useCallback["renderContextMenu"]>>( ({ items, position, onClose }) => diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index 14a7c5f05..701f2091f 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -1,4 +1,4 @@ -import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib"; +import type { Flow, GetProxyStateResponse } from "@yaakapp-internal/proxy-lib"; import { fixtures } from "./fixtures"; /** @@ -42,7 +42,9 @@ function answer(cmd: string, payload: unknown): unknown { const req = (payload ?? {}) as Record; switch (cmd) { case "get_flow_body": { - const fixture = fixtures.find((f) => f.flow.id === req.flowId); + // A copy from `?many` reads its original's body + const id = String(req.flowId).split("~")[0]; + const fixture = fixtures.find((f) => f.flow.id === id); if (fixture == null) throw new Error(`No fixture for ${String(req.flowId)}`); return req.which === "request" ? fixture.requestBody : fixture.responseBody; } @@ -86,12 +88,77 @@ function answer(cmd: string, payload: unknown): unknown { case "list_actions": return { actions: [] }; case "list_flows": { - const flows = fixtures.map((f, i) => ({ ...f.flow, number: 1401 + i })).reverse(); + const many = Number(new URLSearchParams(location.search).get("many") ?? 0); + const flows = (many > 0 ? manyFlows(many) : fixtures.map((f) => f.flow)) + .map((f, i) => ({ ...f, number: 1401 + i })) + .reverse(); return { flows, total: flows.length, matching: null }; } case "list_seen": return { hosts: [], apps: [] }; + // A few of the engine's fields, so the search box highlights and completes + case "query_schema": + return { + schema: { + fields: [ + { + name: "host", + kind: { type: "text" }, + info: "The request's host", + example: "@host:api.example.com", + }, + { + name: "method", + kind: { type: "text" }, + info: "GET, POST, …", + example: "@method:post", + }, + { + name: "status", + kind: { type: "number", units: [] }, + info: "The response's status", + example: "@status:>=400", + }, + { + name: "header", + kind: { type: "text" }, + map: true, + info: "A request or response header", + example: "@header.accept:*json*", + }, + ], + }, + }; + case "query_values": + return []; + case "validate_query": + return []; default: throw new Error(`Not in the harness: ${cmd}`); } } + +/** + * `?many=3000`: the fixtures over and over, a second apart, for checking the list with a lot of + * traffic. Every fourth has no response yet, and a program runs in a few working folders. + */ +function manyFlows(count: number): Flow[] { + const start = Date.parse("2026-11-09T09:00:00Z"); + const folders = ["/Users/you/src/yaak", "/Users/you/.codex/worktrees/1a79/yaak", null]; + return Array.from({ length: count }, (_, i) => { + const base = fixtures[i % fixtures.length]?.flow as Flow; + const pending = i % 4 === 3; + return { + ...base, + id: `${base.id}~${i}`, + startedAt: new Date(start + i * 1000).toISOString().replace("Z", ""), + url: + i % 7 === 0 ? `${base.url}/a/rather/long/path/segment/${i}?page=${i}&q=search` : base.url, + status: pending ? null : base.status, + contentType: pending ? null : base.contentType, + responseSize: pending ? 0 : base.responseSize, + elapsedMs: pending ? null : base.elapsedMs, + clientCwd: base.clientBundleId == null ? (folders[i % folders.length] ?? null) : null, + }; + }); +} diff --git a/apps/yaak-proxy/lib/columns.test.ts b/apps/yaak-proxy/lib/columns.test.ts index f3bd1c272..6703b589d 100644 --- a/apps/yaak-proxy/lib/columns.test.ts +++ b/apps/yaak-proxy/lib/columns.test.ts @@ -4,14 +4,15 @@ import { type ColumnPrefs, defaultColumnPrefs, gridTemplate, + gridWidth, nextSort, parseColumnPrefs, + resetWidth, resetWidths, resizeColumn, shownColumns, sortFlows, toggleColumn, - WIDE_MIN_WIDTH, } from "./columns"; function flow(number: number, extra: Partial = {}): Flow { @@ -33,11 +34,11 @@ function flow(number: number, extra: Partial = {}): Flow { } as Flow; } -const ids = (prefs: ColumnPrefs, width: number) => shownColumns(prefs, width).map((c) => c.id); +const ids = (prefs: ColumnPrefs) => shownColumns(prefs).map((c) => c.id); describe("shownColumns", () => { - test("shows the wide set when there is room, and the narrow set when not", () => { - expect(ids(defaultColumnPrefs, WIDE_MIN_WIDTH)).toEqual([ + test("shows every visible column, however narrow the list", () => { + expect(ids(defaultColumnPrefs)).toEqual([ "number", "app", "method", @@ -48,39 +49,31 @@ describe("shownColumns", () => { "size", "time", ]); - expect(ids(defaultColumnPrefs, WIDE_MIN_WIDTH - 1)).toEqual([ - "app", - "method", - "host", - "path", - "status", - "time", - ]); }); test("follows the picker, and always keeps the path", () => { let prefs = toggleColumn(defaultColumnPrefs, "remote"); prefs = toggleColumn(prefs, "type"); prefs = toggleColumn(prefs, "path"); - expect(ids(prefs, 2000)).toContain("remote"); - expect(ids(prefs, 2000)).not.toContain("type"); - expect(ids(prefs, 2000)).toContain("path"); - // Off by default, and outside the narrow set. - expect(ids(prefs, 400)).not.toContain("remote"); + expect(ids(prefs)).toContain("remote"); + expect(ids(prefs)).not.toContain("type"); + expect(ids(prefs)).toContain("path"); }); - test("lays out fixed widths with the path taking the rest", () => { - const prefs = resizeColumn(defaultColumnPrefs, "host", 240.4); - const template = gridTemplate(shownColumns(prefs, 1200)); - expect(template).toBe("22px 46px 18px 50px 240px minmax(80px,1fr) 46px 50px 72px 64px"); + test("lays out every column at a fixed width, the path included", () => { + let prefs = resizeColumn(defaultColumnPrefs, "host", 240.4); + prefs = resizeColumn(prefs, "path", 500); + const columns = shownColumns(prefs); + const pad = (w: number) => `calc(${w}px + 0.75rem)`; + expect(gridTemplate(columns)).toBe( + ["24px", ...[46, 18, 50, 240, 500, 46, 50, 72, 64].map(pad)].join(" "), + ); + expect(gridWidth(columns)).toBe("calc(1110px + 9 * 0.75rem)"); }); - test("lets the host give way to the path in a narrow list", () => { - const host = (width: number) => - shownColumns(defaultColumnPrefs, width).find((c) => c.id === "host"); - expect(host(2000)?.current).toBe(190); - expect(host(500)?.current).toBe(120); - expect(host(100)?.current).toBe(60); + test("keeps the host's width in a narrow list", () => { + const host = shownColumns(defaultColumnPrefs).find((c) => c.id === "host"); + expect(host?.current).toBe(190); }); }); @@ -96,19 +89,36 @@ describe("column preferences", () => { expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs); expect(resetWidths(prefs).widths).toEqual({}); }); + + test("a double-click resets one column's width", () => { + let prefs = resizeColumn(defaultColumnPrefs, "host", 300); + prefs = resizeColumn(prefs, "path", 400); + expect(resetWidth(prefs, "path").widths).toEqual({ host: 300 }); + }); }); describe("sorting", () => { - test("sorts numbers largest first and names from A, then reverses", () => { + test("sorts numbers largest first and names from A, reverses, then goes back to start", () => { expect(nextSort(null, "size")).toEqual({ column: "size", descending: true }); expect(nextSort(null, "host")).toEqual({ column: "host", descending: false }); expect(nextSort({ column: "host", descending: false }, "host")).toEqual({ column: "host", descending: true, }); + expect(nextSort({ column: "host", descending: true }, "host")).toBeNull(); + expect(nextSort({ column: "size", descending: true }, "size")).toEqual({ + column: "size", + descending: false, + }); + expect(nextSort({ column: "size", descending: false }, "size")).toBeNull(); + // Another column starts over. + expect(nextSort({ column: "host", descending: true }, "size")).toEqual({ + column: "size", + descending: true, + }); }); - test("sorts the loaded flows, those without a value last, ties in arrival order", () => { + test("sorts the loaded flows, those without a value lowest, ties in arrival order", () => { const a = flow(1, { responseSize: 10, elapsedMs: 5, url: "https://b.com/" }); const b = flow(2, { responseSize: 300, elapsedMs: null, url: "https://a.com/" }); const c = flow(3, { responseSize: 10, elapsedMs: 50, url: "https://c.com/" }); @@ -117,12 +127,29 @@ describe("sorting", () => { const by = (column: Parameters[1], descending: boolean) => sortFlows(flows, { column, descending }).map((f) => f.number); expect(by("size", true)).toEqual([2, 1, 3, 4]); - expect(by("size", false)).toEqual([1, 3, 2, 4]); + expect(by("size", false)).toEqual([4, 1, 3, 2]); expect(by("time", true)).toEqual([3, 1, 2, 4]); + expect(by("time", false)).toEqual([2, 4, 1, 3]); expect(by("host", false)).toEqual([2, 4, 1, 3]); expect(by("number", true)).toEqual([4, 3, 2, 1]); // Failures sort below every status. expect(by("status", true)).toEqual([1, 2, 3, 4]); + expect(by("status", false)).toEqual([4, 1, 2, 3]); expect(sortFlows(flows, null)).toBe(flows); }); + + test("flips flows with no status, type, size or time to the other end", () => { + const done = flow(1, { status: 200, contentType: "text/html", responseSize: 5, elapsedMs: 9 }); + const pending = flow(2, { status: null, contentType: null, responseSize: 0, elapsedMs: null }); + const cancelled = flow(3, { status: null, cancelled: true, contentType: "" }); + const flows = [done, pending, cancelled]; + for (const column of ["status", "type", "size", "time"] as const) { + expect(sortFlows(flows, { column, descending: true }).map((f) => f.number)).toEqual([ + 1, 2, 3, + ]); + expect(sortFlows(flows, { column, descending: false }).map((f) => f.number)).toEqual([ + 2, 3, 1, + ]); + } + }); }); diff --git a/apps/yaak-proxy/lib/columns.ts b/apps/yaak-proxy/lib/columns.ts index 549a84b41..1492b7b07 100644 --- a/apps/yaak-proxy/lib/columns.ts +++ b/apps/yaak-proxy/lib/columns.ts @@ -18,14 +18,12 @@ export interface ColumnDef { id: ColumnId; /** In the header and the picker */ label: string; - /** Pixels, at the default interface size; the path takes what is left */ + /** Pixels of content, at the default interface size; each cell adds `CELL_PADDING` */ width: number; minWidth: number; align: "left" | "right"; /** Shown until the person hides it */ defaultVisible: boolean; - /** Kept when the list is narrow; the rest drop out below `WIDE_MIN_WIDTH` */ - narrow: boolean; } export const COLUMNS: ColumnDef[] = [ @@ -36,7 +34,6 @@ export const COLUMNS: ColumnDef[] = [ minWidth: 30, align: "right", defaultVisible: true, - narrow: false, }, { id: "app", @@ -45,7 +42,6 @@ export const COLUMNS: ColumnDef[] = [ minWidth: 18, align: "left", defaultVisible: true, - narrow: true, }, { id: "method", @@ -54,7 +50,6 @@ export const COLUMNS: ColumnDef[] = [ minWidth: 40, align: "left", defaultVisible: true, - narrow: true, }, { id: "host", @@ -63,16 +58,14 @@ export const COLUMNS: ColumnDef[] = [ minWidth: 60, align: "left", defaultVisible: true, - narrow: true, }, { id: "path", label: "Path", - width: 0, + width: 360, minWidth: 80, align: "left", defaultVisible: true, - narrow: true, }, { id: "status", @@ -81,7 +74,6 @@ export const COLUMNS: ColumnDef[] = [ minWidth: 34, align: "left", defaultVisible: true, - narrow: true, }, { id: "type", @@ -90,7 +82,6 @@ export const COLUMNS: ColumnDef[] = [ minWidth: 34, align: "left", defaultVisible: true, - narrow: false, }, { id: "size", @@ -99,7 +90,6 @@ export const COLUMNS: ColumnDef[] = [ minWidth: 44, align: "right", defaultVisible: true, - narrow: false, }, { id: "time", @@ -108,7 +98,6 @@ export const COLUMNS: ColumnDef[] = [ minWidth: 44, align: "right", defaultVisible: true, - narrow: true, }, { id: "remote", @@ -117,7 +106,6 @@ export const COLUMNS: ColumnDef[] = [ minWidth: 60, align: "left", defaultVisible: false, - narrow: false, }, { id: "version", @@ -126,7 +114,6 @@ export const COLUMNS: ColumnDef[] = [ minWidth: 44, align: "left", defaultVisible: false, - narrow: false, }, ]; @@ -138,11 +125,11 @@ export function columnDef(id: ColumnId): ColumnDef { return def; } -/** Below this width the list keeps only the narrow set: what a glance needs */ -export const WIDE_MIN_WIDTH = 760; - /** The mark column, first and fixed */ -export const MARK_WIDTH = 22; +export const MARK_WIDTH = 24; + +/** Each cell's padding, both sides together, added to its column's width; `px-1.5` per side */ +export const CELL_PADDING = "0.75rem"; /** What the person chose: which columns show, and the widths they dragged, by id */ export interface ColumnPrefs { @@ -153,32 +140,18 @@ export interface ColumnPrefs { export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {} }; export interface Column extends ColumnDef { - /** Its width now, in pixels; zero for the path, which takes the rest */ + /** Its width now, in pixels of content */ current: number; } /** - * The columns shown at `listWidth`, in order: the visible ones, less those outside the narrow set - * when the list is narrower than `WIDE_MIN_WIDTH`. The path always shows. Measured, not by - * layout, so a wide window side by side still gets size and type. + * The columns shown, in order: the visible ones, the path always. Each keeps its width however + * narrow the list is; the list scrolls sideways instead. */ -export function shownColumns(prefs: ColumnPrefs, listWidth: number): Column[] { - const wide = listWidth >= WIDE_MIN_WIDTH; - return COLUMNS.filter((c) => { - if (c.id === "path") return true; - if (!(prefs.visible[c.id] ?? c.defaultVisible)) return false; - return wide || c.narrow; - }).map((c) => ({ ...c, current: currentWidth(prefs, c.id, listWidth) })); -} - -/** The host gives way to the path in a narrow list, at most this share of its width */ -const HOST_SHARE = 0.24; - -function currentWidth(prefs: ColumnPrefs, id: ColumnId, listWidth: number): number { - if (id === "path") return 0; - const width = columnWidth(prefs, id); - if (id !== "host" || listWidth <= 0) return width; - return Math.max(columnDef(id).minWidth, Math.min(width, Math.round(listWidth * HOST_SHARE))); +export function shownColumns(prefs: ColumnPrefs): Column[] { + return COLUMNS.filter((c) => c.id === "path" || (prefs.visible[c.id] ?? c.defaultVisible)).map( + (c) => ({ ...c, current: columnWidth(prefs, c.id) }), + ); } export function columnWidth(prefs: ColumnPrefs, id: ColumnId): number { @@ -186,14 +159,18 @@ export function columnWidth(prefs: ColumnPrefs, id: ColumnId): number { return Math.max(def.minWidth, Math.round(prefs.widths[id] ?? def.width)); } -/** The grid template for the header and every row */ +/** The grid template for the header and every row: the mark, then each column and its padding */ export function gridTemplate(columns: Column[]): string { - const cells = columns.map((c) => - c.id === "path" ? `minmax(${c.minWidth}px,1fr)` : `${c.current}px`, - ); + const cells = columns.map((c) => `calc(${c.current}px + ${CELL_PADDING})`); return [`${MARK_WIDTH}px`, ...cells].join(" "); } +/** The width of a row's grid, as CSS: the list scrolls sideways when it is wider than the list */ +export function gridWidth(columns: Column[]): string { + const px = columns.reduce((sum, c) => sum + c.current, MARK_WIDTH); + return `calc(${px}px + ${columns.length} * ${CELL_PADDING})`; +} + export function toggleColumn(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs { if (id === "path") return prefs; const shown = prefs.visible[id] ?? columnDef(id).defaultVisible; @@ -201,7 +178,6 @@ export function toggleColumn(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs { } export function resizeColumn(prefs: ColumnPrefs, id: ColumnId, width: number): ColumnPrefs { - if (id === "path") return prefs; const w = Math.max(columnDef(id).minWidth, Math.round(width)); return { ...prefs, widths: { ...prefs.widths, [id]: w } }; } @@ -210,6 +186,12 @@ export function resetWidths(prefs: ColumnPrefs): ColumnPrefs { return { ...prefs, widths: {} }; } +/** One column back to its default width */ +export function resetWidth(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs { + const { [id]: _, ...widths } = prefs.widths; + return { ...prefs, widths }; +} + /** * Reads stored preferences, dropping what no longer applies: unknown ids, and values of the * wrong type, so a change to the columns never breaks the list. @@ -238,10 +220,15 @@ export function firstDirection(column: ColumnId): boolean { return column === "number" || column === "size" || column === "time" || column === "status"; } -/** A click on a column's label: sort by it, or reverse the sort it already has */ -export function nextSort(sort: FlowSort | null, column: ColumnId): FlowSort { - if (sort?.column === column) return { column, descending: !sort.descending }; - return { column, descending: firstDirection(column) }; +/** + * A click on a column's label: sort by it, reverse the sort it has, then, from the reversed sort, + * go back to start time + */ +export function nextSort(sort: FlowSort | null, column: ColumnId): FlowSort | null { + const first = firstDirection(column); + if (sort?.column !== column) return { column, descending: first }; + if (sort.descending === first) return { column, descending: !first }; + return null; } const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); @@ -276,20 +263,19 @@ function sortKey(flow: Flow, column: ColumnId): Key { } /** - * The flows in `sort`'s order, without a value last either way, then by start so equal values - * keep arrival order. Sorts the flows given, which are the ones loaded, not the whole database. + * The flows in `sort`'s order, those without a value (none, empty or zero) lowest, so first + * ascending and last descending, then by start so equal values keep arrival order. Sorts the + * flows given, which are the ones loaded, not the whole database: the database only ever reads + * by start time, so no `ORDER BY` follows the column sort. */ export function sortFlows(flows: Flow[], sort: FlowSort | null): Flow[] { if (sort == null) return flows; const dir = sort.descending ? -1 : 1; - const keyed = flows.map((flow, i) => ({ flow, i, key: sortKey(flow, sort.column) })); + const keyed = flows.map((flow, i) => ({ flow, i, key: sortKey(flow, sort.column) || null })); keyed.sort((a, b) => { - if (a.key == null || b.key == null) { - if (a.key == null && b.key == null) return a.i - b.i; - return a.key == null ? 1 : -1; - } let c: number; - if (typeof a.key === "number" && typeof b.key === "number") c = a.key - b.key; + if (a.key == null || b.key == null) c = Number(b.key == null) - Number(a.key == null); + else if (typeof a.key === "number" && typeof b.key === "number") c = a.key - b.key; else c = collator.compare(String(a.key), String(b.key)); return c === 0 ? a.i - b.i : c * dir; }); diff --git a/apps/yaak-proxy/lib/opener.test.ts b/apps/yaak-proxy/lib/opener.test.ts index a59946036..e62892a3e 100644 --- a/apps/yaak-proxy/lib/opener.test.ts +++ b/apps/yaak-proxy/lib/opener.test.ts @@ -6,7 +6,7 @@ const opener = vi.hoisted(() => ({ })); vi.mock("@tauri-apps/plugin-opener", () => opener); -const { openLink, revealPath } = await import("./opener"); +const { openLink, revealLabel, revealPath } = await import("./opener"); describe("opener", () => { beforeEach(() => { @@ -39,4 +39,10 @@ describe("opener", () => { await openLink("https://yaak.app", report); expect(report).toHaveBeenCalledWith("Failed to open https://yaak.app: Error: denied"); }); + + test("names the file manager by platform", () => { + expect(revealLabel("macos")).toBe("Reveal in Finder"); + expect(revealLabel("windows")).toBe("Show in Explorer"); + expect(revealLabel("linux")).toBe("Show in Folder"); + }); }); diff --git a/apps/yaak-proxy/lib/opener.ts b/apps/yaak-proxy/lib/opener.ts index 7d653095c..a448b5970 100644 --- a/apps/yaak-proxy/lib/opener.ts +++ b/apps/yaak-proxy/lib/opener.ts @@ -20,3 +20,10 @@ export async function openLink(url: string, report: ReportFailure): Promise { expect(items.get("app:curl|site:example.com")?.kind).toBe("site"); }); + test("folder rows carry their full path, and programs their executable", () => { + expect(items.get("app:claude|cwd:yaak")?.path).toBe("/src/yaak"); + expect(items.get("app:claude|cwd:fieldwork")?.path).toBe("/src/fieldwork"); + expect(items.get("app:claude")?.path).toBe("/usr/local/bin/claude"); + // Hosts and paths under a folder are not on disk. + expect(items.get("app:claude|cwd:yaak|host:statsig.anthropic.com")?.path).toBeUndefined(); + }); + test("a folder row selects that program in that folder", () => { const query = items.get("app:claude|cwd:yaak")?.query ?? ""; expect(query).toBe("@app:claude @cwd:yaak"); @@ -169,6 +177,11 @@ describe("programs outside an app", () => { }); }); +test("an app in a bundle has no path to reveal", () => { + const items = itemsById(buildSidebarTree([flow("https://slack.com/", slack)], 1)); + expect(items.get("app:Slack")?.path).toBeUndefined(); +}); + describe("decrypting from the sidebar", () => { const tunnel = flow("https://bank.example.com", { ...slack, diff --git a/apps/yaak-proxy/lib/sidebarTree.ts b/apps/yaak-proxy/lib/sidebarTree.ts index 76eaa535c..ff16027f2 100644 --- a/apps/yaak-proxy/lib/sidebarTree.ts +++ b/apps/yaak-proxy/lib/sidebarTree.ts @@ -37,6 +37,11 @@ export interface SidebarItem { decrypt?: DecryptSubject; /** The saved search a Saved row is, for renaming and deleting it */ savedId?: string; + /** + * On disk: a folder row's working directory, in full, and a program's executable, for Reveal + * in Finder and, on a folder, Copy path + */ + path?: string; } /** A saved search as the sidebar lists it, with how many flows it covers */ @@ -62,6 +67,8 @@ interface PlaceRow { label: string; query: string; decrypt?: DecryptSubject; + /** A folder row's working directory, in full */ + path?: string; } /** @@ -128,8 +135,15 @@ function placeOf(flow: Flow): Place { } else { const folderId = `app:${app}|cwd:${folder}`; const folderQuery = `${appQuery} ${fieldTerm("cwd", folder)}`; + const folderRow: PlaceRow = { + id: folderId, + kind: "folder", + label: folder, + query: folderQuery, + }; + if (flow.clientCwd != null) folderRow.path = flow.clientCwd; appRows = [ - { id: folderId, kind: "folder", label: folder, query: folderQuery }, + folderRow, ...rows.map((r) => ({ ...r, id: `${folderId}|${r.id}`, @@ -169,6 +183,7 @@ function child(parent: Trie, row: PlaceRow): Trie { children: new Map(), }; if (row.decrypt != null) node.item.decrypt = row.decrypt; + if (row.path != null) node.item.path = row.path; parent.children.set(row.id, node); } node.item.count++; @@ -257,6 +272,8 @@ export function buildSidebarTree( } if (folder != null) item.sublabel = folder; if (flow.clientPath != null) item.appPath = flow.clientPath; + // A program outside an app bundle: its executable + if (flow.clientBundleId == null && flow.clientPath != null) item.path = flow.clientPath; app = { item, children: new Map() }; parent.children.set(id, app); } diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index b23a6871c..814e0df46 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -4,6 +4,10 @@ import type { HTMLAttributes, ReactNode } from "react"; import { forwardRef } from "react"; import { Icon } from "./Icon"; import { LoadingIcon } from "./LoadingIcon"; +import { Tooltip } from "./Tooltip"; + +/** How long the pointer rests on a control before its tooltip shows, in milliseconds */ +export const CONTROL_TOOLTIP_DELAY = 300; type ButtonVariant = "border" | "solid" | "input"; type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto"; @@ -22,6 +26,8 @@ export type ButtonProps = Omit, "color" | "onC forDropdown?: boolean; disabled?: boolean; title?: string; + /** Shows `title` in Yaak's tooltip, quickly, rather than the browser's */ + tooltip?: boolean; leftSlot?: ReactNode; rightSlot?: ReactNode; }; @@ -43,6 +49,7 @@ export const Button = forwardRef(function Button rightSlot, disabled, title, + tooltip, onClick, ...props }: ButtonProps, @@ -50,8 +57,9 @@ export const Button = forwardRef(function Button ) { const resolvedColor = color ?? tone ?? "default"; const isDisabled = disabled || isLoading; + const withTooltip = tooltip === true && title != null && title !== ""; - return ( + const button = ( ); + + return withTooltip ? ( + + {button} + + ) : ( + button + ); }); diff --git a/packages/ui/src/components/ResizeHandle.tsx b/packages/ui/src/components/ResizeHandle.tsx index 114ea8c60..6e362e527 100644 --- a/packages/ui/src/components/ResizeHandle.tsx +++ b/packages/ui/src/components/ResizeHandle.tsx @@ -19,6 +19,8 @@ interface Props { onReset?: () => void; side: "left" | "right" | "top"; justify: "center" | "end" | "start"; + /** Fills the space it is given, all of it taking the pointer, with its bar centred in it */ + fill?: boolean; } export function ResizeHandle({ @@ -30,6 +32,7 @@ export function ResizeHandle({ onResizeMove, onReset, side, + fill = false, }: Props) { const vertical = side === "top"; const [isResizing, setIsResizing] = useState(false); @@ -89,8 +92,12 @@ export function ResizeHandle({ onPointerDown={handlePointerDown} className={classNames( className, - "group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full", - vertical ? "w-full h-1.5 cursor-row-resize" : "h-full w-1.5 cursor-col-resize", + "group z-10 flex select-none", + vertical ? "cursor-row-resize" : "cursor-col-resize", + fill + ? "w-full h-full items-center" + : "transition-colors hover:bg-surface-active rounded-full", + !fill && (vertical ? "w-full h-1.5" : "h-full w-1.5"), justify === "center" && "justify-center", justify === "end" && "justify-end", justify === "start" && "justify-start", @@ -99,6 +106,15 @@ export function ResizeHandle({ side === "top" && "top-0", )} > + {fill && ( +
+ )} {isResizing && (
( () => ({ ...style, - gridTemplate: splitGridTemplate(vertical, vertical ? renderedHeight : renderedWidth), + gridTemplate: splitGridTemplate(vertical, vertical ? renderedHeight : renderedWidth, gutter), }), - [style, vertical, renderedHeight, renderedWidth], + [style, vertical, renderedHeight, renderedWidth, gutter], ); // While dragging, the split is written straight to the DOM and only saved (and rendered) when // the drag ends, so the panes are not re-rendered on every move. const liveDrag = useRef(null); - const applySplit = useCallback((vertical: boolean, ratio: number) => { - if (containerRef.current == null) return; - containerRef.current.style.gridTemplate = splitGridTemplate(vertical, ratio); - }, []); + const applySplit = useCallback( + (vertical: boolean, ratio: number) => { + if (containerRef.current == null) return; + containerRef.current.style.gridTemplate = splitGridTemplate(vertical, ratio, gutter); + }, + [gutter], + ); // Something else re-rendering mid-drag (a response arriving, say) may write the saved split // back; put the live one back before it is painted. @@ -102,9 +112,16 @@ export function SplitLayout({ const $c = containerRef.current; if ($c == null) return 0; const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle($c); + // The panes share what the gutter leaves return vertical - ? $c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom) - : $c.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight); + ? $c.clientHeight - + Number.parseFloat(paddingTop) - + Number.parseFloat(paddingBottom) - + gutter + : $c.clientWidth - + Number.parseFloat(paddingLeft) - + Number.parseFloat(paddingRight) - + gutter; }, apply: (ratio) => applySplit(vertical, ratio), commit: (ratio) => (vertical ? setHeight(ratio) : setWidth(ratio)), @@ -115,6 +132,7 @@ export function SplitLayout({ renderedWidth, minHeightPx, minWidthPx, + gutter, applySplit, setHeight, setWidth, @@ -151,8 +169,9 @@ export function SplitLayout({ style={areaD} className={classNames( resizeHandleClassName, - vertical ? "-translate-y-1" : "-translate-x-1", + gutter === 0 && (vertical ? "-translate-y-1" : "-translate-x-1"), )} + fill={gutter > 0} onResizeStart={handleResizeStart} onResizeMove={handleResizeMove} onResizeEnd={handleResizeEnd} diff --git a/packages/ui/src/components/Tooltip.tsx b/packages/ui/src/components/Tooltip.tsx index d32624cc2..0fc913b46 100644 --- a/packages/ui/src/components/Tooltip.tsx +++ b/packages/ui/src/components/Tooltip.tsx @@ -1,6 +1,6 @@ import classNames from "classnames"; import type { CSSProperties, KeyboardEvent, ReactNode } from "react"; -import { useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { generateId } from "@yaakapp-internal/lib/generateId"; import { Portal } from "./Portal"; @@ -10,6 +10,15 @@ export interface TooltipProps { tabIndex?: number; size?: "md" | "lg"; className?: string; + /** How long the pointer rests before it shows, in milliseconds */ + delay?: number; + /** + * Wraps a control or a cell without a box of its own: shown on hover alone, hidden on a press, + * and placed against the first element inside, which keeps its own focus and clicks + */ + wrap?: boolean; + /** Shown only while something inside is cut short, as a long host in a narrow column is */ + onlyWhenTruncated?: boolean; } const hiddenStyles: CSSProperties = { @@ -27,27 +36,46 @@ interface TooltipOpenState { position: TooltipPosition; } -export function Tooltip({ children, className, content, tabIndex, size = "md" }: TooltipProps) { +export function Tooltip({ + children, + className, + content, + tabIndex, + size = "md", + delay = 500, + wrap = false, + onlyWhenTruncated = false, +}: TooltipProps) { + // Open, then placed once measured; the tooltip is only in the page while open. + const [open, setOpen] = useState(false); const [openState, setOpenState] = useState(null); - const triggerRef = useRef(null); + const triggerRef = useRef(null); const tooltipRef = useRef(null); const showTimeout = useRef>(undefined); - const handleOpenImmediate = () => { - if (triggerRef.current == null || tooltipRef.current == null) return; - clearTimeout(showTimeout.current); - const triggerRect = triggerRef.current.getBoundingClientRect(); + useEffect(() => () => clearTimeout(showTimeout.current), []); + + useLayoutEffect(() => { + if (!open) { + setOpenState(null); + return; + } + const trigger = anchorOf(triggerRef.current, wrap); + if (trigger == null || tooltipRef.current == null) return; + const triggerRect = trigger.getBoundingClientRect(); const tooltipRect = tooltipRef.current.getBoundingClientRect(); const viewportHeight = document.documentElement.clientHeight; + const viewportWidth = document.documentElement.clientWidth; const margin = 8; const spaceAbove = Math.max(0, triggerRect.top - margin); const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin); const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove; const position: TooltipPosition = preferBottom ? "bottom" : "top"; + const centred = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; const styles: CSSProperties = { - left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2), + left: Math.max(0, Math.min(centred, viewportWidth - tooltipRect.width)), maxHeight: position === "top" ? spaceAbove : spaceBelow, ...(position === "top" ? { bottom: viewportHeight - triggerRect.top } @@ -55,25 +83,31 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }: }; setOpenState({ styles, position }); + }, [open, wrap]); + + const handleOpenImmediate = () => { + clearTimeout(showTimeout.current); + if (onlyWhenTruncated && !isTruncated(anchorOf(triggerRef.current, wrap))) return; + setOpen(true); }; const handleOpen = () => { clearTimeout(showTimeout.current); - showTimeout.current = setTimeout(handleOpenImmediate, 500); + showTimeout.current = setTimeout(handleOpenImmediate, delay); }; const handleClose = () => { clearTimeout(showTimeout.current); - setOpenState(null); + setOpen(false); }; const handleToggleImmediate = () => { - if (openState) handleClose(); + if (open) handleClose(); else handleOpenImmediate(); }; - const handleKeyDown = (e: KeyboardEvent) => { - if (openState && e.key === "Escape") { + const handleKeyDown = (e: KeyboardEvent) => { + if (open && e.key === "Escape") { e.preventDefault(); e.stopPropagation(); handleClose(); @@ -84,52 +118,88 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }: return ( <> - - - - {/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Needs to be usable in other buttons */} - - {children} - + + )} + {wrap ? ( + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- hover only; what it wraps keeps its own focus and clicks + + {children} + + ) : ( + // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Needs to be usable in other buttons + + {children} + + )} ); } +/** What the tooltip is placed against: the trigger, or a wrapping one's first element */ +function anchorOf(trigger: HTMLElement | null, wrap: boolean): Element | null { + if (trigger == null) return null; + return wrap ? (trigger.firstElementChild ?? trigger) : trigger; +} + +/** Whether `el`, or anything in it, is cut short */ +function isTruncated(el: Element | null): boolean { + if (el == null) return false; + if (el.scrollWidth > el.clientWidth + 1) return true; + for (const child of el.querySelectorAll("*")) { + if (child.scrollWidth > child.clientWidth + 1) return true; + } + return false; +} + function Triangle({ className, position }: { className?: string; position: "top" | "bottom" }) { const isBottom = position === "bottom"; diff --git a/packages/ui/src/components/resizeDrag.test.ts b/packages/ui/src/components/resizeDrag.test.ts index e5f3ca4ea..923a752b8 100644 --- a/packages/ui/src/components/resizeDrag.test.ts +++ b/packages/ui/src/components/resizeDrag.test.ts @@ -6,6 +6,7 @@ import { createHandleDrag, createSidebarDrag, createSplitDrag, + splitGridTemplate, } from "./resizeDrag"; let frames: Map; @@ -225,6 +226,23 @@ describe("clampSplitRatio", () => { }); }); +describe("splitGridTemplate", () => { + const tracks = (t: string) => t.replace(/\s+/g, " ").trim(); + + test("puts the gutter between the panes, the handle's track", () => { + expect(tracks(splitGridTemplate(false, 0.4, 20))).toBe( + "' left drag right' minmax(0,1fr) / 0.6fr 20px 0.4fr", + ); + expect(tracks(splitGridTemplate(true, 0.5, 8))).toBe( + "' left' minmax(0,0.5fr) ' drag' 8px ' right' minmax(0,0.5fr) / 1fr", + ); + }); + + test("lets the panes meet without one", () => { + expect(tracks(splitGridTemplate(false, 0.5))).toContain("0.5fr 0px 0.5fr"); + }); +}); + describe("createSidebarDrag", () => { function sidebar() { const apply = vi.fn(); diff --git a/packages/ui/src/components/resizeDrag.ts b/packages/ui/src/components/resizeDrag.ts index f248b8072..fc0f2b75f 100644 --- a/packages/ui/src/components/resizeDrag.ts +++ b/packages/ui/src/components/resizeDrag.ts @@ -93,17 +93,18 @@ export function createHandleDrag({ }; } -export function splitGridTemplate(vertical: boolean, ratio: number): string { +/** The split's grid: the two panes by `ratio`, and between them `gutter` pixels for the handle */ +export function splitGridTemplate(vertical: boolean, ratio: number, gutter = 0): string { return vertical ? ` ' left' minmax(0,${1 - ratio}fr) - ' drag' 0 + ' drag' ${gutter}px ' right' minmax(0,${ratio}fr) / 1fr ` : ` ' left drag right' minmax(0,1fr) - / ${1 - ratio}fr 0 ${ratio}fr + / ${1 - ratio}fr ${gutter}px ${ratio}fr `; } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 3e5c69a72..a004464d3 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,7 +1,7 @@ export type { BannerProps } from "./components/Banner"; export { Banner } from "./components/Banner"; export type { ButtonProps } from "./components/Button"; -export { Button } from "./components/Button"; +export { Button, CONTROL_TOOLTIP_DELAY } from "./components/Button"; export type { CheckboxProps } from "./components/Checkbox"; export { Checkbox } from "./components/Checkbox"; export type { CopyIconButtonProps } from "./components/CopyIconButton";