diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 06fd09d9de1..1da0844056c 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -77,7 +77,7 @@ ENV PATH="$HOME/.cargo/bin:$PATH" ## Install Bun.js RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.3" ENV PATH="$HOME/.bun/bin:$PATH" -RUN $HOME/.bun/bin/bun install --global @hey-api/openapi-ts +RUN $HOME/.bun/bin/bun install --global @hey-api/openapi-ts @biomejs/biome ## Install Claude Code RUN curl -fsSL https://claude.ai/install.sh | bash diff --git a/js-packages/common-ui/package.json b/js-packages/common-ui/package.json index 3d408ac9ce3..8b382253c1b 100644 --- a/js-packages/common-ui/package.json +++ b/js-packages/common-ui/package.json @@ -53,7 +53,8 @@ "prepare": "bun run prepack", "prepack": "svelte-kit sync && svelte-package && publint", "check": "svelte-kit sync && svelte-check --threshold error", - "check:watch": "svelte-kit sync && svelte-check --threshold error --watch" + "check:watch": "svelte-kit sync && svelte-check --threshold error --watch", + "format": "biome check --write ." }, "sideEffects": ["**/*.css"], "svelte": "./dist/index.js", diff --git a/js-packages/common-ui/src/lib/LogList.svelte b/js-packages/common-ui/src/lib/LogList.svelte index ea6651ae3cf..93dbf0c805b 100644 --- a/js-packages/common-ui/src/lib/LogList.svelte +++ b/js-packages/common-ui/src/lib/LogList.svelte @@ -7,21 +7,22 @@ externally-owned `search` state; they wire the search input wherever fits their layout. --> + +
+ + + {#if open} + +
+ + + {counterText} + + + +
+ {/if} +
diff --git a/js-packages/common-ui/src/lib/icons/search.svg b/js-packages/common-ui/src/lib/icons/search.svg new file mode 100755 index 00000000000..4e7cbf4dc42 --- /dev/null +++ b/js-packages/common-ui/src/lib/icons/search.svg @@ -0,0 +1 @@ + diff --git a/js-packages/common-ui/src/lib/index.ts b/js-packages/common-ui/src/lib/index.ts index 4b7ba11e41d..f53ca24b6d8 100644 --- a/js-packages/common-ui/src/lib/index.ts +++ b/js-packages/common-ui/src/lib/index.ts @@ -26,13 +26,19 @@ export { advanceSearch, applySearchHighlight, compileSearchPattern, + countOccurrences, emptySearchState, findMatchOffsets, findOccurrence, + isFindShortcut, searchPatternsEqual, type LineMatcher, type MatchRange, + type SearchDirection, type SearchPattern, + type SearchProgress, type SearchState } from './logSearch' +export { default as SearchBar } from './SearchBar.svelte' +export { useShortcut } from './useShortcut.svelte' export { sliceLinesForCopy, type CopySlice } from './logCopy' diff --git a/js-packages/common-ui/src/lib/logSearch.ts b/js-packages/common-ui/src/lib/logSearch.ts index 14286e05ce0..849e289c270 100644 --- a/js-packages/common-ui/src/lib/logSearch.ts +++ b/js-packages/common-ui/src/lib/logSearch.ts @@ -26,13 +26,33 @@ export type SearchState = { /** The starting state — no pattern, cursor at zero. */ export const emptySearchState: SearchState = { pattern: null, occurrenceIndex: 0 } +/** Direction a "submit search" interaction moves the cursor: `next` (Enter) steps forward, + * `prev` (Shift-Enter) steps backward. */ +export type SearchDirection = 'next' | 'prev' + +/** True for the browser "find in page" shortcut (Ctrl-F / Cmd-F, no other modifiers). */ +export function isFindShortcut(e: KeyboardEvent): boolean { + return (e.key === 'f' || e.key === 'F') && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey +} + +/** + * The state for a committed in-view search. + */ +export type SearchProgress = { current: number; total: number } + /** True when two patterns describe the same search — used by hosts to decide whether * pressing Enter should advance to the next match (same pattern) or jump to the first * match of a new pattern (different pattern). */ export function searchPatternsEqual(a: SearchPattern | null, b: SearchPattern | null): boolean { - if (a === b) return true - if (!a || !b) return false - if (a.kind !== b.kind) return false + if (a === b) { + return true + } + if (!a || !b) { + return false + } + if (a.kind !== b.kind) { + return false + } if (a.kind === 'substring' && b.kind === 'substring') { return a.query === b.query && !!a.caseSensitive === !!b.caseSensitive } @@ -43,19 +63,24 @@ export function searchPatternsEqual(a: SearchPattern | null, b: SearchPattern | } /** - * Compute the next {@link SearchState} for a "submit search" interaction (typically Enter - * on the panel's search input). + * Compute the next {@link SearchState} for advancing a search. * - `next = null` (or an empty pattern): clear the search. - * - `next` equals the current pattern: advance to the next match (`occurrenceIndex + 1`). - * - `next` differs from the current pattern: jump to the first match of the new pattern. - * - * Centralising this here keeps every host's submit handler identical and ensures the - * cursor semantics stay consistent across log views. + * - `next` equals the current pattern: step the cursor one match in `direction` + * (`occurrenceIndex ± 1`). {@link findOccurrence} wraps the index, so no bounds check. + * - `next` differs from the current pattern: jump to the first match of the new pattern, + * regardless of direction. */ -export function advanceSearch(state: SearchState, next: SearchPattern | null): SearchState { - if (!next) return emptySearchState +export function advanceSearch( + state: SearchState, + next: SearchPattern | null, + direction: SearchDirection = 'next' +): SearchState { + if (!next) { + return emptySearchState + } if (searchPatternsEqual(state.pattern, next)) { - return { pattern: state.pattern, occurrenceIndex: state.occurrenceIndex + 1 } + const step = direction === 'prev' ? -1 : 1 + return { pattern: state.pattern, occurrenceIndex: state.occurrenceIndex + step } } return { pattern: next, occurrenceIndex: 0 } } @@ -69,7 +94,9 @@ export type MatchRange = readonly [start: number, end: number] export function compileSearchPattern(pattern: SearchPattern): LineMatcher { if (pattern.kind === 'substring') { - if (!pattern.query) return null + if (!pattern.query) { + return null + } if (pattern.caseSensitive) { const q = pattern.query return (line) => line.includes(q) @@ -78,7 +105,9 @@ export function compileSearchPattern(pattern: SearchPattern): LineMatcher { return (line) => line.toLowerCase().includes(q) } if (pattern.kind === 'regex') { - if (!pattern.source) return null + if (!pattern.source) { + return null + } try { const re = new RegExp(pattern.source, pattern.flags) return (line) => re.test(line) @@ -101,7 +130,9 @@ export function compileSearchPattern(pattern: SearchPattern): LineMatcher { export function findMatchOffsets(line: string, pattern: SearchPattern): MatchRange[] { const offsets: MatchRange[] = [] if (pattern.kind === 'substring') { - if (!pattern.query) return offsets + if (!pattern.query) { + return offsets + } const haystack = pattern.caseSensitive ? line : line.toLowerCase() const needle = pattern.caseSensitive ? pattern.query : pattern.query.toLowerCase() let idx = haystack.indexOf(needle) @@ -112,7 +143,9 @@ export function findMatchOffsets(line: string, pattern: SearchPattern): MatchRan return offsets } if (pattern.kind === 'regex') { - if (!pattern.source) return offsets + if (!pattern.source) { + return offsets + } let re: RegExp try { const flags = pattern.flags ?? '' @@ -191,7 +224,9 @@ export function applySearchHighlight( for (const [s, e] of offsets) { const startPos = locate(s) const endPos = locate(e) - if (!startPos || !endPos) continue + if (!startPos || !endPos) { + continue + } const r = new Range() r.setStart(startPos.node, startPos.offset) r.setEnd(endPos.node, endPos.offset) @@ -220,13 +255,43 @@ export function findOccurrence( occurrenceIndex: number ): number { const matcher = compileSearchPattern(pattern) - if (!matcher) return -1 + if (!matcher) { + return -1 + } const matches: number[] = [] for (let i = 0; i < lines.length; i++) { - if (matcher(lines[i])) matches.push(i) + if (matcher(lines[i])) { + matches.push(i) + } + } + if (matches.length === 0) { + return -1 } - if (matches.length === 0) return -1 const n = matches.length const wrapped = ((occurrenceIndex % n) + n) % n return matches[wrapped] } + +/** + * Count the matching lines for `pattern` — the number of "next match" steps before the cursor + * wraps. Hosts use this to enable/disable the search nav buttons (0 - nothing to navigate). + * + * One match per line: multiple substrings on the same line count once, matching + * {@link findOccurrence}. Returns `0` for an empty or invalid pattern. + */ +export function countOccurrences(lines: readonly string[], pattern: SearchPattern | null): number { + if (!pattern) { + return 0 + } + const matcher = compileSearchPattern(pattern) + if (!matcher) { + return 0 + } + let count = 0 + for (const line of lines) { + if (matcher(line)) { + count++ + } + } + return count +} diff --git a/js-packages/common-ui/src/lib/useShortcut.svelte.ts b/js-packages/common-ui/src/lib/useShortcut.svelte.ts new file mode 100644 index 00000000000..a88a7b94d7f --- /dev/null +++ b/js-packages/common-ui/src/lib/useShortcut.svelte.ts @@ -0,0 +1,34 @@ +/** + * Register a keyboard shortcut on the window while a scope is active. + * + * App-level shortcuts (Ctrl/Cmd-F to open search, and the like) are a scope concern, not a focus + * concern: they must fire regardless of which element holds focus. A capture-phase window listener + * delivers that reliably, where routing the key through a focused container is browser-fragile. + * + * Call during component initialization (it sets up an `$effect`). Pass `isActive` as a getter so + * the listener attaches only while the scope is shown (e.g. the current tab) and detaches + * otherwise; the effect re-runs whenever the values it reads change. + * + * @param matches predicate identifying the shortcut (e.g. `isFindShortcut`) + * @param handler runs when the shortcut fires; the default is already prevented + * @param isActive whether the shortcut is currently in scope (default: always) + */ +export function useShortcut( + matches: (e: KeyboardEvent) => boolean, + handler: (e: KeyboardEvent) => void, + isActive: () => boolean = () => true +): void { + $effect(() => { + if (!isActive()) { + return + } + const onKeydown = (e: KeyboardEvent) => { + if (matches(e)) { + e.preventDefault() + handler(e) + } + } + window.addEventListener('keydown', onKeydown, { capture: true }) + return () => window.removeEventListener('keydown', onKeydown, { capture: true }) + }) +} diff --git a/js-packages/profiler-layout/src/lib/components/BundleLogsView.svelte b/js-packages/profiler-layout/src/lib/components/BundleLogsView.svelte index b11440606b8..58d8ea447a9 100644 --- a/js-packages/profiler-layout/src/lib/components/BundleLogsView.svelte +++ b/js-packages/profiler-layout/src/lib/components/BundleLogsView.svelte @@ -1,5 +1,11 @@ {#if !logText} @@ -40,5 +47,5 @@ No logs available in this bundle {:else} - + {/if} diff --git a/js-packages/profiler-layout/src/lib/components/MetricsView.svelte b/js-packages/profiler-layout/src/lib/components/MetricsView.svelte index 35b82fa28c0..e4304a0f989 100644 --- a/js-packages/profiler-layout/src/lib/components/MetricsView.svelte +++ b/js-packages/profiler-layout/src/lib/components/MetricsView.svelte @@ -32,7 +32,9 @@ import MetricsDistributionBlock from './metrics/blocks/MetricsDistributionBlock.svelte' import { buildBlocks, type RenderableBlock } from './metrics/dispatch' import { buildGlobalMetrics, type GlobalMetrics } from '../functions/globalMetrics' - import type { LookupCoordinator } from '../functions/lookup' + import type { LookupCoordinator, SearchProgress } from '../functions/lookup' + import { buildSearchTargets, matchTargets } from '../functions/metricsSearch' + import type { SearchDirection } from 'common-ui' interface Props { mode: MetricsMode @@ -51,8 +53,15 @@ onSearchNode?: (query: string) => void } - const { mode, tooltipData, rootNodeId, globalMetrics, showAdvanced, lookup, onSearchNode }: Props = - $props() + const { + mode, + tooltipData, + rootNodeId, + globalMetrics, + showAdvanced, + lookup, + onSearchNode + }: Props = $props() // Pipeline-wide totals for the overview's "Global stats" tile. Empty (so the tile is hidden) on // any non-overview view or when the bundle carried no stats. @@ -80,6 +89,21 @@ ) const showAttributesView = $derived(mode === 'overview' || mode === 'node') + const genericTable = $derived( + tooltipData && 'genericTable' in tooltipData ? tooltipData.genericTable : null + ) + + // Scroll targets the search can jump to in the current view, in DOM order; each carries a + // `data-block-id` anchor (see {@link buildSearchTargets}). + const searchTargets = $derived( + buildSearchTargets({ + showAttributesView, + globalEntries: globalMetricEntries, + blocks, + topNodeRows: genericTable?.rows ?? [] + }) + ) + let containerEl: HTMLDivElement | undefined = $state() // Container-width-driven column count. A ResizeObserver tracks the scroll container's @@ -98,47 +122,40 @@ return () => observer.disconnect() }) - // Search priorities: block title, then metric label, then metric id. Always returns the - // *block* to scroll to — metrics in distribution blocks share grid cells with their - // siblings and don't have a single DOM anchor, so block-level is the reliable target. - function findMatchingBlockId(query: string): string | null { - const q = query.trim().toLowerCase() - if (!q || blocks.length === 0) return null - for (const b of blocks) { - if (b.title?.toLowerCase().includes(q)) return b.id - } - for (const b of blocks) { - for (const e of b.entries) { - if (e.label.toLowerCase().includes(q)) return b.id - } + // Search cursor. A new query starts at the first match; repeating the same query steps the + // cursor (Enter / down → next, Shift-Enter / up → previous), wrapping modulo the match count. + // Imperative — no $state on purpose + let searchQuery = '' + let matchCursor = 0 + + // Called imperatively on every submit, so pressing Enter again on the same query advances to the + // next match rather than being ignored. Matches are the full target list in DOM order, + // so a new query starts at the first match from the top. + // Returns the match count so the layout can enable/disable the nav buttons. + function runSearch(query: string, direction: SearchDirection): SearchProgress { + if (!containerEl) return { current: 0, total: 0 } + const ids = matchTargets(searchTargets, query) + if (ids.length === 0) { + searchQuery = query + return { current: 0, total: 0 } } - for (const b of blocks) { - for (const e of b.entries) { - if (e.row.metric.toLowerCase().includes(q)) return b.id - } + if (query === searchQuery) { + matchCursor += direction === 'prev' ? -1 : 1 + } else { + matchCursor = 0 + searchQuery = query } - return null - } - - // Imperative handler. Each Enter on the panel's search input calls this directly via the - // lookup coordinator, so identical queries still re-fire (unlike a reactive `$effect` on a - // query prop, where Svelte would dedupe equal values). - function runSearch(query: string) { - if (!containerEl) return - const matchId = findMatchingBlockId(query) - if (!matchId) return - const el = containerEl.querySelector(`[data-block-id="${matchId}"]`) + const n = ids.length + matchCursor = ((matchCursor % n) + n) % n + const el = containerEl.querySelector(`[data-block-id="${ids[matchCursor]}"]`) el?.scrollIntoView({ block: 'start', behavior: 'smooth' }) + return { current: matchCursor + 1, total: n } } $effect(() => { if (!lookup) return return lookup.register('Metrics', runSearch) }) - - const genericTable = $derived( - tooltipData && 'genericTable' in tooltipData ? tooltipData.genericTable : null - ) {#snippet attributesView()} @@ -205,8 +222,8 @@ - {#each genericTable.rows as row} - + {#each genericTable.rows as row, i} +