Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion js-packages/common-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 25 additions & 28 deletions js-packages/common-ui/src/lib/LogList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,22 @@
externally-owned `search` state; they wire the search input wherever fits their layout.
-->
<script lang="ts">
import { untrack, type Snippet } from 'svelte'
import { Virtualizer, type VirtualizerHandle } from 'virtua/svelte'
import stripAnsi from 'strip-ansi'
import { type Snippet, untrack } from 'svelte'
import { Virtualizer, type VirtualizerHandle } from 'virtua/svelte'
import ANSIDecoratedText from './ANSIDecoratedText.svelte'
import ScrollDownFab from './ScrollDownFab.svelte'
import { useReverseScrollContainer } from './useReverseScrollContainer.svelte'
import { virtualSelect } from './userSelect'
import { sliceLinesForCopy, type CopySlice } from './logCopy'
import { type CopySlice, sliceLinesForCopy } from './logCopy'
import {
applySearchHighlight,
countOccurrences,
emptySearchState,
findMatchOffsets,
findOccurrence,
type SearchState
} from './logSearch'
import ScrollDownFab from './ScrollDownFab.svelte'
import { useReverseScrollContainer } from './useReverseScrollContainer.svelte'
import { virtualSelect } from './userSelect'

interface Props {
/** Lines to render, one per row. Hosts pre-split their source into lines. */
Expand Down Expand Up @@ -51,13 +52,12 @@
* joins the selected `lines` with `\n`; rows that already carry a trailing newline
* need an override that joins with `''` (see {@link sliceLinesForCopy}). */
getCopyContent?: (slice: CopySlice) => string
/** Invoked when the user presses Ctrl-F / Cmd-F while focus is inside the log list.
* Hosts typically focus their search input here. When omitted, the browser's native
* find-in-page is left to handle the shortcut. */
onSearchShortcut?: () => void
/** Fired whenever stick-to-bottom toggles: `true` when the view re-anchors to the
* bottom, `false` when the user scrolls up off the bottom. */
onStickToBottomChange?: (stickToBottom: boolean) => void
/** Fired with the number of lines matching the current search pattern (0 when the search
* is cleared). Hosts use it to enable/disable their search nav buttons. */
onMatchCountChange?: (count: number) => void
}

let {
Expand All @@ -70,8 +70,8 @@
style,
header,
getCopyContent,
onSearchShortcut,
onStickToBottomChange
onStickToBottomChange,
onMatchCountChange
}: Props = $props()

// Reverse-scroll lives here (not in wrappers) so search behaviour and auto-scroll behaviour
Expand Down Expand Up @@ -104,6 +104,13 @@
search.pattern ? findOccurrence(lines, search.pattern, search.occurrenceIndex) : -1
)

// Report the match count so the host can enable/disable its search nav buttons. Recomputes
// as the pattern changes or streaming appends lines that match.
const matchCount = $derived(countOccurrences(lines, search.pattern))
$effect(() => {
onMatchCountChange?.(matchCount)
})

