|
| 1 | +<script lang="ts"> |
| 2 | + import { untrack, type Snippet } from 'svelte' |
| 3 | + import { Virtualizer, type VirtualizerHandle } from 'virtua/svelte' |
| 4 | + import stripAnsi from 'strip-ansi' |
| 5 | + import ANSIDecoratedText from './ANSIDecoratedText.svelte' |
| 6 | + import ScrollDownFab from './ScrollDownFab.svelte' |
| 7 | + import { useReverseScrollContainer } from './useReverseScrollContainer.svelte' |
| 8 | + import { virtualSelect } from './userSelect' |
| 9 | + import { |
| 10 | + applySearchHighlight, |
| 11 | + emptySearchState, |
| 12 | + findMatchOffsets, |
| 13 | + findOccurrence, |
| 14 | + type SearchState |
| 15 | + } from './logSearch' |
| 16 | +
|
| 17 | + /** Shape of the slice passed by {@link virtualSelect}'s copy interceptor — row/column |
| 18 | + * endpoints over the virtualised list. */ |
| 19 | + type CopySlice = |
| 20 | + | 'all' |
| 21 | + | { start: { row: number; col: number }; end: { row: number; col: number } } |
| 22 | +
|
| 23 | + interface Props { |
| 24 | + /** Lines to render, one per row. Hosts split their source (a string, a stream, ...) into |
| 25 | + * lines before passing them in. */ |
| 26 | + lines: string[] |
| 27 | + /** Offset added to the row index when computing the Virtualizer's stable key — needed |
| 28 | + * when the buffer evicts from the front (a streaming log shifts indices downward). */ |
| 29 | + firstLineIndex?: number |
| 30 | + /** Externally-controlled search state. The owner advances it on submit via |
| 31 | + * {@link advanceSearch} — kept here as a prop so callers can wire the search input |
| 32 | + * wherever fits their layout. */ |
| 33 | + search?: SearchState |
| 34 | + /** Show a left gutter with 1-based row numbers. Implemented via per-row `counter-set` + |
| 35 | + * `::before` generated content so the line number is NOT a DOM text node, keeping the |
| 36 | + * search-offset TreeWalker pointed at the actual log content. */ |
| 37 | + showLineNumbers?: boolean |
| 38 | + /** When true, the container starts at the bottom and re-sticks as content grows — the |
| 39 | + * streaming-log behaviour. When false (default), the container starts at the top and |
| 40 | + * never auto-scrolls on mount — appropriate for static log dumps. */ |
| 41 | + streaming?: boolean |
| 42 | + /** Extra classes for the scroll container — wrappers add background, padding, etc. |
| 43 | + * The monospace font is already baked in. */ |
| 44 | + class?: string |
| 45 | + /** Inline style for the scroll container — for values that don't fit Tailwind utilities. */ |
| 46 | + style?: string |
| 47 | + /** Renders above the scroll container — for status banners (e.g. "logs were skipped"). */ |
| 48 | + header?: Snippet |
| 49 | + /** Override the text produced by copy / Ctrl-C across the virtualised list. Default |
| 50 | + * joins `lines` (ANSI-stripped) with `\n`. Hosts whose rows already carry trailing |
| 51 | + * newlines pass an override that joins with `''`. */ |
| 52 | + getCopyContent?: (slice: CopySlice) => string |
| 53 | + /** Invoked when the user presses Ctrl-F / Cmd-F while focus is inside the log list. |
| 54 | + * Hosts typically focus their search input here. When omitted, the browser's native |
| 55 | + * find-in-page is left to handle the shortcut. */ |
| 56 | + onSearchShortcut?: () => void |
| 57 | + } |
| 58 | +
|
| 59 | + let { |
| 60 | + lines, |
| 61 | + firstLineIndex = 0, |
| 62 | + search = emptySearchState, |
| 63 | + showLineNumbers = false, |
| 64 | + streaming = false, |
| 65 | + class: className = '', |
| 66 | + style, |
| 67 | + header, |
| 68 | + getCopyContent, |
| 69 | + onSearchShortcut |
| 70 | + }: Props = $props() |
| 71 | +
|
| 72 | + // Reverse-scroll lives here (not in wrappers) so search behaviour and auto-scroll behaviour |
| 73 | + // stay co-located. `initialStickToBottom` flips with `streaming`: streams start at the |
| 74 | + // bottom and re-stick on growth; static bundles start at the top. The capture-by-initial |
| 75 | + // value is intentional — `streaming` is a structural switch chosen at mount time, not a |
| 76 | + // runtime-toggleable prop. |
| 77 | + // svelte-ignore state_referenced_locally |
| 78 | + const reverseScroll = useReverseScrollContainer({ |
| 79 | + observeContentElement: (e) => e.firstElementChild!, |
| 80 | + initialStickToBottom: streaming |
| 81 | + }) |
| 82 | +
|
| 83 | + let scrollContainer: HTMLDivElement | undefined = $state() |
| 84 | + let virtualizer: VirtualizerHandle = $state()! |
| 85 | +
|
| 86 | + // `CSS.highlights` is keyed by a static name (the `::highlight(<name>)` selector below can |
| 87 | + // not take a dynamic suffix). One LogList visible at a time per host, so a fixed name |
| 88 | + // shared across embedders is fine. |
| 89 | + const highlightName = 'feldera-log-list-search' |
| 90 | +
|
| 91 | + const matchedIndex = $derived( |
| 92 | + search.pattern ? findOccurrence(lines, search.pattern, search.occurrenceIndex) : -1 |
| 93 | + ) |
| 94 | +
|
| 95 | + function paintHighlight() { |
| 96 | + if (!scrollContainer || matchedIndex < 0 || !search.pattern) { |
| 97 | + applySearchHighlight(highlightName, null, []) |
| 98 | + return |
| 99 | + } |
| 100 | + const el = scrollContainer.querySelector<HTMLElement>(`[data-rowindex="${matchedIndex}"]`) |
| 101 | + applySearchHighlight( |
| 102 | + highlightName, |
| 103 | + el, |
| 104 | + el ? findMatchOffsets(stripAnsi(lines[matchedIndex]), search.pattern) : [] |
| 105 | + ) |
| 106 | + } |
| 107 | +
|
| 108 | + // Re-paint when the matched row index shifts (streaming evicts rows) or the pattern changes. |
| 109 | + // Pure paint — never moves the viewport. |
| 110 | + $effect(() => { |
| 111 | + void matchedIndex |
| 112 | + void search.pattern |
| 113 | + paintHighlight() |
| 114 | + }) |
| 115 | +
|
| 116 | + // Bring the match into view ONCE per submission. Tracked only on the SearchState fields, |
| 117 | + // and the lookup runs `untrack`ed so streaming updates can't re-trigger the scroll — the |
| 118 | + // user retains scroll control after the initial jump. |
| 119 | + $effect(() => { |
| 120 | + const pattern = search.pattern |
| 121 | + const occ = search.occurrenceIndex |
| 122 | + if (!pattern) return |
| 123 | + untrack(() => { |
| 124 | + const idx = findOccurrence(lines, pattern, occ) |
| 125 | + if (idx < 0) return |
| 126 | + virtualizer?.scrollToIndex(idx, { align: 'center' }) |
| 127 | + // virtua mounts the freshly-visible row on the next frame; paint then so the Range is |
| 128 | + // created against the new DOM node rather than the previously-cleared one. |
| 129 | + requestAnimationFrame(paintHighlight) |
| 130 | + }) |
| 131 | + }) |
| 132 | +
|
| 133 | + // Drop the registered Highlight on unmount so it doesn't leak across hosts. |
| 134 | + $effect(() => () => applySearchHighlight(highlightName, null, [])) |
| 135 | +
|
| 136 | + function defaultGetCopyContent(slice: CopySlice): string { |
| 137 | + if (slice === 'all') return lines.map(stripAnsi).join('\n') |
| 138 | + const result = lines.slice(slice.start.row, slice.end.row + 1).map(stripAnsi) |
| 139 | + result[0] = result[0].slice(slice.start.col) |
| 140 | + result[result.length - 1] = result[result.length - 1].slice( |
| 141 | + 0, |
| 142 | + slice.end.col - (slice.start.row === slice.end.row ? slice.start.col : 0) |
| 143 | + ) |
| 144 | + return result.join('\n') |
| 145 | + } |
| 146 | +</script> |
| 147 | + |
| 148 | +<!-- The outer wrapper is positioned so ScrollDownFab (absolute-positioned) can anchor inside |
| 149 | + LogList itself — wrappers don't have to add their own `relative` parent. --> |
| 150 | +<div class="relative flex h-full w-full flex-col"> |
| 151 | + {@render header?.()} |
| 152 | + <!-- onscroll re-paints the highlight: when the matched row scrolls into view, virtua mounts |
| 153 | + a fresh DOM node, so the previously-painted Range is gone and must be re-created |
| 154 | + against the new node. The monospace font + user-select: contain are baked in here so |
| 155 | + wrappers don't have to repeat them. --> |
| 156 | + <div |
| 157 | + bind:this={scrollContainer} |
| 158 | + role="textbox" |
| 159 | + tabindex={-1} |
| 160 | + class="log-list-scroll scrollbar w-full flex-1 overflow-y-auto whitespace-pre-wrap {className}" |
| 161 | + {style} |
| 162 | + use:reverseScroll.action |
| 163 | + use:virtualSelect={{ |
| 164 | + getRoot: (node) => node.firstElementChild!, |
| 165 | + getCopyContent: getCopyContent ?? defaultGetCopyContent |
| 166 | + }} |
| 167 | + onkeydown={(e) => { |
| 168 | + // Ctrl-F (Win/Linux) or Cmd-F (Mac) — redirect to host's search input. Only intercept |
| 169 | + // when the host supplied a handler; otherwise the browser's find-in-page is left alone. |
| 170 | + if ( |
| 171 | + onSearchShortcut && |
| 172 | + (e.key === 'f' || e.key === 'F') && |
| 173 | + (e.ctrlKey || e.metaKey) && |
| 174 | + !e.altKey && |
| 175 | + !e.shiftKey |
| 176 | + ) { |
| 177 | + e.preventDefault() |
| 178 | + onSearchShortcut() |
| 179 | + } |
| 180 | + }} |
| 181 | + > |
| 182 | + <Virtualizer |
| 183 | + data={lines} |
| 184 | + getKey={(_, i) => i + firstLineIndex} |
| 185 | + bind:this={virtualizer} |
| 186 | + onscroll={paintHighlight} |
| 187 | + > |
| 188 | + {#snippet children(value, index)} |
| 189 | + <div |
| 190 | + data-rowindex={index} |
| 191 | + class:logline={showLineNumbers} |
| 192 | + style:counter-set={showLineNumbers ? `line ${index + 1}` : undefined} |
| 193 | + > |
| 194 | + <ANSIDecoratedText {value} /> |
| 195 | + </div> |
| 196 | + {/snippet} |
| 197 | + </Virtualizer> |
| 198 | + </div> |
| 199 | + <ScrollDownFab {reverseScroll}></ScrollDownFab> |
| 200 | +</div> |
| 201 | + |
| 202 | +<style> |
| 203 | + /* Monospace font baked in. Both embedding apps load DM Mono via @fontsource/dm-mono, so it |
| 204 | + resolves; consumers without that font fall back to the system monospace stack. */ |
| 205 | + .log-list-scroll { |
| 206 | + font-family: |
| 207 | + 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', |
| 208 | + 'Courier New', monospace; |
| 209 | + user-select: contain; |
| 210 | + } |
| 211 | + /* `::highlight()` is registered globally on `CSS.highlights`, so the matching CSS rule |
| 212 | + also has to live at the document root. Class-scoped variants (Tailwind's |
| 213 | + `[&::highlight(...)]:bg-…`) work in Chromium but Firefox only honors `::highlight()` |
| 214 | + rules whose selector matches at the document level. */ |
| 215 | + :global(::highlight(feldera-log-list-search)) { |
| 216 | + background-color: var(--color-secondary-200); |
| 217 | + } |
| 218 | + :global(.dark ::highlight(feldera-log-list-search)) { |
| 219 | + background-color: var(--color-secondary-800); |
| 220 | + } |
| 221 | + /* Line-number gutter. Generated content (`::before`) is invisible to DOM text-node |
| 222 | + walkers — keeps the search-offset mapping in {@link applySearchHighlight} aligned to |
| 223 | + the log content, ignoring the displayed line number. */ |
| 224 | + .logline { |
| 225 | + position: relative; |
| 226 | + padding-left: 4rem; |
| 227 | + word-break: break-all; |
| 228 | + } |
| 229 | + .logline::before { |
| 230 | + content: counter(line); |
| 231 | + position: absolute; |
| 232 | + left: 0; |
| 233 | + width: 3rem; |
| 234 | + padding-right: 0.5rem; |
| 235 | + text-align: right; |
| 236 | + user-select: none; |
| 237 | + color: var(--color-surface-400); |
| 238 | + border-right: 1px solid var(--color-surface-200); |
| 239 | + } |
| 240 | + :global(.dark) .logline::before { |
| 241 | + color: var(--color-surface-600); |
| 242 | + border-right-color: var(--color-surface-800); |
| 243 | + } |
| 244 | +</style> |
0 commit comments