From 4e63ba2ce75888e45d521e55186ef55f5979a46c Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 01:10:35 -0700 Subject: [PATCH 01/13] Drag Yaak Proxy's flow list columns to reorder them A column's label drags it: a lifted ghost follows the pointer, a line marks the boundary it would drop at, down the header and the rows in view, and the list scrolls sideways near its edges. A press that doesn't move still sorts, the resize handles still resize, and Escape cancels. The order is kept with the other column preferences as a list of ids, and the header menu gains Move Left and Move Right, the columns menu Reset Column Order. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/components/FlowList.tsx | 27 +- apps/yaak-proxy/components/FlowListHeader.tsx | 368 +++++++++++++++--- apps/yaak-proxy/lib/columns.test.ts | 150 +++++++ apps/yaak-proxy/lib/columns.ts | 113 +++++- apps/yaak-proxy/package.json | 1 + package-lock.json | 1 + 6 files changed, 592 insertions(+), 68 deletions(-) diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index edf49d933..75b4ae566 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -301,6 +301,15 @@ export function FlowList({ style, className }: Props) { el.scrollLeft += e.deltaX; }, []); + // A dragged column's drop line, from the window's coordinates to the list's + const rootRef = useRef(null); + const [dropLine, setDropLine] = useState(null); + const getScrollElement = useCallback(() => scrollRef.current, []); + const handleDropLine = useCallback((x: number | null) => { + const root = rootRef.current; + setDropLine(x == null || root == null ? null : x - root.getBoundingClientRect().left); + }, []); + // A list too short to scroll still loads older flows on scrolling up. const handleWheel = useCallback( (e: WheelEvent) => { @@ -407,10 +416,11 @@ export function FlowList({ style, className }: Props) { return (
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- scrolls the list sideways */} @@ -430,8 +440,23 @@ export function FlowList({ style, className }: Props) { onSort={handleSort} onSetSort={handleSetSort} onResetSort={follow} + scrollElement={getScrollElement} + onDropLine={handleDropLine} />
+ {/* Where a dragged column would drop, down the header and the rows in view */} + {dropLine != null && ( +
+
+
+ )}
void; onResetSort: () => void; + /** The rows' scrolling element, which a column dragged to its edge scrolls sideways */ + scrollElement: () => HTMLElement | null; + /** Where a dragged column would drop, in the window's coordinates, or null for nowhere */ + onDropLine: (x: number | null) => void; } /** * The list's only control surface: a click on a label sorts by it alone, a second reverses it * and a third goes back to arrival order; Option-click adds it to the sort as a further key. - * Dragging the edge after a label resizes its column and a double-click on it resets it. A - * right-click on a label opens the column's menu; the picker sits in the empty corner on hover. + * Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label + * resizes its column and a double-click on it resets it. A right-click on a label opens the + * column's menu; the picker sits in the empty corner on hover. */ export const FlowListHeader = memo(function FlowListHeader({ columns, @@ -60,6 +94,8 @@ export const FlowListHeader = memo(function FlowListHeader({ onSort, onSetSort: setSort, onResetSort, + scrollElement, + onDropLine, }: Props) { const setPrefs = useSetAtom(columnPrefsAtom); const unavailable = unavailableColumns(useAtomValue(hasAppsAtom)); @@ -67,6 +103,12 @@ export const FlowListHeader = memo(function FlowListHeader({ const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null); // Widths when a drag began, so each move sets the width from where it started. const startWidth = useRef(0); + const { dragging, sensors, dndHandlers, gridRef } = useColumnDrag({ + columns, + scrollElement, + onDropLine, + onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)), + }); // Taking out the sort's only key goes back to arrival order, as Reset Sort does const removeColumn = (id: CustomColumnId) => { @@ -110,11 +152,17 @@ export const FlowListHeader = memo(function FlowListHeader({ { type: "separator" }, { label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort }, { label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) }, + { + label: "Reset Column Order", + disabled: prefs.order.length === 0, + onSelect: () => setPrefs((p) => resetOrder(p)), + }, ]; const columnItems = (c: ColumnDef): DropdownItem[] => { const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]); const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending)); + const at = columns.findIndex((col) => col.id === c.id); return [ { label: "Sort Ascending", onSelect: () => sortBy(false) }, { label: "Sort Descending", onSelect: () => sortBy(true) }, @@ -122,6 +170,17 @@ export const FlowListHeader = memo(function FlowListHeader({ { label: "Add to Sort Descending", onSelect: () => addTo(true) }, { label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort }, { type: "separator" }, + { + label: "Move Left", + disabled: at <= 0, + onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, -1, unavailable)), + }, + { + label: "Move Right", + disabled: at === -1 || at >= columns.length - 1, + onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, 1, unavailable)), + }, + { type: "separator" }, { label: "Hide Column", disabled: !canHide(prefs, c.id, unavailable), @@ -163,6 +222,7 @@ export const FlowListHeader = memo(function FlowListHeader({
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
- {columns.map((c) => { - const at = sort.findIndex((k) => k.column === c.id); - const key = sort[at]; - const label = ( - <> - {key != null && ( - - )} - {key != null && sort.length > 1 && ( - {at + 1} - )} - {c.id === "app" ? "" : c.label} - - ); - const labelClass = classNames( - "flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap", - c.align === "right" && "justify-end", - key != null && "text-text-subtle", - ); - return ( - + {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) => resetWidth(p, c.id))} - /> - - ); - })} + column={c} + sort={sort} + dragging={dragging?.id === c.id} + onSort={onSort} + onResizeStart={() => { + startWidth.current = c.current; + }} + onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))} + onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))} + /> + ))} + {/* Over everything, as the header clips what overflows it */} + {createPortal( + + {dragging != null && ( +
+ +
+ )} +
, + document.body, + )} + ); }); + +interface HeaderCellProps { + column: Column; + sort: FlowSortKey[]; + /** Being dragged, so it dims where it was */ + dragging: boolean; + onSort: (column: ColumnId, add: boolean) => void; + onResizeStart: () => void; + /** How far the edge moved since the resize began */ + onResizeMove: (dx: number) => void; + onResetWidth: () => void; +} + +function HeaderCell({ + column: c, + sort, + dragging, + onSort, + onResizeStart, + onResizeMove, + onResetWidth, +}: HeaderCellProps) { + // The label drags the column; the resize handle beside it is outside it, so it still resizes. + const { listeners, setNodeRef } = useDraggable({ id: c.id }); + const sorted = sort.some((k) => k.column === c.id); + return ( + + + {/* On the column's right edge, the boundary with the next, resizing this one */} + onResizeMove(e.x - e.xStart)} + onReset={onResetWidth} + /> + + ); +} + +/** + * A column's name, after its sort direction and, sorted by several, its place in the sort. App's + * column is as narrow as its icons, so only its dragged ghost names it. + */ +function ColumnLabel({ + column: c, + sort, + ghost = false, +}: { + column: Column; + sort: FlowSortKey[]; + ghost?: boolean; +}) { + const at = sort.findIndex((k) => k.column === c.id); + const key = sort[at]; + return ( + <> + {key != null && ( + + )} + {key != null && sort.length > 1 && ( + {at + 1} + )} + {c.id === "app" && !ghost ? "" : c.label} + + ); +} + +/** + * Dragging a column's label: where it would drop, the boundary between the labels nearest the + * pointer, and the list scrolling sideways while the pointer is near its edge. The columns only + * move on the drop, so the rows lay out once. + */ +function useColumnDrag({ + columns, + scrollElement, + onDropLine, + onDrop, +}: { + columns: Column[]; + scrollElement: () => HTMLElement | null; + onDropLine: (x: number | null) => void; + /** Moves the column to the boundary `to` between those shown */ + onDrop: (id: ColumnId, to: number) => void; +}) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: DRAG_DISTANCE } }), + ); + const gridRef = useRef(null); + const [dragging, setDragging] = useState(null); + // Live while dragging, read each frame + const state = useRef<{ + /** The column dragged */ + id: ColumnId; + /** The pointer when the press began, and now */ + start: number; + x: number; + /** The boundary it would drop at, and that boundary's line, or null where it would not move */ + to: number | null; + line: number | null; + frame: number; + } | null>(null); + const latest = useRef({ columns, scrollElement, onDropLine, onDrop }); + useLayoutEffect(() => { + latest.current = { columns, scrollElement, onDropLine, onDrop }; + }); + + const stop = useCallback(() => { + const s = state.current; + state.current = null; + if (s != null) cancelAnimationFrame(s.frame); + setDragging(null); + latest.current.onDropLine(null); + return s; + }, []); + + // A drag never outlives the header + useEffect( + () => () => { + stop(); + }, + [stop], + ); + + const onDragStart = useCallback((e: DragStartEvent) => { + const column = latest.current.columns.find((c) => c.id === e.active.id); + if (column == null) return; + const x = pointerX(e); + state.current = { id: column.id, start: x, x, to: null, line: null, frame: 0 }; + setDragging(column); + + const tick = () => { + const s = state.current; + if (s == null) return; + // Near an edge, the list scrolls that way, faster the nearer; the header follows it. + const list = latest.current.scrollElement(); + if (list != null) { + const { left, right } = list.getBoundingClientRect(); + const near = + s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0; + if (near !== 0) { + const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED; + list.scrollLeft += Math.sign(near) * Math.max(1, speed); + } + } + // The boundary before the first label whose middle is right of the pointer + const cells = Array.from( + gridRef.current?.querySelectorAll(":scope > [data-column]") ?? [], + ).map((el) => el.getBoundingClientRect()); + let to = cells.findIndex((r) => s.x < r.left + r.width / 2); + if (to === -1) to = cells.length; + // Where it already is, it would not move, so no line. Its place is read each frame, as + // the columns can change under a drag: Operation shows itself when a flow has one. + const from = latest.current.columns.findIndex((c) => c.id === s.id); + const moves = from !== -1 && to !== from && to !== from + 1; + s.to = moves ? to : null; + const line = moves ? (cells[to]?.left ?? cells.at(-1)?.right ?? null) : null; + // Told again only when it changes: another boundary, or the same one scrolled + if (line !== s.line) latest.current.onDropLine(line); + s.line = line; + s.frame = requestAnimationFrame(tick); + }; + state.current.frame = requestAnimationFrame(tick); + }, []); + + // The pointer, from where the press began; the frames above read it, scrolling or not. + const onDragMove = useCallback((e: DragMoveEvent) => { + const s = state.current; + if (s != null) s.x = s.start + e.delta.x; + }, []); + + const onDragEnd = useCallback(() => { + const s = stop(); + if (s?.to != null) latest.current.onDrop(s.id, s.to); + }, [stop]); + + return { + dragging, + sensors, + gridRef, + dndHandlers: { onDragStart, onDragMove, onDragEnd, onDragCancel: stop }, + }; +} + +/** Where the press that began a drag was, across the window */ +function pointerX(e: DragStartEvent): number { + const event = e.activatorEvent; + return event instanceof globalThis.MouseEvent ? event.clientX : 0; +} diff --git a/apps/yaak-proxy/lib/columns.test.ts b/apps/yaak-proxy/lib/columns.test.ts index 95b3dcce9..446b3c71c 100644 --- a/apps/yaak-proxy/lib/columns.test.ts +++ b/apps/yaak-proxy/lib/columns.test.ts @@ -4,6 +4,7 @@ import customSortFixture from "../../../crates-proxy/yaak-proxy-lib/tests/fixtur import { addCustomColumn, addSortKey, + allColumns, cellFilterTerm, type ColumnPrefs, customPath, @@ -15,10 +16,13 @@ import { gridTemplate, gridWidth, hideOtherColumns, + moveColumn, + moveColumnBy, nextSort, operationOf, parseColumnPrefs, removeCustomColumn, + resetOrder, resetWidth, resetWidths, resizeColumn, @@ -121,6 +125,7 @@ describe("column preferences", () => { visible: { number: false, "field:header.x-cache": true }, widths: { host: 60 }, custom: ["field:header.x-cache"], + order: [], sort: [{ column: "status", descending: true }], }); expect( @@ -138,6 +143,7 @@ describe("column preferences", () => { visible: {}, widths: { size: 90 }, custom: ["field:query.page"], + order: [], sort: [{ column: "time", descending: false }], }); expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs); @@ -181,6 +187,150 @@ describe("column preferences", () => { }); }); +describe("column order", () => { + const defaults = ids(defaultColumnPrefs); + + test("a column moves to the boundary it was dropped at", () => { + expect(ids(moveColumn(defaultColumnPrefs, "status", 2))).toEqual([ + "number", + "app", + "status", + "method", + "host", + "path", + "query", + "type", + "size", + "time", + ]); + // Rightwards, before the column at the boundary + expect(ids(moveColumn(defaultColumnPrefs, "method", 5)).slice(2, 6)).toEqual([ + "host", + "path", + "method", + "query", + ]); + // To either end + expect(ids(moveColumn(defaultColumnPrefs, "time", 0))[0]).toBe("time"); + expect(ids(moveColumn(defaultColumnPrefs, "number", defaults.length)).at(-1)).toBe("number"); + // The picker lists them in the same order + const moved = moveColumn(defaultColumnPrefs, "time", 0); + expect(allColumns(moved)[0]?.id).toBe("time"); + }); + + test("a drop where the column already is changes nothing", () => { + expect(moveColumn(defaultColumnPrefs, "host", 3)).toBe(defaultColumnPrefs); + expect(moveColumn(defaultColumnPrefs, "host", 4)).toBe(defaultColumnPrefs); + expect(moveColumn(defaultColumnPrefs, "host", -1)).toBe(defaultColumnPrefs); + expect(moveColumn(defaultColumnPrefs, "host", defaults.length + 1)).toBe(defaultColumnPrefs); + // A hidden column has no place among those shown + expect(moveColumn(defaultColumnPrefs, "latency", 0)).toBe(defaultColumnPrefs); + }); + + test("Move Left and Move Right go one place, and not past the ends", () => { + expect(ids(moveColumnBy(defaultColumnPrefs, "host", -1)).slice(2, 5)).toEqual([ + "host", + "method", + "path", + ]); + expect(ids(moveColumnBy(defaultColumnPrefs, "host", 1)).slice(3, 6)).toEqual([ + "path", + "host", + "query", + ]); + expect(moveColumnBy(defaultColumnPrefs, "number", -1)).toBe(defaultColumnPrefs); + expect(moveColumnBy(defaultColumnPrefs, "time", 1)).toBe(defaultColumnPrefs); + // Where App has nothing to show, Number is next to Method + expect( + shownColumns(moveColumnBy(defaultColumnPrefs, "method", -1, ["app"]), ["app"]).map( + (c) => c.id, + )[0], + ).toBe("method"); + }); + + test("a hidden column keeps its place and comes back to it", () => { + let prefs = moveColumn(defaultColumnPrefs, "time", 0); + prefs = toggleColumn(prefs, "status"); + prefs = moveColumn(prefs, "host", 0); + expect(ids(prefs)).not.toContain("status"); + prefs = toggleColumn(prefs, "status"); + expect(ids(prefs)).toEqual([ + "host", + "time", + "number", + "app", + "method", + "path", + "query", + "status", + "type", + "size", + ]); + }); + + test("a column turned on goes at the end, unless it had a place", () => { + // Request Size's default place is before Size + let prefs = toggleColumn(defaultColumnPrefs, "requestSize"); + expect(ids(prefs).at(-1)).toBe("requestSize"); + expect(ids(prefs).slice(0, -1)).toEqual(defaults); + // Moved, hidden and shown again, it goes back where it was moved to + prefs = moveColumn(prefs, "requestSize", defaults.indexOf("size")); + prefs = toggleColumn(toggleColumn(prefs, "requestSize"), "requestSize"); + expect(ids(prefs).slice(-3)).toEqual(["requestSize", "size", "time"]); + // Already last of those shown, nothing needs placing + expect(toggleColumn(defaultColumnPrefs, "remote").order).toEqual([]); + }); + + test("a custom column is added at the end and leaves the order when removed", () => { + const moved = moveColumn(defaultColumnPrefs, "size", 0); + let prefs = addCustomColumn(moved, "header.x-cache"); + expect(ids(prefs).at(-1)).toBe("field:header.x-cache"); + prefs = moveColumn(prefs, "field:header.x-cache", 1); + expect(ids(prefs).slice(0, 2)).toEqual(["size", "field:header.x-cache"]); + // Hidden and added again, it keeps its place + prefs = addCustomColumn(toggleColumn(prefs, "field:header.x-cache"), "header.x-cache"); + expect(ids(prefs)[1]).toBe("field:header.x-cache"); + prefs = removeCustomColumn(prefs, "field:header.x-cache"); + expect(prefs.order).not.toContain("field:header.x-cache"); + expect(ids(prefs)).toEqual(ids(moved)); + // Added again, it starts over at the end + expect(ids(addCustomColumn(prefs, "header.x-cache")).at(-1)).toBe("field:header.x-cache"); + }); + + test("the order persists, and columns it doesn't list follow it in their default order", () => { + let prefs = addCustomColumn(defaultColumnPrefs, "query.page"); + prefs = moveColumn(prefs, "field:query.page", 0); + expect(parseColumnPrefs(JSON.parse(JSON.stringify(prefs))).order).toEqual(prefs.order); + const parsed = parseColumnPrefs({ + custom: ["field:query.page"], + order: ["time", "gone", 3, "time", "field:header.x-gone", "field:query.page", "host"], + }); + expect(parsed.order).toEqual(["time", "field:query.page", "host"]); + expect(ids(parsed)).toEqual([ + "time", + "field:query.page", + "host", + "number", + "app", + "method", + "path", + "query", + "status", + "type", + "size", + ]); + expect(parseColumnPrefs({ order: "time" }).order).toEqual([]); + }); + + test("Reset Column Order puts every column back", () => { + let prefs = moveColumn(defaultColumnPrefs, "time", 0); + prefs = toggleColumn(prefs, "latency"); + prefs = resetOrder(prefs); + expect(prefs.order).toEqual([]); + expect(ids(prefs)).toEqual([...defaults.slice(0, -1), "time", "latency"]); + }); +}); + describe("sorting", () => { const one = (column: FlowSortKey["column"], descending: boolean) => [{ column, descending }]; diff --git a/apps/yaak-proxy/lib/columns.ts b/apps/yaak-proxy/lib/columns.ts index f94655389..e55e2b4a2 100644 --- a/apps/yaak-proxy/lib/columns.ts +++ b/apps/yaak-proxy/lib/columns.ts @@ -145,26 +145,45 @@ export const CELL_PADDING = "0.75rem"; /** * What the person chose: which columns show, the widths they dragged, by id, the header and - * query parameter columns they added, in order, and the list's sort + * query parameter columns they added, in order, the order they dragged the columns into, and the + * list's sort */ export interface ColumnPrefs { visible: Partial>; widths: Partial>; custom: CustomColumnId[]; + /** + * The columns' order, by id. Columns not listed follow the listed ones, in their default order; + * empty for the default order. + */ + order: ColumnId[]; /** The keys in order; empty for the order flows arrive in */ sort: FlowSortKey[]; } -export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] }; +export const defaultColumnPrefs: ColumnPrefs = { + visible: {}, + widths: {}, + custom: [], + order: [], + sort: [], +}; export interface Column extends ColumnDef { /** Its width now, in pixels of content */ current: number; } -/** Every column the picker lists: the built-in ones, then those the person added */ +/** + * Every column, in the order the person left them: those they placed, then the rest in their + * default order, the built-in ones before those added. The picker lists them so. + */ export function allColumns(prefs: ColumnPrefs): ColumnDef[] { - return [...COLUMNS, ...prefs.custom.map(customDef)]; + const defaults = [...COLUMNS, ...prefs.custom.map(customDef)]; + if (prefs.order.length === 0) return defaults; + const byId = new Map(defaults.map((c) => [c.id, c])); + const placed = prefs.order.flatMap((id) => byId.get(id) ?? []); + return [...placed, ...defaults.filter((c) => !prefs.order.includes(c.id))]; } function isVisible(prefs: ColumnPrefs, def: ColumnDef): boolean { @@ -217,7 +236,66 @@ export function toggleColumn( ): ColumnPrefs { const shown = isVisible(prefs, columnDef(id)); if (shown && !canHide(prefs, id, unavailable)) return prefs; - return { ...prefs, visible: { ...prefs.visible, [id]: !shown } }; + const next = { ...prefs, visible: { ...prefs.visible, [id]: !shown } }; + return shown ? next : placeAtEnd(next, id); +} + +/** + * Puts a column just shown after the others shown, unless the person placed it before, so it + * goes back to that place. The columns shown before it are placed too, where they are, so the + * ones not listed don't jump ahead of it. + */ +function placeAtEnd(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs { + if (prefs.order.includes(id)) return prefs; + const all = allColumns(prefs); + if (all.filter((c) => isVisible(prefs, c)).at(-1)?.id === id) return prefs; + const placed = all.filter( + (c) => c.id !== id && (prefs.order.includes(c.id) || isVisible(prefs, c)), + ); + return { ...prefs, order: [...placed.map((c) => c.id), id] }; +} + +/** + * Moves a shown column to the boundary `to` between the columns shown: 0 before the first, their + * count after the last. Hidden columns keep their places among the others. + */ +export function moveColumn( + prefs: ColumnPrefs, + id: ColumnId, + to: number, + unavailable: ColumnId[] = [], +): ColumnPrefs { + const shown = shownColumns(prefs, unavailable).map((c) => c.id); + const from = shown.indexOf(id); + if (from === -1 || to < 0 || to > shown.length || to === from || to === from + 1) return prefs; + // Every column shown, and those hidden that were placed, so they keep their places + const order = allColumns(prefs) + .filter((c) => c.id !== id && (prefs.order.includes(c.id) || shown.includes(c.id))) + .map((c) => c.id); + // Before the column at the boundary, or after the last one shown + const before = shown[to]; + const last = shown.at(-1); + const at = before != null ? order.indexOf(before) : last == null ? -1 : order.indexOf(last) + 1; + if (at === -1) return prefs; + order.splice(at, 0, id); + return { ...prefs, order }; +} + +/** One place left, or with `1`, right, among the columns shown */ +export function moveColumnBy( + prefs: ColumnPrefs, + id: ColumnId, + by: -1 | 1, + unavailable: ColumnId[] = [], +): ColumnPrefs { + const from = shownColumns(prefs, unavailable).findIndex((c) => c.id === id); + if (from === -1) return prefs; + return moveColumn(prefs, id, by === -1 ? from - 1 : from + 2, unavailable); +} + +/** Every column back to its default place */ +export function resetOrder(prefs: ColumnPrefs): ColumnPrefs { + return { ...prefs, order: [] }; } /** Shows `id` alone */ @@ -227,20 +305,24 @@ export function hideOtherColumns(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs return { ...prefs, visible }; } -/** Adds a column for a header or query parameter's path, shown at the end; already there, shown */ +/** + * Adds a column for a header or query parameter's path, shown at the end; already there, shown, + * in its place + */ export function addCustomColumn(prefs: ColumnPrefs, path: string): ColumnPrefs { const id = customColumnId(path); const custom = prefs.custom.includes(id) ? prefs.custom : [...prefs.custom, id]; - return { ...prefs, custom, visible: { ...prefs.visible, [id]: true } }; + return placeAtEnd({ ...prefs, custom, visible: { ...prefs.visible, [id]: true } }, id); } -/** Takes the column out, and out of the sort */ +/** Takes the column out, and out of the order and the sort */ export function removeCustomColumn(prefs: ColumnPrefs, id: CustomColumnId): ColumnPrefs { const { [id]: _v, ...visible } = prefs.visible; const { [id]: _w, ...widths } = prefs.widths; const custom = prefs.custom.filter((c) => c !== id); + const order = prefs.order.filter((c) => c !== id); const sort = prefs.sort.filter((k) => k.column !== id); - return { ...prefs, custom, visible, widths, sort }; + return { ...prefs, custom, visible, widths, order, sort }; } export function resizeColumn(prefs: ColumnPrefs, id: ColumnId, width: number): ColumnPrefs { @@ -270,9 +352,9 @@ export function showOperationFor(prefs: ColumnPrefs, flows: Flow[]): ColumnPrefs * wrong type, so a change to the columns never breaks the list. */ export function parseColumnPrefs(value: unknown): ColumnPrefs { - const prefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] }; + const prefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], order: [], sort: [] }; if (value == null || typeof value !== "object") return prefs; - const { visible, widths, custom, sort } = value as Record; + const { visible, widths, custom, order, sort } = value as Record; if (Array.isArray(custom)) { for (const id of custom) { if (typeof id !== "string" || !id.startsWith("field:")) continue; @@ -288,6 +370,15 @@ export function parseColumnPrefs(value: unknown): ColumnPrefs { const w = (widths as Record | undefined)?.[def.id]; if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w; } + // Columns no longer there are dropped; those not listed follow the rest in their default order + if (Array.isArray(order)) { + for (const column of order) { + if (typeof column !== "string") continue; + const id = column as ColumnId; + const known = isCustomColumn(id) ? prefs.custom.includes(id) : DEFS.has(id); + if (known && !prefs.order.includes(id)) prefs.order.push(id); + } + } if (Array.isArray(sort)) { for (const key of sort) { if (key == null || typeof key !== "object") continue; diff --git a/apps/yaak-proxy/package.json b/apps/yaak-proxy/package.json index 7e160c0b9..dc2baf4ba 100644 --- a/apps/yaak-proxy/package.json +++ b/apps/yaak-proxy/package.json @@ -9,6 +9,7 @@ "lint": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", "@tanstack/react-query": "^5.105.0", "@tanstack/react-virtual": "^3.14.24", "@tauri-apps/api": "^2.12.4", diff --git a/package-lock.json b/package-lock.json index 1238d6bc1..754dd06ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -246,6 +246,7 @@ "name": "@yaakapp/yaak-proxy", "version": "1.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", "@tanstack/react-query": "^5.105.0", "@tanstack/react-virtual": "^3.14.24", "@tauri-apps/api": "^2.12.4", From ba403307d8883e6035fad87b5a10a0d8d51a99b8 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 01:18:44 -0700 Subject: [PATCH 02/13] Drop a dragged column where the pointer is when released A release right after a move used the target from the last frame, or none, since dnd-kit reports a move only once it renders. The drag now tracks the pointer itself and works out the target again on release. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/components/FlowListHeader.tsx | 107 ++++++++++-------- 1 file changed, 59 insertions(+), 48 deletions(-) diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx index 44998936e..223ced700 100644 --- a/apps/yaak-proxy/components/FlowListHeader.tsx +++ b/apps/yaak-proxy/components/FlowListHeader.tsx @@ -1,7 +1,6 @@ import { DndContext, DragOverlay, - type DragMoveEvent, type DragStartEvent, PointerSensor, useDraggable, @@ -406,8 +405,7 @@ function useColumnDrag({ const state = useRef<{ /** The column dragged */ id: ColumnId; - /** The pointer when the press began, and now */ - start: number; + /** The pointer, across the window */ x: number; /** The boundary it would drop at, and that boundary's line, or null where it would not move */ to: number | null; @@ -419,9 +417,16 @@ function useColumnDrag({ latest.current = { columns, scrollElement, onDropLine, onDrop }; }); + // The pointer, read as it moves rather than when dnd-kit next renders, so a release right + // after a move drops where the pointer is + const track = useCallback((e: PointerEvent) => { + if (state.current != null) state.current.x = e.clientX; + }, []); + const stop = useCallback(() => { const s = state.current; state.current = null; + window.removeEventListener("pointermove", track, true); if (s != null) cancelAnimationFrame(s.frame); setDragging(null); latest.current.onDropLine(null); @@ -436,63 +441,69 @@ function useColumnDrag({ [stop], ); - const onDragStart = useCallback((e: DragStartEvent) => { - const column = latest.current.columns.find((c) => c.id === e.active.id); - if (column == null) return; - const x = pointerX(e); - state.current = { id: column.id, start: x, x, to: null, line: null, frame: 0 }; - setDragging(column); + const onDragStart = useCallback( + (e: DragStartEvent) => { + const column = latest.current.columns.find((c) => c.id === e.active.id); + if (column == null) return; + state.current = { id: column.id, x: pointerX(e), to: null, line: null, frame: 0 }; + window.addEventListener("pointermove", track, true); + setDragging(column); - const tick = () => { - const s = state.current; - if (s == null) return; - // Near an edge, the list scrolls that way, faster the nearer; the header follows it. - const list = latest.current.scrollElement(); - if (list != null) { - const { left, right } = list.getBoundingClientRect(); - const near = - s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0; - if (near !== 0) { - const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED; - list.scrollLeft += Math.sign(near) * Math.max(1, speed); + const tick = () => { + const s = state.current; + if (s == null) return; + // Near an edge, the list scrolls that way, faster the nearer; the header follows it. + const list = latest.current.scrollElement(); + if (list != null) { + const { left, right } = list.getBoundingClientRect(); + const near = + s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0; + if (near !== 0) { + const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED; + list.scrollLeft += Math.sign(near) * Math.max(1, speed); + } } - } - // The boundary before the first label whose middle is right of the pointer - const cells = Array.from( - gridRef.current?.querySelectorAll(":scope > [data-column]") ?? [], - ).map((el) => el.getBoundingClientRect()); - let to = cells.findIndex((r) => s.x < r.left + r.width / 2); - if (to === -1) to = cells.length; - // Where it already is, it would not move, so no line. Its place is read each frame, as - // the columns can change under a drag: Operation shows itself when a flow has one. - const from = latest.current.columns.findIndex((c) => c.id === s.id); - const moves = from !== -1 && to !== from && to !== from + 1; - s.to = moves ? to : null; - const line = moves ? (cells[to]?.left ?? cells.at(-1)?.right ?? null) : null; - // Told again only when it changes: another boundary, or the same one scrolled - if (line !== s.line) latest.current.onDropLine(line); - s.line = line; - s.frame = requestAnimationFrame(tick); - }; - state.current.frame = requestAnimationFrame(tick); - }, []); - - // The pointer, from where the press began; the frames above read it, scrolling or not. - const onDragMove = useCallback((e: DragMoveEvent) => { - const s = state.current; - if (s != null) s.x = s.start + e.delta.x; - }, []); + target(s); + s.frame = requestAnimationFrame(tick); + }; + state.current.frame = requestAnimationFrame(tick); + // oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs + }, + [track], + ); const onDragEnd = useCallback(() => { + // Where the pointer is now, as a release can come before the next frame reads it + if (state.current != null) target(state.current); const s = stop(); if (s?.to != null) latest.current.onDrop(s.id, s.to); + // oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs }, [stop]); + /** Sets where the drag would drop, from the pointer and the labels where they are now */ + function target(s: NonNullable) { + // The boundary before the first label whose middle is right of the pointer + const cells = Array.from( + gridRef.current?.querySelectorAll(":scope > [data-column]") ?? [], + ).map((el) => el.getBoundingClientRect()); + let to = cells.findIndex((r) => s.x < r.left + r.width / 2); + if (to === -1) to = cells.length; + // Where it already is, it would not move, so no line. Its place is read each time, as the + // columns can change under a drag: Operation shows itself when a flow has one. + const from = latest.current.columns.findIndex((c) => c.id === s.id); + const moves = from !== -1 && to !== from && to !== from + 1; + s.to = moves ? to : null; + const line = moves ? (cells[to]?.left ?? cells.at(-1)?.right ?? null) : null; + // Told again only when it changes: another boundary, or the same one scrolled + if (line !== s.line) latest.current.onDropLine(line); + s.line = line; + } + return { dragging, sensors, gridRef, - dndHandlers: { onDragStart, onDragMove, onDragEnd, onDragCancel: stop }, + dndHandlers: { onDragStart, onDragEnd, onDragCancel: stop }, }; } From 78351f620e05107d8ab8df62db7ff3a2beb208e4 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 01:28:16 -0700 Subject: [PATCH 03/13] Drop a column dragged and released in one move dnd-kit starts a drag on the move that crosses its threshold and reports the release only once it has rendered the drag, so a quick drag could miss its last position or not drop at all. The header now follows the pointer from the press and drops the column on its release. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/components/FlowListHeader.tsx | 128 +++++++++++------- 1 file changed, 76 insertions(+), 52 deletions(-) diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx index 223ced700..1e002ab48 100644 --- a/apps/yaak-proxy/components/FlowListHeader.tsx +++ b/apps/yaak-proxy/components/FlowListHeader.tsx @@ -21,6 +21,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { memo, type MouseEvent, + type PointerEvent as ReactPointerEvent, useCallback, useEffect, useLayoutEffect, @@ -102,7 +103,7 @@ export const FlowListHeader = memo(function FlowListHeader({ const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null); // Widths when a drag began, so each move sets the width from where it started. const startWidth = useRef(0); - const { dragging, sensors, dndHandlers, gridRef } = useColumnDrag({ + const { dragging, sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({ columns, scrollElement, onDropLine, @@ -222,6 +223,7 @@ export const FlowListHeader = memo(function FlowListHeader({ {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
{ - if (state.current != null) state.current.x = e.clientX; - }, []); - const stop = useCallback(() => { const s = state.current; state.current = null; - window.removeEventListener("pointermove", track, true); if (s != null) cancelAnimationFrame(s.frame); setDragging(null); latest.current.onDropLine(null); return s; }, []); - // A drag never outlives the header - useEffect( - () => () => { - stop(); - }, - [stop], - ); + const onDragStart = useCallback((e: DragStartEvent) => { + const column = latest.current.columns.find((c) => c.id === e.active.id); + if (column == null) return; + state.current = { id: column.id, x: pointer.current, to: null, line: null, frame: 0 }; + setDragging(column); - const onDragStart = useCallback( - (e: DragStartEvent) => { - const column = latest.current.columns.find((c) => c.id === e.active.id); - if (column == null) return; - state.current = { id: column.id, x: pointerX(e), to: null, line: null, frame: 0 }; - window.addEventListener("pointermove", track, true); - setDragging(column); - - const tick = () => { - const s = state.current; - if (s == null) return; - // Near an edge, the list scrolls that way, faster the nearer; the header follows it. - const list = latest.current.scrollElement(); - if (list != null) { - const { left, right } = list.getBoundingClientRect(); - const near = - s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0; - if (near !== 0) { - const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED; - list.scrollLeft += Math.sign(near) * Math.max(1, speed); - } + const tick = () => { + const s = state.current; + if (s == null) return; + // Near an edge, the list scrolls that way, faster the nearer; the header follows it. + const list = latest.current.scrollElement(); + if (list != null) { + const { left, right } = list.getBoundingClientRect(); + const near = + s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0; + if (near !== 0) { + const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED; + list.scrollLeft += Math.sign(near) * Math.max(1, speed); } - target(s); - s.frame = requestAnimationFrame(tick); - }; - state.current.frame = requestAnimationFrame(tick); - // oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs - }, - [track], - ); + } + target(s); + s.frame = requestAnimationFrame(tick); + }; + state.current.frame = requestAnimationFrame(tick); + // oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs + }, []); - const onDragEnd = useCallback(() => { + /** Drops the column where the pointer is, once; a second call finds no drag */ + const drop = useCallback(() => { // Where the pointer is now, as a release can come before the next frame reads it if (state.current != null) target(state.current); const s = stop(); @@ -480,6 +464,51 @@ function useColumnDrag({ // oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs }, [stop]); + // The pointer from a press on the header until its release, read before dnd-kit sees it. + // dnd-kit starts a drag on the move that crosses `DRAG_DISTANCE` and reports moves and the + // release only once it has rendered the drag, so a release right after a move would drop + // where the pointer was a frame before, or not at all. The release drops it here instead. + const pointer = useRef(0); + const track = useCallback((e: PointerEvent) => { + pointer.current = e.clientX; + if (state.current != null) state.current.x = e.clientX; + }, []); + const unlisten = useRef<(() => void) | null>(null); + const onPointerDownCapture = useCallback( + (e: ReactPointerEvent) => { + unlisten.current?.(); + pointer.current = e.clientX; + const up = (e: PointerEvent) => { + unlisten.current?.(); + track(e); + drop(); + }; + const cancel = () => { + unlisten.current?.(); + stop(); + }; + window.addEventListener("pointermove", track, true); + window.addEventListener("pointerup", up, true); + window.addEventListener("pointercancel", cancel, true); + unlisten.current = () => { + unlisten.current = null; + window.removeEventListener("pointermove", track, true); + window.removeEventListener("pointerup", up, true); + window.removeEventListener("pointercancel", cancel, true); + }; + }, + [track, drop, stop], + ); + + // A drag never outlives the header + useEffect( + () => () => { + unlisten.current?.(); + stop(); + }, + [stop], + ); + /** Sets where the drag would drop, from the pointer and the labels where they are now */ function target(s: NonNullable) { // The boundary before the first label whose middle is right of the pointer @@ -503,12 +532,7 @@ function useColumnDrag({ dragging, sensors, gridRef, - dndHandlers: { onDragStart, onDragEnd, onDragCancel: stop }, + onPointerDownCapture, + dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: stop }, }; } - -/** Where the press that began a drag was, across the window */ -function pointerX(e: DragStartEvent): number { - const event = e.activatorEvent; - return event instanceof globalThis.MouseEvent ? event.clientX : 0; -} From 9ee37e4015002d7842c2ad714e32099c98be5156 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 09:03:19 -0700 Subject: [PATCH 04/13] Show a dragged column's drop line at every boundary The line was hidden while a drop would leave the column where it is, so a drag showed nothing until it crossed a neighbour's middle. It now shows from the first movement, on the nearer edge of the column's own slot when the drop would change nothing, and at the first boundary over the mark column. The choice of boundary moves to a tested function in the columns module. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/components/FlowListHeader.tsx | 17 +++++++------- apps/yaak-proxy/lib/columns.test.ts | 22 +++++++++++++++++++ apps/yaak-proxy/lib/columns.ts | 19 ++++++++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx index 1e002ab48..a232080f8 100644 --- a/apps/yaak-proxy/components/FlowListHeader.tsx +++ b/apps/yaak-proxy/components/FlowListHeader.tsx @@ -38,6 +38,7 @@ import { type ColumnPrefs, type CustomColumnId, canHide, + dropBoundary, hideOtherColumns, isCustomColumn, moveColumn, @@ -409,7 +410,7 @@ function useColumnDrag({ id: ColumnId; /** The pointer, across the window */ x: number; - /** The boundary it would drop at, and that boundary's line, or null where it would not move */ + /** The boundary it would move to, null where it would stay, and the line drawn on it */ to: number | null; line: number | null; frame: number; @@ -433,6 +434,8 @@ function useColumnDrag({ if (column == null) return; state.current = { id: column.id, x: pointer.current, to: null, line: null, frame: 0 }; setDragging(column); + // The line shows from the drag's first movement, not a frame later + target(state.current); const tick = () => { const s = state.current; @@ -511,18 +514,14 @@ function useColumnDrag({ /** Sets where the drag would drop, from the pointer and the labels where they are now */ function target(s: NonNullable) { - // The boundary before the first label whose middle is right of the pointer const cells = Array.from( gridRef.current?.querySelectorAll(":scope > [data-column]") ?? [], ).map((el) => el.getBoundingClientRect()); - let to = cells.findIndex((r) => s.x < r.left + r.width / 2); - if (to === -1) to = cells.length; - // Where it already is, it would not move, so no line. Its place is read each time, as the - // columns can change under a drag: Operation shows itself when a flow has one. + // Its place is read each time, as the columns can change under a drag: Operation shows + // itself when a flow has one. const from = latest.current.columns.findIndex((c) => c.id === s.id); - const moves = from !== -1 && to !== from && to !== from + 1; - s.to = moves ? to : null; - const line = moves ? (cells[to]?.left ?? cells.at(-1)?.right ?? null) : null; + const { to, line } = dropBoundary(s.x, cells, from); + s.to = to; // Told again only when it changes: another boundary, or the same one scrolled if (line !== s.line) latest.current.onDropLine(line); s.line = line; diff --git a/apps/yaak-proxy/lib/columns.test.ts b/apps/yaak-proxy/lib/columns.test.ts index 446b3c71c..cafcb4b3c 100644 --- a/apps/yaak-proxy/lib/columns.test.ts +++ b/apps/yaak-proxy/lib/columns.test.ts @@ -11,6 +11,7 @@ import { customPathProblem, customValue, defaultColumnPrefs, + dropBoundary, fieldReference, fieldReferenceChanges, gridTemplate, @@ -322,6 +323,27 @@ describe("column order", () => { expect(parseColumnPrefs({ order: "time" }).order).toEqual([]); }); + test("the drop line shows at every boundary, its own slot's edges included", () => { + // Four columns 100px wide from x 24, after the mark column; the third is dragged + const cells = [24, 124, 224, 324].map((left) => ({ left, right: left + 100 })); + const at = (x: number) => dropBoundary(x, cells, 2); + // Over the mark column or the first's left half: the first boundary + expect(at(5)).toEqual({ to: 0, line: 24 }); + expect(at(60)).toEqual({ to: 0, line: 24 }); + expect(at(80)).toEqual({ to: 1, line: 124 }); + // The left neighbour's near half and its own left half: its left edge, staying + expect(at(190)).toEqual({ to: null, line: 224 }); + expect(at(250)).toEqual({ to: null, line: 224 }); + // Its own right half and the right neighbour's near half: its right edge, staying + expect(at(290)).toEqual({ to: null, line: 324 }); + expect(at(350)).toEqual({ to: null, line: 324 }); + // Past the right neighbour's middle, and past the end + expect(at(380)).toEqual({ to: 4, line: 424 }); + expect(at(900)).toEqual({ to: 4, line: 424 }); + // The last column dragged to the end stays + expect(dropBoundary(900, cells, 3)).toEqual({ to: null, line: 424 }); + }); + test("Reset Column Order puts every column back", () => { let prefs = moveColumn(defaultColumnPrefs, "time", 0); prefs = toggleColumn(prefs, "latency"); diff --git a/apps/yaak-proxy/lib/columns.ts b/apps/yaak-proxy/lib/columns.ts index e55e2b4a2..693cd9ee4 100644 --- a/apps/yaak-proxy/lib/columns.ts +++ b/apps/yaak-proxy/lib/columns.ts @@ -298,6 +298,25 @@ export function resetOrder(prefs: ColumnPrefs): ColumnPrefs { return { ...prefs, order: [] }; } +/** + * Where a dragged column would drop, from the pointer and the shown columns' edges, left to + * right: the boundary before the first column whose middle is right of the pointer, and the + * line's place on it. Over its own column or the near half of a neighbour, that is an edge of + * its own slot, where it would stay, so `to` is null; the line still shows there. Left of the + * first column, over the fixed mark column, it is the first boundary. + */ +export function dropBoundary( + x: number, + cells: { left: number; right: number }[], + from: number, +): { to: number | null; line: number | null } { + let at = cells.findIndex((r) => x < (r.left + r.right) / 2); + if (at === -1) at = cells.length; + const line = cells[at]?.left ?? cells.at(-1)?.right ?? null; + const stays = from === -1 || at === from || at === from + 1; + return { to: stays ? null : at, line }; +} + /** Shows `id` alone */ export function hideOtherColumns(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs { const visible: ColumnPrefs["visible"] = {}; From 185a4e30dfbc69da79904a044b199ea09c5c42dc Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 09:22:17 -0700 Subject: [PATCH 05/13] Drag a whole column's ghost, and keep default places until reordered The ghost of a dragged column is now its header and the cells of the rows in view, copied once when the drag starts and moved sideways by a transform, kept within the list; the column dims in place, its cells by one style rule so no row renders. A built-in column turned on before any reordering takes its default place again; once the person has an order, a newly shown column goes at the end. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/components/FlowList.tsx | 151 +++++++++++++++++- apps/yaak-proxy/components/FlowListHeader.tsx | 65 ++++---- apps/yaak-proxy/lib/columns.test.ts | 27 +++- apps/yaak-proxy/lib/columns.ts | 5 +- 4 files changed, 198 insertions(+), 50 deletions(-) diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index 75b4ae566..fc196d318 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -14,7 +14,14 @@ import { } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; -import type { CSSProperties, KeyboardEvent, MouseEvent, ReactElement, WheelEvent } from "react"; +import type { + CSSProperties, + KeyboardEvent, + MouseEvent, + ReactElement, + RefObject, + WheelEvent, +} from "react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms"; import { shortContentType } from "../lib/body"; @@ -61,7 +68,7 @@ import { import { useShowToast } from "../lib/toast"; import { AppIcon } from "./AppIcon"; import { FlowListEmpty, PausedRow } from "./FlowListEmpty"; -import { FlowListHeader } from "./FlowListHeader"; +import { type ColumnDragEvent, FlowListHeader } from "./FlowListHeader"; const BOTTOM_PADDING = 6; /** Rows from the top at which older flows start loading, or from the bottom, sorted, more */ @@ -305,6 +312,7 @@ export function FlowList({ style, className }: Props) { const rootRef = useRef(null); const [dropLine, setDropLine] = useState(null); const getScrollElement = useCallback(() => scrollRef.current, []); + const { ghostColumn, onColumnDrag } = useColumnGhost(rootRef, headerScrollRef, scrollRef); const handleDropLine = useCallback((x: number | null) => { const root = rootRef.current; setDropLine(x == null || root == null ? null : x - root.getBoundingClientRect().left); @@ -442,14 +450,19 @@ export function FlowList({ style, className }: Props) { onResetSort={follow} scrollElement={getScrollElement} onDropLine={handleDropLine} + onColumnDrag={onColumnDrag} />
- {/* Where a dragged column would drop, down the header and the rows in view */} + {/* A dragged column's cells dim where they are, in one rule rather than a render a row */} + {ghostColumn != null && ( + + )} + {/* Where a dragged column would drop, down the header and the rows in view, over its ghost */} {dropLine != null && (
{ }, [flows, following]); } +/** + * The ghost of a dragged column: its header and the cells of the rows in view, copied once when + * the drag starts, at the column's width and the list's height. It follows the pointer sideways, + * kept within the list, by a transform, so the rows don't render while it moves; flows arriving + * or the list scrolling sideways meanwhile leave the copy as it was. + */ +function useColumnGhost( + rootRef: RefObject, + headerRef: RefObject, + listRef: RefObject, +): { ghostColumn: ColumnId | null; onColumnDrag: (event: ColumnDragEvent) => void } { + const [ghostColumn, setGhostColumn] = useState(null); + const ghost = useRef(null); + + const onColumnDrag = useCallback( + (event: ColumnDragEvent) => { + if (event.type === "move") { + ghost.current?.move(event.x); + return; + } + ghost.current?.remove(); + ghost.current = null; + if (event.type === "end") { + setGhostColumn(null); + return; + } + const root = rootRef.current; + const header = headerRef.current; + const list = listRef.current; + if (root == null || header == null || list == null) return; + ghost.current = snapshotColumn(event.id, event.x, root, header, list); + setGhostColumn(event.id); + }, + [rootRef, headerRef, listRef], + ); + + useEffect(() => () => ghost.current?.remove(), []); + return { ghostColumn, onColumnDrag }; +} + +interface ColumnGhost { + /** Follows the pointer, across the window */ + move: (x: number) => void; + remove: () => void; +} + +function snapshotColumn( + id: ColumnId, + startX: number, + root: HTMLElement, + header: HTMLElement, + list: HTMLElement, +): ColumnGhost | null { + const cell = header.querySelector(`[data-column="${id}"]`); + if (cell == null) return null; + const rootBox = root.getBoundingClientRect(); + const cellBox = cell.getBoundingClientRect(); + const listBox = list.getBoundingClientRect(); + const listHeight = list.clientHeight; + + const el = document.createElement("div"); + el.setAttribute("aria-hidden", "true"); + el.className = classNames( + "pointer-events-none absolute top-0 z-20 overflow-hidden rounded-sm", + // Lifted: a shadow, and just enough opacity to read as held, not enough to show text through + "bg-surface border border-border-subtle shadow-lg opacity-95", + ); + Object.assign(el.style, { + left: `${cellBox.left - rootBox.left}px`, + width: `${cellBox.width}px`, + height: `${rootBox.height}px`, + willChange: "transform", + }); + + // The header's label, without its resize handle + const head = place(cellBox.top - rootBox.top, cellBox.height, "text-xs text-text-subtlest"); + const label = cell.cloneNode(true) as HTMLElement; + for (const handle of label.querySelectorAll(".cursor-col-resize")) handle.remove(); + label.style.width = "100%"; + head.append(label); + el.append(head); + + // The rows in view, clipped to the list as it shows them + const rows = document.createElement("div"); + rows.className = "absolute left-0 right-0 overflow-hidden"; + Object.assign(rows.style, { top: `${listBox.top - rootBox.top}px`, height: `${listHeight}px` }); + for (const row of list.querySelectorAll('[role="option"]')) { + const rowBox = row.getBoundingClientRect(); + if (rowBox.bottom <= listBox.top || rowBox.top >= listBox.top + listHeight) continue; + const source = row.querySelector(`[data-column="${id}"]`); + if (source == null) continue; + const copy = place( + rowBox.top - listBox.top, + rowBox.height, + classNames( + "whitespace-nowrap tabular-nums", + row.getAttribute("aria-selected") === "true" && "text-text bg-surface-highlight", + row.classList.contains("text-text-subtlest") && "text-text-subtlest", + ), + ); + for (const child of source.children) { + const clone = child.cloneNode(true) as HTMLElement; + clone.style.width = "100%"; + copy.append(clone); + } + rows.append(copy); + } + el.append(rows); + root.append(el); + + // Kept within the list's visible width + const min = listBox.left; + const max = Math.max(min, listBox.left + list.clientWidth - cellBox.width); + const move = (x: number) => { + const left = Math.min(max, Math.max(min, cellBox.left + x - startX)); + el.style.transform = `translateX(${left - cellBox.left}px)`; + }; + move(startX); + return { move, remove: () => el.remove() }; +} + +/** A line of the ghost, `top` pixels down its box */ +function place(top: number, height: number, className: string): HTMLElement { + const el = document.createElement("div"); + el.className = classNames("absolute left-0 right-0 flex items-center", className); + Object.assign(el.style, { top: `${top}px`, height: `${height}px` }); + return el; +} + interface RowProps { flow: Flow; index: number; diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx index a232080f8..952ca6cc6 100644 --- a/apps/yaak-proxy/components/FlowListHeader.tsx +++ b/apps/yaak-proxy/components/FlowListHeader.tsx @@ -1,6 +1,5 @@ import { DndContext, - DragOverlay, type DragStartEvent, PointerSensor, useDraggable, @@ -28,7 +27,6 @@ import { useRef, useState, } from "react"; -import { createPortal } from "react-dom"; import { addSortKey, allColumns, @@ -77,8 +75,16 @@ interface Props { scrollElement: () => HTMLElement | null; /** Where a dragged column would drop, in the window's coordinates, or null for nowhere */ onDropLine: (x: number | null) => void; + /** A column's drag: its start, the pointer's moves, and its end, for the list's ghost */ + onColumnDrag: (event: ColumnDragEvent) => void; } +/** A column's drag, for the ghost the list draws of it; `x` is the pointer, across the window */ +export type ColumnDragEvent = + | { type: "start"; id: ColumnId; x: number } + | { type: "move"; x: number } + | { type: "end" }; + /** * The list's only control surface: a click on a label sorts by it alone, a second reverses it * and a third goes back to arrival order; Option-click adds it to the sort as a further key. @@ -97,6 +103,7 @@ export const FlowListHeader = memo(function FlowListHeader({ onResetSort, scrollElement, onDropLine, + onColumnDrag, }: Props) { const setPrefs = useSetAtom(columnPrefsAtom); const unavailable = unavailableColumns(useAtomValue(hasAppsAtom)); @@ -108,6 +115,7 @@ export const FlowListHeader = memo(function FlowListHeader({ columns, scrollElement, onDropLine, + onColumnDrag, onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)), }); @@ -259,24 +267,6 @@ export const FlowListHeader = memo(function FlowListHeader({ onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))} /> ))} - {/* Over everything, as the header clips what overflows it */} - {createPortal( - - {dragging != null && ( -
- -
- )} -
, - document.body, - )} k.column === c.id); const key = sort[at]; return ( @@ -377,7 +356,7 @@ function ColumnLabel({ {key != null && sort.length > 1 && ( {at + 1} )} - {c.id === "app" && !ghost ? "" : c.label} + {c.id === "app" ? "" : c.label} ); } @@ -391,11 +370,13 @@ function useColumnDrag({ columns, scrollElement, onDropLine, + onColumnDrag, onDrop, }: { columns: Column[]; scrollElement: () => HTMLElement | null; onDropLine: (x: number | null) => void; + onColumnDrag: (event: ColumnDragEvent) => void; /** Moves the column to the boundary `to` between those shown */ onDrop: (id: ColumnId, to: number) => void; }) { @@ -413,11 +394,13 @@ function useColumnDrag({ /** The boundary it would move to, null where it would stay, and the line drawn on it */ to: number | null; line: number | null; + /** The pointer the ghost was last moved to */ + ghostX: number; frame: number; } | null>(null); - const latest = useRef({ columns, scrollElement, onDropLine, onDrop }); + const latest = useRef({ columns, scrollElement, onDropLine, onColumnDrag, onDrop }); useLayoutEffect(() => { - latest.current = { columns, scrollElement, onDropLine, onDrop }; + latest.current = { columns, scrollElement, onDropLine, onColumnDrag, onDrop }; }); const stop = useCallback(() => { @@ -426,13 +409,17 @@ function useColumnDrag({ if (s != null) cancelAnimationFrame(s.frame); setDragging(null); latest.current.onDropLine(null); + if (s != null) latest.current.onColumnDrag({ type: "end" }); return s; }, []); const onDragStart = useCallback((e: DragStartEvent) => { const column = latest.current.columns.find((c) => c.id === e.active.id); if (column == null) return; - state.current = { id: column.id, x: pointer.current, to: null, line: null, frame: 0 }; + const x = pointer.current; + state.current = { id: column.id, x, to: null, line: null, ghostX: x, frame: 0 }; + // Before the column dims, so the ghost is of it as it shows + latest.current.onColumnDrag({ type: "start", id: column.id, x }); setDragging(column); // The line shows from the drag's first movement, not a frame later target(state.current); @@ -452,6 +439,10 @@ function useColumnDrag({ } } target(s); + if (s.x !== s.ghostX) { + s.ghostX = s.x; + latest.current.onColumnDrag({ type: "move", x: s.x }); + } s.frame = requestAnimationFrame(tick); }; state.current.frame = requestAnimationFrame(tick); diff --git a/apps/yaak-proxy/lib/columns.test.ts b/apps/yaak-proxy/lib/columns.test.ts index cafcb4b3c..6e01d5a2d 100644 --- a/apps/yaak-proxy/lib/columns.test.ts +++ b/apps/yaak-proxy/lib/columns.test.ts @@ -269,17 +269,30 @@ describe("column order", () => { ]); }); - test("a column turned on goes at the end, unless it had a place", () => { + test("never reordered, a built-in column turned on takes its default place", () => { // Request Size's default place is before Size - let prefs = toggleColumn(defaultColumnPrefs, "requestSize"); + const prefs = toggleColumn(defaultColumnPrefs, "requestSize"); + expect(prefs.order).toEqual([]); + expect(ids(prefs).slice(-4)).toEqual(["type", "requestSize", "size", "time"]); + // Among the built-in ones, so a custom column stays at the end + const custom = addCustomColumn(defaultColumnPrefs, "header.x-cache"); + expect(ids(custom).at(-1)).toBe("field:header.x-cache"); + const both = toggleColumn(custom, "requestSize"); + expect(both.order).toEqual([]); + expect(ids(both).slice(-4)).toEqual(["requestSize", "size", "time", "field:header.x-cache"]); + }); + + test("once reordered, a column turned on goes at the end, unless it had a place", () => { + let prefs = moveColumn(defaultColumnPrefs, "time", 0); + prefs = toggleColumn(prefs, "requestSize"); expect(ids(prefs).at(-1)).toBe("requestSize"); - expect(ids(prefs).slice(0, -1)).toEqual(defaults); // Moved, hidden and shown again, it goes back where it was moved to - prefs = moveColumn(prefs, "requestSize", defaults.indexOf("size")); + const before = ids(prefs).indexOf("size"); + prefs = moveColumn(prefs, "requestSize", before); prefs = toggleColumn(toggleColumn(prefs, "requestSize"), "requestSize"); - expect(ids(prefs).slice(-3)).toEqual(["requestSize", "size", "time"]); - // Already last of those shown, nothing needs placing - expect(toggleColumn(defaultColumnPrefs, "remote").order).toEqual([]); + expect(ids(prefs).slice(-3)).toEqual(["type", "requestSize", "size"]); + // A custom column added goes at the end too + expect(ids(addCustomColumn(prefs, "query.page")).at(-1)).toBe("field:query.page"); }); test("a custom column is added at the end and leaves the order when removed", () => { diff --git a/apps/yaak-proxy/lib/columns.ts b/apps/yaak-proxy/lib/columns.ts index 693cd9ee4..7c2cc339f 100644 --- a/apps/yaak-proxy/lib/columns.ts +++ b/apps/yaak-proxy/lib/columns.ts @@ -243,10 +243,11 @@ export function toggleColumn( /** * Puts a column just shown after the others shown, unless the person placed it before, so it * goes back to that place. The columns shown before it are placed too, where they are, so the - * ones not listed don't jump ahead of it. + * ones not listed don't jump ahead of it. Until the person has ordered the columns, each keeps + * its default place: a built-in one among the others, an added one at the end. */ function placeAtEnd(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs { - if (prefs.order.includes(id)) return prefs; + if (prefs.order.length === 0 || prefs.order.includes(id)) return prefs; const all = allColumns(prefs); if (all.filter((c) => isVisible(prefs, c)).at(-1)?.id === id) return prefs; const placed = all.filter( From 80b587f507b19608bc7ec3be59b590b964c762aa Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 10:09:39 -0700 Subject: [PATCH 06/13] Slide columns aside for a dragged one instead of drawing a drop line While a column is dragged it hides where it is and the columns between it and where it would land slide by its width, header and cells together, so the gap shows the drop. One style element carries a rule a column and changes only when the target does; the rows don't render. On drop the rules go as the order changes, in the same task, so nothing animates; on Escape the columns slide home and the column shows again. The drop line and its code are gone. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/components/FlowList.tsx | 165 ++++++++++++------ apps/yaak-proxy/components/FlowListHeader.tsx | 120 ++++++------- apps/yaak-proxy/lib/columns.test.ts | 39 +++-- apps/yaak-proxy/lib/columns.ts | 32 +++- 4 files changed, 223 insertions(+), 133 deletions(-) diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index fc196d318..df6a8af35 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -30,6 +30,7 @@ import { type Column, type ColumnId, cellFilterTerm, + columnShifts, customValue, gridTemplate, gridWidth, @@ -308,15 +309,10 @@ export function FlowList({ style, className }: Props) { el.scrollLeft += e.deltaX; }, []); - // A dragged column's drop line, from the window's coordinates to the list's + // A dragged column: its ghost, and the others sliding aside to make room for it const rootRef = useRef(null); - const [dropLine, setDropLine] = useState(null); const getScrollElement = useCallback(() => scrollRef.current, []); - const { ghostColumn, onColumnDrag } = useColumnGhost(rootRef, headerScrollRef, scrollRef); - const handleDropLine = useCallback((x: number | null) => { - const root = rootRef.current; - setDropLine(x == null || root == null ? null : x - root.getBoundingClientRect().left); - }, []); + const onColumnDrag = useColumnDragView(rootRef, headerScrollRef, scrollRef); // A list too short to scroll still loads older flows on scrolling up. const handleWheel = useCallback( @@ -449,27 +445,9 @@ export function FlowList({ style, className }: Props) { onSetSort={handleSetSort} onResetSort={follow} scrollElement={getScrollElement} - onDropLine={handleDropLine} onColumnDrag={onColumnDrag} />
- {/* A dragged column's cells dim where they are, in one rule rather than a render a row */} - {ghostColumn != null && ( - - )} - {/* Where a dragged column would drop, down the header and the rows in view, over its ghost */} - {dropLine != null && ( -
-
-
- )}
{ }, [flows, following]); } +/** How long the columns take to slide aside for a dragged column, or back */ +const SLIDE_MS = 150; + /** - * The ghost of a dragged column: its header and the cells of the rows in view, copied once when - * the drag starts, at the column's width and the list's height. It follows the pointer sideways, - * kept within the list, by a transform, so the rows don't render while it moves; flows arriving - * or the list scrolling sideways meanwhile leave the copy as it was. + * Shows a column's drag without rendering a row. Its ghost is its header and the cells of the + * rows in view, copied once when the drag starts and following the pointer sideways. The column + * hides where it is, and the columns between it and where it would drop slide aside by its width + * to leave a gap there, all by one style element: a rule a column, header and cells together, + * which rows arriving meanwhile follow too. Dropped, the rules go as the order changes, in the + * same task, so nothing animates or jumps; put back, the columns slide home first. */ -function useColumnGhost( +function useColumnDragView( rootRef: RefObject, headerRef: RefObject, listRef: RefObject, -): { ghostColumn: ColumnId | null; onColumnDrag: (event: ColumnDragEvent) => void } { - const [ghostColumn, setGhostColumn] = useState(null); - const ghost = useRef(null); +): (event: ColumnDragEvent) => void { + const drag = useRef(null); + // Columns sliding home after a drag was put back, until they are there + const settling = useRef<{ style: HTMLStyleElement; timer: number } | null>(null); + + const settle = useCallback(() => { + const s = settling.current; + settling.current = null; + if (s == null) return; + clearTimeout(s.timer); + s.style.remove(); + }, []); const onColumnDrag = useCallback( (event: ColumnDragEvent) => { - if (event.type === "move") { - ghost.current?.move(event.x); - return; + const d = drag.current; + switch (event.type) { + case "move": + d?.ghost?.move(event.x); + return; + case "target": + if (d == null) return; + d.to = event.to; + d.style.textContent = columnDragCss(d); + return; + case "end": { + drag.current = null; + if (d == null) return; + d.ghost?.remove(); + if (event.dropped || d.to == null) { + d.style.remove(); + return; + } + d.to = null; + d.style.textContent = columnDragCss(d); + settling.current = { style: d.style, timer: window.setTimeout(settle, SLIDE_MS) }; + return; + } + case "start": { + settle(); + d?.ghost?.remove(); + d?.style.remove(); + drag.current = null; + const root = rootRef.current; + const header = headerRef.current; + const list = listRef.current; + if (root == null || header == null || list == null) return; + const cells = Array.from( + header.querySelectorAll("[data-flow-header] > [data-column]"), + ); + const ids = cells.map((c) => c.dataset.column as ColumnId); + const from = ids.indexOf(event.id); + if (from === -1) return; + // Copied before it hides + const ghost = snapshotColumn(event.id, event.x, root, header, list); + const style = document.createElement("style"); + document.head.append(style); + const width = cells[from]?.getBoundingClientRect().width ?? 0; + drag.current = { ghost, ids, from, width, to: null, style }; + style.textContent = columnDragCss(drag.current); + } } - ghost.current?.remove(); - ghost.current = null; - if (event.type === "end") { - setGhostColumn(null); - return; - } - const root = rootRef.current; - const header = headerRef.current; - const list = listRef.current; - if (root == null || header == null || list == null) return; - ghost.current = snapshotColumn(event.id, event.x, root, header, list); - setGhostColumn(event.id); }, - [rootRef, headerRef, listRef], + [rootRef, headerRef, listRef, settle], ); - useEffect(() => () => ghost.current?.remove(), []); - return { ghostColumn, onColumnDrag }; + useEffect( + () => () => { + settle(); + drag.current?.ghost?.remove(); + drag.current?.style.remove(); + drag.current = null; + }, + [settle], + ); + return onColumnDrag; +} + +interface ColumnDragView { + ghost: ColumnGhost | null; + /** The columns shown, the dragged one's place among them and its width, and its target */ + ids: ColumnId[]; + from: number; + width: number; + to: number | null; + style: HTMLStyleElement; +} + +/** The rules for a drag: the column hidden, and those it passes slid aside */ +function columnDragCss({ ids, from, width, to }: ColumnDragView): string { + // A row's cell is the box in its grid: the column's child, or inside the `contents` span a + // tooltip wraps it in, as a box that is only its contents can't move + const cells = (is: string) => + `[data-flow-rows] ${is} > :not(.contents), [data-flow-rows] ${is} > .contents > *`; + const column = (id: ColumnId) => { + const is = `[data-column="${CSS.escape(id)}"]`; + return `[data-flow-header] > ${is}, ${cells(is)}`; + }; + const rules = [ + `[data-flow-header] > [data-column], ${cells("[data-column]")} { transition: transform ${SLIDE_MS}ms ease; }`, + ]; + const dragged = ids[from]; + if (dragged != null) rules.push(`${column(dragged)} { visibility: hidden; }`); + columnShifts(ids.length, from, width, to).forEach((dx, i) => { + const id = ids[i]; + if (dx !== 0 && id != null) rules.push(`${column(id)} { transform: translateX(${dx}px); }`); + }); + return rules.join("\n"); } interface ColumnGhost { diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx index 952ca6cc6..837eec069 100644 --- a/apps/yaak-proxy/components/FlowListHeader.tsx +++ b/apps/yaak-proxy/components/FlowListHeader.tsx @@ -73,17 +73,20 @@ interface Props { onResetSort: () => void; /** The rows' scrolling element, which a column dragged to its edge scrolls sideways */ scrollElement: () => HTMLElement | null; - /** Where a dragged column would drop, in the window's coordinates, or null for nowhere */ - onDropLine: (x: number | null) => void; - /** A column's drag: its start, the pointer's moves, and its end, for the list's ghost */ + /** A column's drag, for the list to show: its ghost, and the columns making room for it */ onColumnDrag: (event: ColumnDragEvent) => void; } -/** A column's drag, for the ghost the list draws of it; `x` is the pointer, across the window */ +/** + * A column's drag: its start, the pointer's moves (`x`, across the window), the boundary it + * would drop at when that changes (`to`, null where it would stay), and its end, dropped there + * or put back + */ export type ColumnDragEvent = | { type: "start"; id: ColumnId; x: number } | { type: "move"; x: number } - | { type: "end" }; + | { type: "target"; to: number | null } + | { type: "end"; dropped: boolean }; /** * The list's only control surface: a click on a label sorts by it alone, a second reverses it @@ -102,7 +105,6 @@ export const FlowListHeader = memo(function FlowListHeader({ onSetSort: setSort, onResetSort, scrollElement, - onDropLine, onColumnDrag, }: Props) { const setPrefs = useSetAtom(columnPrefsAtom); @@ -111,10 +113,9 @@ export const FlowListHeader = memo(function FlowListHeader({ const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null); // Widths when a drag began, so each move sets the width from where it started. const startWidth = useRef(0); - const { dragging, sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({ + const { sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({ columns, scrollElement, - onDropLine, onColumnDrag, onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)), }); @@ -232,6 +233,7 @@ export const FlowListHeader = memo(function FlowListHeader({ {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
{ startWidth.current = c.current; @@ -281,8 +282,6 @@ export const FlowListHeader = memo(function FlowListHeader({ interface HeaderCellProps { column: Column; sort: FlowSortKey[]; - /** Being dragged, so it dims where it was */ - dragging: boolean; onSort: (column: ColumnId, add: boolean) => void; onResizeStart: () => void; /** How far the edge moved since the resize began */ @@ -293,7 +292,6 @@ interface HeaderCellProps { function HeaderCell({ column: c, sort, - dragging, onSort, onResizeStart, onResizeMove, @@ -303,13 +301,7 @@ function HeaderCell({ const { listeners, setNodeRef } = useDraggable({ id: c.id }); const sorted = sort.some((k) => k.column === c.id); return ( - +
} requestSize={2048} />, ); } @@ -49,9 +41,8 @@ describe("FlowRequestBody", () => { expect(html).toContain("LOADING"); }); - test("the editor once loaded, the proxy's viewer for a binary body", () => { + test("the viewer once loaded", () => { expect(render({ data: data(), isLoading: false, error: null })).toContain("EDITOR"); - expect(render({ data: data(), isLoading: false, error: null }, "HEXDUMP")).toContain("HEXDUMP"); }); test("a cut body says how much was kept", () => { diff --git a/apps/yaak-proxy/components/FlowRequestBody.tsx b/apps/yaak-proxy/components/FlowRequestBody.tsx index 162a0adcd..11d6cf69d 100644 --- a/apps/yaak-proxy/components/FlowRequestBody.tsx +++ b/apps/yaak-proxy/components/FlowRequestBody.tsx @@ -13,19 +13,17 @@ import type { FlowBodyData } from "../lib/flowViews"; interface Props { /** The request body as the engine's body RPC returned it */ body: HttpBodyResult; - /** The shared editor for the body's type */ - editor: ReactNode; - /** The proxy's own viewer, for a body that isn't text; null for one that is */ - binaryViewer: ReactNode | null; + /** The body in the viewer for its type, formatted or raw */ + viewer: ReactNode; /** Bytes of the request body on the wire */ requestSize: number; } /** - * The request half's Body tab: the body in its editor, or why it isn't there yet. A body that + * The request half's Body tab: the body in its viewer, or why it isn't there yet. A body that * failed to load says why, as the response's viewer does, rather than reading as no body. */ -export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Props) { +export function FlowRequestBody({ body, viewer, requestSize }: Props) { if (body.error != null) { return ( @@ -40,7 +38,6 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro ); } - if (binaryViewer != null) return binaryViewer; if (body.data?.truncated) { return (
@@ -51,9 +48,9 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro -
{editor}
+
{viewer}
); } - return editor; + return viewer; } diff --git a/apps/yaak-proxy/harness/fixtures.ts b/apps/yaak-proxy/harness/fixtures.ts index 7368f528c..0bca85476 100644 --- a/apps/yaak-proxy/harness/fixtures.ts +++ b/apps/yaak-proxy/harness/fixtures.ts @@ -75,6 +75,27 @@ const graphqlRequest = JSON.stringify({ operationName: "GetViewer", }); const formRequest = "grant_type=client_credentials&client_id=yaak-proxy&scope=read+write"; +const csvResponse = [ + "id,email,plan,created_at", + "1,ada@example.com,pro,2026-10-01", + "2,grace@example.com,team,2026-10-14", + "3,linus@example.com,free,2026-11-02", +].join("\n"); +const multipartBoundary = "yaakBoundary7MA4YWxk"; +const multipartRequest = [ + `--${multipartBoundary}`, + 'Content-Disposition: form-data; name="title"', + "", + "Quarterly report", + `--${multipartBoundary}`, + 'Content-Disposition: form-data; name="meta"; filename="meta.json"', + "Content-Type: application/json", + "", + '{"pages":12,"draft":false}', + `--${multipartBoundary}--`, + "", +].join("\r\n"); +const textResponse = "Uploaded 2 parts"; const htmlResponse = "

Signed in

You can close this window.

"; // A 24x24 PNG gradient @@ -198,6 +219,41 @@ export const fixtures: FlowFixture[] = [ requestBody: body(graphqlRequest), responseBody: body('{"data":{"viewer":{"login":"yaak"}}}'), }, + { + label: "CSV GET", + flow: { + ...base, + id: "fl_csv", + url: "https://api.example.com/users/export.csv", + contentType: "text/csv", + requestHeaders: [{ name: "Accept", value: "text/csv" }], + responseHeaders: [{ name: "Content-Type", value: "text/csv; charset=utf-8" }], + responseSize: csvResponse.length, + }, + requestBody: body(""), + responseBody: body(csvResponse), + }, + { + label: "Multipart POST", + flow: { + ...base, + id: "fl_multipart", + method: "POST", + url: "https://api.example.com/reports", + contentType: "text/plain", + requestHeaders: [ + { + name: "Content-Type", + value: `multipart/form-data; boundary=${multipartBoundary}`, + }, + ], + responseHeaders: [{ name: "Content-Type", value: "text/plain; charset=utf-8" }], + requestSize: multipartRequest.length, + responseSize: textResponse.length, + }, + requestBody: body(multipartRequest), + responseBody: body(textResponse), + }, { label: "gRPC call", flow: { diff --git a/apps/yaak-proxy/lib/atoms.ts b/apps/yaak-proxy/lib/atoms.ts index 291ddef95..e68132615 100644 --- a/apps/yaak-proxy/lib/atoms.ts +++ b/apps/yaak-proxy/lib/atoms.ts @@ -1,5 +1,5 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; -import type { SplitLayoutLayout } from "@yaakapp-internal/ui"; +import type { ResponseViewMode, SplitLayoutLayout } from "@yaakapp-internal/ui"; import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; @@ -22,3 +22,11 @@ export const helperDialogOpenAtom = atom(false); /** Stacked by default; the header's toggle switches to side by side */ export const flowLayoutAtom = atomWithStorage("flow_layout", "vertical"); + +/** Bodies show formatted or raw, one choice for both halves of every flow */ +export const bodyViewModeAtom = atomWithStorage( + "body_view_mode", + "pretty", + undefined, + { getOnInit: true }, +); diff --git a/packages/ui/src/components/ActionsBar.tsx b/packages/ui/src/components/ActionsBar.tsx new file mode 100644 index 000000000..891d18395 --- /dev/null +++ b/packages/ui/src/components/ActionsBar.tsx @@ -0,0 +1,36 @@ +import classNames from "classnames"; +import type { ReactNode } from "react"; + +/** For the positioned element an `ActionsBar` sits in: hovering it brings the bar's actions in */ +export const ACTIONS_BAR_CONTAINER = "relative group/actions"; + +/** Set on an action's outermost element to keep it in view, as a control that shows state does */ +export const PINNED_ACTION = { "data-pinned": true } as const; + +interface Props { + children: ReactNode; + className?: string; +} + +/** + * A row of actions over the bottom-right corner of the content in an `ACTIONS_BAR_CONTAINER`: + * small bordered buttons on the surface colour, so they read over text. Each fades in while the + * container is hovered or the action has focus, unless it carries `PINNED_ACTION`. The row lets + * clicks through to the content between its actions. + */ +export function ActionsBar({ children, className }: Props) { + return ( +
:not([data-pinned])]:opacity-0", + "group-hover/actions:*:opacity-100! *:focus-within:opacity-100! *:hover:opacity-100!", + )} + > + {children} +
+ ); +} diff --git a/packages/ui/src/components/FormatAction.test.tsx b/packages/ui/src/components/FormatAction.test.tsx new file mode 100644 index 000000000..c58ff4c11 --- /dev/null +++ b/packages/ui/src/components/FormatAction.test.tsx @@ -0,0 +1,62 @@ +import { EditorState } from "@codemirror/state"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, test, vi } from "vite-plus/test"; +import { FormatAction, type FormattableEditor, formatEditor } from "./FormatAction"; + +/** An editor as far as formatting reaches into one, recording its changes and blurs */ +function fakeEditor(text: string) { + const changes: string[] = []; + const blur = vi.fn(); + let state = EditorState.create({ doc: text }); + const editor: FormattableEditor & { changes: string[]; blur: typeof blur } = { + get state() { + return state; + }, + dispatch: ((spec: { changes: { from: number; to: number; insert: string } }) => { + state = state.update(spec).state; + changes.push(state.doc.toString()); + }) as FormattableEditor["dispatch"], + contentDOM: { blur } as unknown as HTMLElement, + changes, + blur, + }; + return editor; +} + +describe("formatEditor", () => { + test("replaces the document with its formatted version and blurs", async () => { + const editor = fakeEditor('{"a":1}'); + await formatEditor(editor, async (t) => JSON.stringify(JSON.parse(t), null, 2)); + expect(editor.state.doc.toString()).toBe('{\n "a": 1\n}'); + expect(editor.changes).toHaveLength(1); + expect(editor.blur).toHaveBeenCalledOnce(); + }); + + test("leaves a document that is already formatted alone", async () => { + const editor = fakeEditor("same"); + await formatEditor(editor, async (t) => t); + expect(editor.changes).toHaveLength(0); + }); + + test("drops a result for text that changed while it was formatting", async () => { + const editor = fakeEditor("before"); + await formatEditor(editor, async () => { + editor.dispatch({ changes: { from: 0, to: 6, insert: "typed" } }); + return "formatted"; + }); + expect(editor.state.doc.toString()).toBe("typed"); + }); +}); + +describe("FormatAction", () => { + test("is offered for an editable editor only", () => { + const editor = { current: null }; + const format = async (t: string) => t; + expect(renderToStaticMarkup()).toContain( + "Reformat contents", + ); + expect(renderToStaticMarkup()).toBe( + "", + ); + }); +}); diff --git a/packages/ui/src/components/FormatAction.tsx b/packages/ui/src/components/FormatAction.tsx new file mode 100644 index 000000000..b79f44498 --- /dev/null +++ b/packages/ui/src/components/FormatAction.tsx @@ -0,0 +1,49 @@ +import type { EditorView } from "@codemirror/view"; +import type { RefObject } from "react"; +import { IconButton } from "./IconButton"; + +/** What reformatting needs of an editor; the `EditorView` an editor's `setRef` hands over has it */ +export type FormattableEditor = Pick; + +/** + * Replaces the editor's document with `format`'s version of it, and blurs it, since the cursor + * resets anyway. The change goes through the editor's own `onChange`, as typing does. + */ +export async function formatEditor( + editor: FormattableEditor, + format: (text: string) => Promise, +): Promise { + const before = editor.state.doc.toString(); + const formatted = await format(before); + // Typed into while formatting: the result is out of date + if (editor.state.doc.toString() !== before) return; + if (formatted === before) return; + editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: formatted } }); + editor.contentDOM.blur(); +} + +interface Props { + /** The editor to reformat, as its `setRef` set it */ + editor: RefObject; + format: (text: string) => Promise; + /** A read-only editor can't be reformatted, so offers nothing */ + readOnly?: boolean; +} + +/** Reformats an editable editor's contents, for an `ActionsBar` */ +export function FormatAction({ editor, format, readOnly }: Props) { + if (readOnly) return null; + return ( + { + if (editor.current == null) return; + await formatEditor(editor.current, format); + }} + /> + ); +} diff --git a/apps/yaak-client/components/core/SegmentedControl.tsx b/packages/ui/src/components/SegmentedControl.tsx similarity index 73% rename from apps/yaak-client/components/core/SegmentedControl.tsx rename to packages/ui/src/components/SegmentedControl.tsx index b2ab147f5..74092c9f2 100644 --- a/apps/yaak-client/components/core/SegmentedControl.tsx +++ b/packages/ui/src/components/SegmentedControl.tsx @@ -1,19 +1,33 @@ -import type { IconProps } from "@yaakapp-internal/ui"; -import { HStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui"; +import { generateId } from "@yaakapp-internal/lib/generateId"; import classNames from "classnames"; import { type ReactNode, useRef } from "react"; -import { Button } from "./Button"; +import { useStateWithDeps } from "../hooks/useStateWithDeps"; +import { Button, CONTROL_TOOLTIP_DELAY } from "./Button"; +import type { IconProps } from "./Icon"; import { IconButton, type IconButtonProps } from "./IconButton"; -import { generateId } from "@yaakapp-internal/lib/generateId"; +import { IconTooltip } from "./IconTooltip"; +import { Label } from "./Label"; +import { HStack } from "./Stacks"; +import { Tooltip } from "./Tooltip"; -interface Props { - options: { value: T; label: string; icon?: IconProps["icon"] }[]; +export interface SegmentedControlOption { + value: T; + label: string; + icon?: IconProps["icon"]; + /** Shown on hover over a text option, leaving its label as its name */ + tooltip?: string; +} + +export interface SegmentedControlProps { + options: SegmentedControlOption[]; onChange: (value: T) => void; value: T; name: string; size?: IconButtonProps["size"]; label: string; className?: string; + /** Replaces the full width the control takes by default */ + containerClassName?: string; hideLabel?: boolean; labelClassName?: string; help?: ReactNode; @@ -29,7 +43,8 @@ export function SegmentedControl({ labelClassName, help, className, -}: Props) { + containerClassName = "w-full", +}: SegmentedControlProps) { const [selectedValue, setSelectedValue] = useStateWithDeps(value, [value]); const containerRef = useRef(null); const id = useRef(`input-${generateId()}`); @@ -38,8 +53,13 @@ export function SegmentedControl({ const inlineHelp = hideLabel && help ? : null; + const focusOption = (index: number) => { + const option = containerRef.current?.querySelectorAll('[role="radio"]')[index]; + option?.focus(); + }; + return ( -
+