agent-ecosystem/src/renderer/components/chat/searchHighlightUtils.ts
iliya 2ceed41e00 fix: resolve all CI lint errors and flaky test
- Fix React hooks violations: ref updates during render (useDraftPersistence,
  useChipDraftPersistence, useAttachments), setState in effects across 15+
  components, useCallback self-reference TDZ in useResizableColumns
- Fix TypeScript lint: remove unnecessary type assertions, replace inline
  import() annotations with direct imports, remove unused variables/imports
- Fix SonarJS issues: prefer-regexp-exec, slow-regex in SubagentResolver,
  no-misleading-array-reverse in TeamProvisioningService, use-type-alias
  in ClaudeLogsSection, variable shadowing in ChangeExtractorService
- Fix accessibility: associate labels with controls in filter popovers
- Fix template expression safety: wrap unknown errors with String()
- Fix flaky FileWatcher test: floor instanceCreatedAt to second granularity
  to match filesystem birthtimeMs resolution on Linux
- Replace TODO comments with NOTE where features are intentionally disabled
- Remove unused leadContextByTeam from TeamDetailView store selector

62 files changed across main process, renderer, shared types, and hooks.
All 1646 tests pass, typecheck clean, 0 lint errors.
2026-03-05 21:09:45 +02:00

163 lines
5.1 KiB
TypeScript

/**
* Search highlighting utilities for use within ReactMarkdown components.
* Recursively processes React children to highlight search term matches
* while preserving the markdown-rendered element tree.
*/
import React from 'react';
import type { SearchMatch } from '@renderer/store/types';
// Highlight styles matching SearchHighlight.tsx
const baseStyles: React.CSSProperties = {
borderRadius: '0.125rem',
padding: '0 0.125rem',
};
const currentHighlightStyles: React.CSSProperties = {
...baseStyles,
backgroundColor: 'var(--highlight-bg)',
color: 'var(--highlight-text)',
boxShadow: '0 0 0 1px var(--highlight-ring)',
};
const inactiveHighlightStyles: React.CSSProperties = {
...baseStyles,
backgroundColor: 'var(--highlight-bg-inactive)',
color: 'var(--highlight-text-inactive)',
};
export interface SearchContext {
itemId: string;
query: string;
lowerQuery: string;
/** Mutable counter tracking match index within the item, incremented as text nodes are processed */
matchCounter: { current: number };
isCurrentItem: boolean;
currentMatchIndexInItem: number | null;
/** When true, render all matches using the "current" highlight style */
forceAllActive?: boolean;
}
/**
* Create a SearchContext from store state.
* Returns null if no search is active.
*/
export function createSearchContext(
searchQuery: string,
itemId: string,
searchMatches: SearchMatch[],
currentSearchIndex: number
): SearchContext | null {
if (!searchQuery || searchQuery.trim().length === 0) return null;
const currentMatch = currentSearchIndex >= 0 ? searchMatches[currentSearchIndex] : null;
const isCurrentItem = currentMatch?.itemId === itemId;
return {
itemId,
query: searchQuery,
lowerQuery: searchQuery.toLowerCase(),
matchCounter: { current: 0 },
isCurrentItem,
currentMatchIndexInItem: isCurrentItem ? (currentMatch?.matchIndexInItem ?? null) : null,
};
}
/**
* Highlight search term matches in a text string.
* Increments matchCounter for each match found.
*/
// eslint-disable-next-line sonarjs/function-return-type -- mixed text/element return
function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode {
const lowerText = text.toLowerCase();
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let pos = 0;
while ((pos = lowerText.indexOf(ctx.lowerQuery, pos)) !== -1) {
if (pos > lastIndex) {
parts.push(text.slice(lastIndex, pos));
}
const isCurrentResult =
ctx.forceAllActive === true ||
(ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current);
parts.push(
React.createElement(
'mark',
{
key: `s-${pos}-${ctx.matchCounter.current}`,
style: isCurrentResult ? currentHighlightStyles : inactiveHighlightStyles,
'data-search-result': isCurrentResult ? 'current' : 'match',
'data-search-item-id': ctx.itemId,
'data-search-match-index': ctx.matchCounter.current,
},
text.slice(pos, pos + ctx.query.length)
)
);
lastIndex = pos + ctx.query.length;
pos = lastIndex;
ctx.matchCounter.current++;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
if (parts.length === 0) return text;
if (parts.length === 1) return parts[0];
return parts;
}
// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types
export function highlightQueryInText(
text: string,
query: string,
itemId: string,
options?: { forceAllActive?: boolean }
): React.ReactNode {
const ctx = createSearchContext(query, itemId, [], -1);
if (!ctx) return text;
if (options?.forceAllActive) ctx.forceAllActive = true;
return highlightSearchInChildren(text, ctx);
}
/**
* Recursively process React children to highlight search terms in text nodes.
* Preserves the React element tree structure (markdown components, etc.)
* while adding <mark> tags to text content.
*/
// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types
export function highlightSearchInChildren(
children: React.ReactNode,
ctx: SearchContext
): React.ReactNode {
// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types
return React.Children.map(children, (child): React.ReactNode => {
if (typeof child === 'string') {
return highlightSearchText(child, ctx);
}
if (React.isValidElement<{ children?: React.ReactNode }>(child)) {
// Skip <mark> elements already created by search highlighting to prevent
// double-counting when hl() is applied at multiple markdown component levels
// (e.g., both the `strong` and `p` components process the same text)
if (child.type === 'mark' && (child.props as Record<string, unknown>)['data-search-result']) {
return child;
}
if (child.props.children) {
return React.cloneElement(
child,
undefined,
highlightSearchInChildren(child.props.children, ctx)
);
}
}
return child;
});
}