8829c8cfd2
- F3: give the <mark> search highlight the full theme-token tier set (light / th-dark / th-highcontrast, with a legible text colour) so it reads correctly in every theme now that the viewer is theme-aware. - F6: restore the transient 'Copied' acknowledgement on the Copy button via useCopy's copiedSuccessfully (Check icon + 'Copied' label). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
2.9 KiB
TypeScript
92 lines
2.9 KiB
TypeScript
import { Fragment, ReactNode } from 'react';
|
|
|
|
import { FormattedLine } from '@/docker/helpers/logHelper/types';
|
|
|
|
interface Props {
|
|
line: FormattedLine;
|
|
/** 1-based ordinal shown in the left gutter (when line numbers are on). */
|
|
lineNumber?: number;
|
|
showLineNumber: boolean;
|
|
/** Case-insensitive substring to highlight (empty = no highlight). */
|
|
search: string;
|
|
}
|
|
|
|
// Split a span's text on the search term (case-insensitive) and wrap matches in
|
|
// a <mark>. Highlighting is done per span, so a match that straddles two spans
|
|
// (e.g. a coloured boundary) is not highlighted — an accepted edge case: the
|
|
// formatter's spans are the colouring unit and re-tokenising them to highlight
|
|
// across boundaries would fight the existing pipeline.
|
|
function highlight(text: string, search: string): ReactNode {
|
|
if (!search) {
|
|
return text;
|
|
}
|
|
const lower = text.toLowerCase();
|
|
const term = search.toLowerCase();
|
|
const parts: ReactNode[] = [];
|
|
let start = 0;
|
|
for (;;) {
|
|
const idx = lower.indexOf(term, start);
|
|
if (idx === -1) {
|
|
break;
|
|
}
|
|
if (idx > start) {
|
|
parts.push(text.substring(start, idx));
|
|
}
|
|
parts.push(
|
|
<mark
|
|
key={idx}
|
|
className="bg-warning-5 text-black th-dark:bg-warning-9 th-dark:text-white th-highcontrast:bg-warning-9 th-highcontrast:text-white"
|
|
>
|
|
{text.substring(idx, idx + term.length)}
|
|
</mark>
|
|
);
|
|
start = idx + term.length;
|
|
}
|
|
if (parts.length === 0) {
|
|
return text;
|
|
}
|
|
if (start < text.length) {
|
|
parts.push(text.substring(start));
|
|
}
|
|
return parts.map((part, i) => <Fragment key={i}>{part}</Fragment>);
|
|
}
|
|
|
|
export function LogLine({ line, lineNumber, showLineNumber, search }: Props) {
|
|
return (
|
|
<div className="log-viewer-line flex">
|
|
{showLineNumber && (
|
|
<span
|
|
// Left gutter (fixed width, right aligned). The muted colour comes
|
|
// from theme tokens so it reads correctly in every theme. Kept as its
|
|
// own class so the layout/tests can target it.
|
|
className="log-viewer-gutter shrink-0 select-none pr-4 text-right text-gray-6 th-dark:text-gray-6 th-highcontrast:text-gray-5"
|
|
style={{ width: '3.25rem' }}
|
|
aria-hidden="true"
|
|
>
|
|
{lineNumber}
|
|
</span>
|
|
)}
|
|
<span className="log-viewer-line-content min-w-0 flex-1">
|
|
{line.spans.length === 0 ? (
|
|
// keep empty lines visible (they still occupy a row)
|
|
' '
|
|
) : (
|
|
line.spans.map((span, index) => (
|
|
<span
|
|
// spans have no stable id; index within a formatted line is stable
|
|
key={index}
|
|
style={{
|
|
color: span.fgColor,
|
|
backgroundColor: span.bgColor,
|
|
fontWeight: span.fontWeight,
|
|
}}
|
|
>
|
|
{highlight(span.text, search)}
|
|
</span>
|
|
))
|
|
)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|