Files
portainer/app/react/components/LogViewer/LogLine.tsx
T
agent_coder 8829c8cfd2 fix(#6): theme-consistent search highlight + Copied ack on Copy button
- 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>
2026-07-01 21:46:55 +03:00

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>
);
}