diff --git a/bun.lock b/bun.lock index 969f4b3f06..8491d2cd27 100644 --- a/bun.lock +++ b/bun.lock @@ -1046,6 +1046,7 @@ "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.2.0", "@types/node": "catalog:tooling", + "happy-dom": "^21.0.4", "typescript": "catalog:tooling", "vite": "catalog:", "vitest": "catalog:", diff --git a/packages/views/package.json b/packages/views/package.json index 2e1bb38e53..8db6427d65 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -38,6 +38,7 @@ "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.2.0", "@types/node": "catalog:tooling", + "happy-dom": "^21.0.4", "typescript": "catalog:tooling", "vite": "catalog:", "vitest": "catalog:" diff --git a/packages/views/src/lib/findable.test.ts b/packages/views/src/lib/findable.test.ts new file mode 100644 index 0000000000..9b5e81506e --- /dev/null +++ b/packages/views/src/lib/findable.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +import { Virtualizer } from "@tanstack/virtual-core" +import { describe, expect, test } from "vitest" +import { bindFindable, type FindableMatch } from "./findable" +import { DOM_OPTIONS, type Instance } from "./virtualizer" + +const ROW_HEIGHT = 30 + +/** A scroller with both stand-in layers, as `findableLayers` renders them, over a measured virtualizer. */ +const setUp = (count: number, scrollMargin = 0) => { + const scroller = document.createElement("div") + scroller.innerHTML = ` +
+
+
+
` + document.body.append(scroller) + const virtualizer: Instance = new Virtualizer({ + count, + getScrollElement: () => scroller, + estimateSize: () => ROW_HEIGHT, + scrollMargin, + ...DOM_OPTIONS, + }) + virtualizer.getVirtualItems() + const matches: Array = [] + const unbind = bindFindable(scroller, virtualizer, (match) => matches.push(match)) + const standIn = (index: number) => + scroller.querySelector(`[data-findable-index="${index}"]`) ?? + document.createElement("div") + // The browser fires `beforematch` on the stand-in, then on its group; it bubbles. + const match = (index: number) => { + const target = standIn(index) + target.dispatchEvent(new Event("beforematch", { bubbles: true })) + target.parentElement?.dispatchEvent(new Event("beforematch", { bubbles: true })) + } + return { scroller, matches, unbind, standIn, match } +} + +describe("bindFindable", () => { + test("a match moves the stand-in to its row's offset and reports the row and its side, once", () => { + const list = setUp(50) + list.match(40) + expect(list.standIn(40).style.top).toBe(`${40 * ROW_HEIGHT}px`) + expect(list.standIn(40).style.height).toBe(`${ROW_HEIGHT}px`) + list.match(3) + expect(list.matches).toEqual([ + { index: 40, side: "after" }, + { index: 3, side: "before" }, + ]) + }) + + test("the offset is in the list's coordinates: the scroll margin is taken off", () => { + const list = setUp(50, 120) + list.match(40) + expect(list.standIn(40).style.top).toBe(`${40 * ROW_HEIGHT}px`) + }) + + test("a row the virtualizer has not counted yet is not reported or moved", () => { + const list = setUp(50) + list.match(99) + expect(list.matches).toEqual([]) + expect(list.standIn(99).style.top).toBe("") + }) + + test("unbinding stops reporting matches", () => { + const list = setUp(50) + list.unbind() + list.match(40) + expect(list.matches).toEqual([]) + }) +}) diff --git a/packages/views/src/lib/findable.ts b/packages/views/src/lib/findable.ts new file mode 100644 index 0000000000..252333b810 --- /dev/null +++ b/packages/views/src/lib/findable.ts @@ -0,0 +1,196 @@ +import { Array as Arr, Option, Order, Schema } from "effect" +import { createKeyedLazy, type Html, type HtmlBuilder } from "foldkit/html" +import type { Instance } from "./virtualizer" + +// Find in page (Cmd+F) for a virtualized list, after ui-sdk.dev's `findable`: rows outside the +// window get `hidden="until-found"` stand-ins before and after it, in list order. On `beforematch` +// the stand-in moves to its row's offset, so the browser's own scroll brings the row on screen. + +export const FindableSide = Schema.Literals(["before", "after"]) +export type FindableSide = typeof FindableSide.Type + +/** The row the browser matched, and the layer its stand-in is in. */ +export const FindableMatch = Schema.Struct({ index: Schema.Number, side: FindableSide }) +export type FindableMatch = typeof FindableMatch.Type + +/** The found row; `isRendered` once a window held it, so the next window without it ends the find. */ +export const Found = Schema.Struct({ ...FindableMatch.fields, isRendered: Schema.Boolean }) +export type Found = typeof Found.Type + +export const foundOf = (match: FindableMatch): Found => ({ ...match, isRendered: false }) + +/** The found row after a new window: kept until its row has rendered and then left the window. */ +export const foundAfterWindow = ( + found: Option.Option, + rendered: ReadonlyArray, +): Option.Option => + Option.flatMap(found, (row) => { + const isRendered = rendered.includes(row.index) + if (isRendered === row.isRendered) return found + return isRendered ? Option.some({ ...row, isRendered }) : Option.none() + }) + +const INDEX_ATTRIBUTE = "findable-index" +const SIDE_ATTRIBUTE = "findable-side" +/** Stand-ins are grouped so the browser skips style and layout for a group until it matches. */ +const CHUNK = 64 + +// Zero-size and invisible: the stand-in only has to be found, its row shows the text. +const LAYER_STYLE = { + position: "absolute", + top: "0px", + left: "0px", + width: "100%", + height: "0px", + overflow: "clip", + opacity: "0", + "pointer-events": "none", + contain: "strict", +} as const +const STAND_IN_STYLE = { position: "absolute", left: "0px", width: "100%" } as const + +export interface FindableLayers { + readonly items: ReadonlyArray + /** The row's searchable text: what it shows, not timestamps. Module-level, so groups memoize. */ + readonly textOf: (item: A, variant: string) => ReadonlyArray + /** Anything `textOf` reads besides the item (wrap mode, columns); a change rebuilds the groups. */ + readonly variant: string + /** The rendered rows' indices: their own text is what the browser finds. */ + readonly rendered: ReadonlyArray + readonly found: Option.Option +} + +// Keyed by side and chunk: bounded by the row count / CHUNK. +const lazyGroup = createKeyedLazy() + +const standIn = ( + h: HtmlBuilder, + index: number, + isFound: boolean, + item: A, + textOf: (item: A, variant: string) => ReadonlyArray, + variant: string, +): Html => + h.keyed("div")( + String(index), + [ + h.DataAttribute(INDEX_ATTRIBUTE, String(index)), + h.Style(STAND_IN_STYLE), + ...(isFound ? [] : [h.Attribute("hidden", "until-found")]), + ], + // One text node per row: a stand-in costs two DOM nodes. + [Arr.join(textOf(item, variant), " ")], + ) + +const group = ( + h: HtmlBuilder, + side: FindableSide, + chunk: number, + from: number, + to: number, + found: number, + items: ReadonlyArray, + textOf: (item: A, variant: string) => ReadonlyArray, + variant: string, +): Html => + h.keyed("div")( + `${side}-${chunk}`, + found >= 0 ? [] : [h.Attribute("hidden", "until-found")], + Arr.flatMap(Arr.range(chunk * CHUNK, chunk * CHUNK + CHUNK - 1), (index) => { + const item = items[index] + if (item === undefined || ((index < from || index > to) && index !== found)) return [] + return [standIn(h, index, index === found, item, textOf, variant)] + }), + ) + +const layer = ( + h: HtmlBuilder, + side: FindableSide, + from: number, + to: number, + input: FindableLayers, +): Html => { + const found = Option.match(input.found, { + onNone: () => -1, + onSome: (match) => (match.side === side ? match.index : -1), + }) + const rangeChunks = from <= to ? Arr.range(Math.floor(from / CHUNK), Math.floor(to / CHUNK)) : [] + const chunks = Arr.sort( + Arr.dedupe(found < 0 ? rangeChunks : [...rangeChunks, Math.floor(found / CHUNK)]), + Order.Number, + ) + return h.keyed("div")( + side, + [h.DataAttribute(SIDE_ATTRIBUTE, side), h.Style(LAYER_STYLE)], + Arr.flatMap(chunks, (chunk) => { + const first = chunk * CHUNK + const last = first + CHUNK - 1 + // Only the part of the range inside this chunk is a lazy argument, so moving the window + // rebuilds the one or two chunks at its edges. + const clippedFrom = Math.max(from, first) + const clippedTo = Math.min(to, last) + const foundHere = found >= first && found <= last ? found : -1 + const vnode = lazyGroup(`${side}-${chunk}`, group, [ + h, + side, + chunk, + clippedFrom, + clippedTo, + foundHere, + input.items, + input.textOf, + input.variant, + ]) + return vnode === null ? [] : [vnode] + }), + ) +} + +/** + * The two stand-in layers, placed in the list container (positioned, as tall as the list): the + * `before` layer ahead of the rendered rows, the `after` layer behind them. + */ +export const findableLayers = ( + h: HtmlBuilder, + input: FindableLayers, +): Readonly<{ before: Html; after: Html }> => { + const first = Arr.match(input.rendered, { + onEmpty: () => input.items.length, + onNonEmpty: (r) => Math.min(...r), + }) + const last = Arr.match(input.rendered, { + onEmpty: () => input.items.length - 1, + onNonEmpty: (r) => Math.max(...r), + }) + return { + before: layer(h, "before", 0, first - 1, input), + after: layer(h, "after", last + 1, input.items.length - 1, input), + } +} + +/** + * Listens for the browser's `beforematch` under `scroller`: the matched stand-in moves to its row's + * offset (in the list container's coordinates, `start - scrollMargin`), and the match is reported. + */ +export const bindFindable = ( + scroller: HTMLElement, + virtualizer: Instance, + onMatch: (match: FindableMatch) => void, +): (() => void) => { + const onBeforeMatch = (event: Event) => { + const target = event.target + if (!(target instanceof HTMLElement)) return + const index = Number(target.dataset.findableIndex) + if (target.dataset.findableIndex === undefined || !Number.isInteger(index)) return + const side = target.closest(`[data-${SIDE_ATTRIBUTE}]`)?.dataset.findableSide + if (side !== "before" && side !== "after") return + // A row the virtualizer has not counted yet has no offset to scroll to. + const item = virtualizer.measurementsCache[index] + if (item === undefined) return + target.style.top = `${item.start - virtualizer.options.scrollMargin}px` + target.style.height = `${item.size}px` + onMatch({ index, side }) + } + scroller.addEventListener("beforematch", onBeforeMatch) + return () => scroller.removeEventListener("beforematch", onBeforeMatch) +} diff --git a/packages/views/src/ui/logs/logs-stream-message.ts b/packages/views/src/ui/logs/logs-stream-message.ts index bdb04aa8a8..136efa78f9 100644 --- a/packages/views/src/ui/logs/logs-stream-message.ts +++ b/packages/views/src/ui/logs/logs-stream-message.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" +import { FindableMatch } from "../../lib/findable" import * as AttributesPanel from "../attributes/attributes-panel" /** A rendered row of the virtual window: which log, and where it sits in the list. */ @@ -12,6 +13,8 @@ export type NavigationKey = typeof NavigationKey.Type export const Message = defineMessageUnion({ /** The virtualizer's window moved or a row was measured. */ ChangedVirtualWindow: { rows: Schema.Array(VirtualRow), totalSize: Schema.Number }, + /** The browser's find in page matched a row that was not rendered. */ + FoundRow: { match: FindableMatch }, /** The scroller left the newest rows, or came back to them. */ ChangedScrolledAway: { isScrolledAway: Schema.Boolean }, EnteredStream: {}, diff --git a/packages/views/src/ui/logs/logs-stream-view.ts b/packages/views/src/ui/logs/logs-stream-view.ts index 4f3ca37da7..d87c523742 100644 --- a/packages/views/src/ui/logs/logs-stream-view.ts +++ b/packages/views/src/ui/logs/logs-stream-view.ts @@ -11,6 +11,8 @@ import { Message } from "./logs-stream-message" import { eyebrow } from "../eyebrow" import type * as AttributesPanel from "../attributes/attributes-panel" import type { CopyStatus } from "../copy-button" +import { findableLayers } from "../../lib/findable" +import { ROW_LINE_CHARS, ROW_WRAPPED_CHARS, textWindow } from "../../lib/display-text" import { COUNT_ATTRIBUTE, LAYOUT_ATTRIBUTE, STREAM_ATTRIBUTE, VirtualizeLogStream } from "./virtualize" export interface ViewInputs { @@ -143,6 +145,16 @@ const rowView = ( canFilter, }) +/** What find in page searches in a row that is not rendered: the text the row shows. */ +const findableText = (row: LogRowView, variant: string): ReadonlyArray => [ + row.severity, + row.serviceName, + ...Arr.flatMap(row.pinned, (cell) => (cell.isEmpty ? [] : [cell.value])), + variant === "wrap" + ? textWindow(row.body, undefined, ROW_WRAPPED_CHARS) + : textWindow(row.firstLine, undefined, ROW_LINE_CHARS), +] + // Rows are keyed by index; the slot is `index % 256` so the cache stays bounded // (a window never holds 256 rows) while each slot still renders at one position. const lazyRow = createKeyedLazy() @@ -173,6 +185,13 @@ export const view = defineView((model, inputs, h) => ]), ] }) + const findable = findableLayers(h, { + items: model.rows, + textOf: findableText, + variant: inputs.wrap ? "wrap" : "nowrap", + rendered: Arr.map(model.window, (row) => row.index), + found: model.found, + }) return h.div( inputs.isBare === true ? [h.Class("contents")] @@ -213,7 +232,7 @@ export const view = defineView((model, inputs, h) => h.Style({ height: `${model.totalSize}px`, position: "relative" }), h.Role("log"), ], - rows, + [findable.before, ...rows, findable.after], ), ], ), diff --git a/packages/views/src/ui/logs/logs-stream.scene.test.ts b/packages/views/src/ui/logs/logs-stream.scene.test.ts index ba3bd491d6..fa8f204bae 100644 --- a/packages/views/src/ui/logs/logs-stream.scene.test.ts +++ b/packages/views/src/ui/logs/logs-stream.scene.test.ts @@ -302,3 +302,60 @@ describe("logs stream lazy rows", () => { Lazy.expectAllReused({ view: sceneView, model: withWindow(0, 19) }) }) }) + +describe("logs stream: find in page", () => { + const standIn = (side: "before" | "after", index: number) => + selector(`[data-findable-side="${side}"] [data-findable-index="${index}"]`) + const standIns = (side: "before" | "after") => + all.selector(`[data-findable-side="${side}"] [data-findable-index]`) + const found = (index: number, side: "before" | "after" = "after") => + Mount.resolve(VirtualizeLogStream, Message.FoundRow({ match: { index, side } })) + + test("every row outside the window has a stand-in on its side, hidden until found", () => { + scene( + program, + given(withWindow(20, 39)), + ackVirtualizer, + expectAll(standIns("before")).toHaveCount(20), + expectAll(standIns("after")).toHaveCount(20), + expect(standIn("after", 59)).toHaveAttr("hidden", "until-found"), + expect( + within(standIn("before", 0), text("Synthetic wide log row 0:", { exact: false })), + ).toExist(), + expect(selector('[data-findable-index="20"]')).not.toExist(), + expect(selector('[data-findable-index="39"]')).not.toExist(), + ) + }) + + test("a match focuses its row and keeps the revealed stand-in until the row scrolls away", () => { + scene( + program, + given(withWindow(0, 24)), + found(50), + expect(standIn("after", 50)).not.toHaveAttr("hidden"), + // A window the virtualizer measured before the browser scrolled does not end the find. + Subscription.emit(windowOf(0, 25)), + expect(standIn("after", 50)).not.toHaveAttr("hidden"), + Subscription.emit(windowOf(40, 59)), + expect(within(rowOf(50), role("listitem"))).toHaveAttr("data-focused", "true"), + expect(standIn("after", 50)).not.toHaveAttr("hidden"), + expect(selector('[data-findable-index="51"]')).not.toExist(), + Subscription.emit(windowOf(0, 24)), + expect(standIn("after", 50)).toHaveAttr("hidden", "until-found"), + ) + }) + + test("a match above the window is kept in the before layer, ahead of the rows", () => { + scene( + program, + given(withWindow(40, 59)), + found(10, "before"), + Subscription.emit(windowOf(0, 20)), + expect(within(rowOf(10), role("listitem"))).toHaveAttr("data-focused", "true"), + expect(standIn("before", 10)).not.toHaveAttr("hidden"), + expect(selector('[data-findable-side="after"] [data-findable-index="10"]')).not.toExist(), + Subscription.emit(windowOf(40, 59)), + expect(standIn("before", 10)).toHaveAttr("hidden", "until-found"), + ) + }) +}) diff --git a/packages/views/src/ui/logs/logs-stream.ts b/packages/views/src/ui/logs/logs-stream.ts index 4a0cc52ec2..17d94981c8 100644 --- a/packages/views/src/ui/logs/logs-stream.ts +++ b/packages/views/src/ui/logs/logs-stream.ts @@ -2,6 +2,7 @@ import { Array, Duration, Effect, Option, Record, Schema, Stream } from "effect" import { Command, Dom, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { modifyFields } from "foldkit/struct" +import { Found, foundAfterWindow, foundOf } from "../../lib/findable" import * as AttributesPanel from "../attributes/attributes-panel" import { writeClipboard } from "@maple/ui/lib/clipboard" import { afterCopyStatusHold } from "../copy-button" @@ -27,6 +28,8 @@ export const Model = Schema.Struct({ window: Schema.Array(VirtualRow), totalSize: Schema.Number, focusedIndex: Schema.Option(Schema.Number), + /** The row find in page matched, whose stand-in holds the browser's active match. */ + found: Schema.Option(Found), expandedRows: Schema.Array(Schema.Number), isHovering: Schema.Boolean, isScrolledAway: Schema.Boolean, @@ -48,6 +51,7 @@ export const init = (id: string, rows: ReadonlyArray): Model => ({ window: [], totalSize: 0, focusedIndex: Option.none(), + found: Option.none(), expandedRows: [], isHovering: false, isScrolledAway: false, @@ -250,11 +254,23 @@ const handle = (model: Model, rows: ReadonlyArray, message: Message) model: modifyFields(model, { window: () => rendered, totalSize: () => totalSize, + found: (found) => + foundAfterWindow( + found, + Array.map(rendered, (row) => row.index), + ), visibleChip: (visible) => Option.filter(visible, isRendered), pendingOpen: (pending) => Option.filter(pending, (p) => isRendered(p.chip)), }), } }, + // The found row takes the keyboard focus, so it is marked and j/k carry on from it. + FoundRow: ({ match }) => ({ + model: modifyFields(model, { + found: () => Option.some(foundOf(match)), + focusedIndex: () => Option.some(match.index), + }), + }), ChangedScrolledAway: ({ isScrolledAway }) => ({ model: modifyFields(model, { isScrolledAway: () => isScrolledAway }), }), @@ -398,7 +414,12 @@ export const setRows = (model: Model, rows: ReadonlyArray): UpdateRe /** New data (a reload that prepends rows, a filter): expansion was made on the old rows, so it goes. */ export const replaceRows = (model: Model, rows: ReadonlyArray): UpdateReturn => reportingInspect(model, { - model: modifyFields(model, { rows: () => rows, expandedRows: () => [], panels: () => ({}) }), + model: modifyFields(model, { + rows: () => rows, + expandedRows: () => [], + panels: () => ({}), + found: () => Option.none(), + }), }) /** "Jump to latest": back to the newest rows, every row collapsed. */ diff --git a/packages/views/src/ui/logs/virtualize.ts b/packages/views/src/ui/logs/virtualize.ts index f05fe15c82..3f5b3909b4 100644 --- a/packages/views/src/ui/logs/virtualize.ts +++ b/packages/views/src/ui/logs/virtualize.ts @@ -11,6 +11,7 @@ import { virtualizerStream, whileLive, } from "../../lib/virtualizer" +import { bindFindable } from "../../lib/findable" import { writeClipboard } from "@maple/ui/lib/clipboard" import { Message, type VirtualRow } from "./logs-stream-message" @@ -49,7 +50,11 @@ const rowsOf = (scroller: HTMLElement): ReadonlyArray => * of every mounted row. The window * reaches the Model only when it changes, not on every scroll event. */ -const VirtualizeMessage = Schema.Union([Message.ChangedVirtualWindow, Message.ChangedScrolledAway]) +const VirtualizeMessage = Schema.Union([ + Message.ChangedVirtualWindow, + Message.ChangedScrolledAway, + Message.FoundRow, +]) type VirtualizeMessage = typeof VirtualizeMessage.Type type VirtualizerState = { @@ -166,6 +171,10 @@ const virtualize = (scroller: HTMLElement): Stream.Stream => } scroller.addEventListener(SCROLL_EVENT, onScrollRequest) + const unbindFindable = bindFindable(scroller, virtualizer, (match) => + offer(Message.FoundRow({ match })), + ) + observeLog() Arr.forEach(rowsOf(scroller), measure) const unmount = virtualizer._didMount() @@ -173,6 +182,7 @@ const virtualize = (scroller: HTMLElement): Stream.Stream => emitWindow(virtualizer) return () => { + unbindFindable() scroller.removeEventListener(SCROLL_EVENT, onScrollRequest) scroller.removeEventListener("scroll", onScroll) layoutObserver.disconnect() @@ -182,7 +192,7 @@ const virtualize = (scroller: HTMLElement): Stream.Stream => }) export const VirtualizeLogStream = Mount.defineStream("VirtualizeLogStream", { - messages: [Message.ChangedVirtualWindow, Message.ChangedScrolledAway], + messages: [Message.ChangedVirtualWindow, Message.ChangedScrolledAway, Message.FoundRow], execute: ({ element, viewStateChanges }) => whileLive(element, viewStateChanges, virtualize), })