function paintHighlight() {
if (!scrollContainer || matchedIndex < 0 || !search.pattern) {
applySearchHighlight(highlightName, null, [])
Expand Down Expand Up @@ -131,10 +138,14 @@
$effect(() => {
const pattern = search.pattern
const occ = search.occurrenceIndex
if (!pattern) return
if (!pattern) {
return
}
untrack(() => {
const idx = findOccurrence(lines, pattern, occ)
if (idx < 0) return
if (idx < 0) {
return
}
// Drop stick-to-bottom so a streaming log doesn't scroll away from the match the user
// jumped to. They regain auto-scroll by scrolling back to the bottom themselves.
reverseScroll.stickToBottom = false
Expand Down Expand Up @@ -170,20 +181,6 @@
getRoot: (node) => node.firstElementChild!,
getCopyContent: getCopyContent ?? defaultGetCopyContent
}}
onkeydown={(e) => {
// Ctrl-F (Win/Linux) or Cmd-F (Mac) — redirect to host's search input. Only intercept
// when the host supplied a handler; otherwise the browser's find-in-page is left alone.
if (
onSearchShortcut &&
(e.key === 'f' || e.key === 'F') &&
(e.ctrlKey || e.metaKey) &&
!e.altKey &&
!e.shiftKey
) {
e.preventDefault()
onSearchShortcut()
}
}}
>
<Virtualizer
data={lines}
Expand Down
205 changes: 205 additions & 0 deletions js-packages/common-ui/src/lib/SearchBar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<!--
SearchBar: shared "search within the active view" widget for the pipeline Logs tab and the
profiler bundle viewer. Renders inline as a search-icon button that opens a popup with the query
input, a match counter, and prev/next nav buttons.

Presentational only: the host owns the query and runs the search. `results` is the single source
of truth — the counter, the host's highlight, and the nav buttons all derive from it, and Escape
/ editing / closing call `onclear` so they clear together.

Hosts open the search by wiring Ctrl/Cmd-F to the exported `activate()`; the widget handles no
shortcut itself. The popup closes only via `toggle()`, Escape, the close button, or blur when
empty — never on outside click.
-->
<script lang="ts">
import { tick } from 'svelte'
import searchIcon from './icons/search.svg?raw'
import type { SearchDirection, SearchProgress } from './logSearch'

interface Props {
/** The query text. Bindable so the host reads what was typed. */
value: string
placeholder?: string
title?: string
/** The single source of truth for the committed search — see {@link SearchProgress}.
* `null` (no active search) hides the counter and disables the nav buttons; the host must
* reset it to `null` on `onclear` so the counter, highlight, and buttons clear together. */
results?: SearchProgress | null
/** The underlying input element, exposed so hosts can focus it (e.g. on Ctrl/Cmd-F from
* the results view). */
inputEl?: HTMLInputElement
/** Whether the popup is open. Bindable so hosts can open it programmatically (Ctrl/Cmd-F). */
open?: boolean
/** Extra classes for the outer (relative) container. */
class?: string
/** Extra classes for the input (width, ...). */
inputClass?: string
/** Advance to the next match — Enter or the down button. */
onnext: () => void
/** Step back to the previous match — Shift-Enter or the up button. */
onprevious: () => void
/** Drop the committed search (highlight + counter + nav). Called on Escape, on edit, and on
* close; the host must reset its results to `null`. */
onclear?: () => void
}

let {
value = $bindable(''),
placeholder,
title,
results = null,
inputEl = $bindable(),
open = $bindable(false),
class: className = '',
inputClass = '',
onnext,
onprevious,
onclear
}: Props = $props()

const hasResults = $derived(!!results && results.total > 0)
const counterText = $derived(
!results ? '' : results.total > 0 ? `${results.current} of ${results.total}` : 'No results'
)

// The element that had the focus when the popup opened; closing returns focus there (with `preventScroll`
// so the viewport doesn't jump), so keyboard nav resumes where the user left off.
//
// The search-icon button uses `onmousedown preventDefault` so clicking it doesn't steal focus:
// Chrome focuses a <button> on click (Firefox doesn't), which would otherwise make the button
// the `opener` instead of the content the user was in.
let opener: HTMLElement | null = null

// Open the popup and focus/select the query, or just refocus it if already open. Hosts wire
// Ctrl/Cmd-F here, so the shortcut opens-or-refocuses and never closes the search.
export async function activate() {
if (!open) {
opener = document.activeElement as HTMLElement | null
open = true
await tick()
}
inputEl?.focus()
inputEl?.select()
}

// Trigger-button click: close if open, else open.
export async function toggle() {
if (open) {
close()
} else {
await activate()
}
}

// Clear the field and drop any displayed results (highlight + counter).
function reset() {
value = ''
onclear?.()
}

function close() {
open = false
reset()
opener?.focus({ preventScroll: true })
opener = null
}

// Commit the current query and step to the next / previous match.
function submit(direction: SearchDirection) {
if (direction === 'prev') {
onprevious()
} else {
onnext()
}
}

function onkeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
submit(e.shiftKey ? 'prev' : 'next')
} else if (e.key === 'Escape') {
close()
}
}

// Editing the query invalidates the displayed results — drop them (the host resets `results`
// to null, which clears the counter, the highlight, and the nav buttons together).
function oninput() {
if (results) {
onclear?.()
}
}

// An empty field losing focus closes the popup; a field with text stays open so focus can move to
// the nav / close buttons. No focus-restore here — blur means focus is already moving on.
function onblur() {
if (!value) {
open = false
reset()
opener = null
}
}
</script>

<div class="relative flex items-center {className}">
<button
type="button"
class="btn-icon p-0.5 text-[16px] hover:preset-tonal-surface"
class:preset-tonal-surface={open}
onclick={toggle}
onmousedown={(e) => e.preventDefault()}
aria-label="Search"
aria-expanded={open}
{title}
>
{@html searchIcon}
</button>

{#if open}
<!-- Anchored just below the button's top-right, growing leftward from its right edge. -->
<div
class="absolute top-9 right-1 z-20 flex items-center gap-1 rounded border border-surface-200-800 bg-surface-50-950 p-1 shadow-md"
>
<input
bind:this={inputEl}
bind:value
type="text"
{placeholder}
{title}
{onkeydown}
{oninput}
{onblur}
class="input {inputClass}"
/>
<!-- Fixed-width slot (shrink-0 so flex keeps it reserved even when the text is empty),
so the nav buttons don't shift as the counter text appears/changes. -->
<span class="w-14 px-1 shrink-0 text-sm whitespace-nowrap text-surface-600-400 text-right">{counterText}</span>
<button
type="button"
class="fd fd-arrow-down btn-icon rotate-180 p-0 hover:not-disabled:preset-tonal-surface disabled:opacity-30"
onclick={() => submit('prev')}
disabled={!hasResults}
aria-label="Previous match"
title="Previous match (Shift+Enter)"
>
</button>
<button
type="button"
class="fd fd-arrow-down btn-icon p-0 hover:not-disabled:preset-tonal-surface disabled:opacity-30"
onclick={() => submit('next')}
disabled={!hasResults}
aria-label="Next match"
title="Next match (Enter)"
>
</button>
<button
type="button"
class="fd fd-x btn-icon p-0 ml-2 hover:preset-tonal-surface"
onclick={close}
aria-label="Close search"
title="Close search (Esc to clear)"
>
</button>
</div>
{/if}
</div>
1 change: 1 addition & 0 deletions js-packages/common-ui/src/lib/icons/search.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions js-packages/common-ui/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading
Loading