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";