diff --git a/web/package.json b/web/package.json
index 41409019e..278245aa4 100644
--- a/web/package.json
+++ b/web/package.json
@@ -30,7 +30,6 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0",
"react-syntax-highlighter": "^16.1.1",
- "regexify-string": "^1.0.17",
"rehype-github-alerts": "^4.2.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
diff --git a/web/src/layout/common/SearchRepositories.test.tsx b/web/src/layout/common/SearchRepositories.test.tsx
index 76b2aaf7a..2e5742f78 100644
--- a/web/src/layout/common/SearchRepositories.test.tsx
+++ b/web/src/layout/common/SearchRepositories.test.tsx
@@ -42,7 +42,7 @@ describe('SearchRepositories', () => {
const mockSearch = getMockSearch('1');
vi.mocked(API).searchRepositories.mockResolvedValue(mockSearch);
- render();
+ const { container } = render();
const input = screen.getByRole('textbox', { name: 'Search repositories' });
expect(input).toBeInTheDocument();
@@ -55,6 +55,9 @@ describe('SearchRepositories', () => {
});
expect(await screen.findAllByRole('button')).toHaveLength(3);
+ const highlightedMatches = container.querySelectorAll('.highlighted');
+ expect(highlightedMatches).toHaveLength(1);
+ expect(highlightedMatches[0]).toHaveTextContent('sec');
});
it('selects repo', async () => {
diff --git a/web/src/layout/common/SearchRepositories.tsx b/web/src/layout/common/SearchRepositories.tsx
index cd644800b..0d890d9d9 100644
--- a/web/src/layout/common/SearchRepositories.tsx
+++ b/web/src/layout/common/SearchRepositories.tsx
@@ -6,12 +6,12 @@ import { ChangeEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { FaUser } from 'react-icons/fa';
import { FiSearch } from 'react-icons/fi';
import { MdBusiness } from 'react-icons/md';
-import regexifyString from 'regexify-string';
import API from '../../api';
import useOutsideClick from '../../hooks/useOutsideClick';
import { ErrorKind, Repository, SearchQuery } from '../../types';
import alertDispatcher from '../../utils/alertDispatcher';
+import regexifyString from '../../utils/regexifyString';
import Loading from './Loading';
import RepositoryIcon from './RepositoryIcon';
import styles from './SearchRepositories.module.css';
@@ -162,6 +162,21 @@ const SearchRepositories = (props: Props) => {
}
};
+ // Highlights the typed search term in repository names.
+ const getHighlightedRepositoryName = (repositoryName: string) => {
+ return regexifyString({
+ pattern: new RegExp(escapeRegExp(searchName), 'gi'),
+ decorator: (match: string, index: number) => {
+ return (
+
+ {match}
+
+ );
+ },
+ input: repositoryName,
+ });
+ };
+
const cleanTimeout = () => {
if (!isNull(dropdownTimeout)) {
clearTimeout(dropdownTimeout);
@@ -298,23 +313,7 @@ const SearchRepositories = (props: Props) => {
- {searchName === '' ? (
- <>{item.name}>
- ) : (
- <>
- {regexifyString({
- pattern: new RegExp(escapeRegExp(searchName), 'gi'),
- decorator: (match: string, index: number) => {
- return (
-
- {match}
-
- );
- },
- input: item.name,
- })}
- >
- )}
+ {searchName === '' ? <>{item.name}> : <>{getHighlightedRepositoryName(item.name)}>}
|
diff --git a/web/src/layout/package/chartTemplates/Template.tsx b/web/src/layout/package/chartTemplates/Template.tsx
index 5294bb8a0..50b9bda68 100644
--- a/web/src/layout/package/chartTemplates/Template.tsx
+++ b/web/src/layout/package/chartTemplates/Template.tsx
@@ -6,11 +6,11 @@ import isObject from 'lodash/isObject';
import isString from 'lodash/isString';
import isUndefined from 'lodash/isUndefined';
import { Dispatch, Fragment, memo, SetStateAction, useContext, useEffect, useState } from 'react';
-import * as regexifyStringModule from 'regexify-string';
import { AppCtx } from '../../../context/AppCtx';
import { ChartTemplate, ChartTemplateSpecialType, DefinedTemplate, DefinedTemplatesList } from '../../../types';
import processHelmTemplate from '../../../utils/processHelmTemplate';
+import regexifyString from '../../../utils/regexifyString';
import AutoresizeTextarea from '../../common/AutoresizeTextarea';
import builtInDefinitions from './builtIn.json';
import functionsDefinitions from './functions.json';
@@ -32,7 +32,6 @@ const SPECIAL_CHARACTERS = /[^|({})-]+/;
const TOKENIZE_RE = /[^\s"']+|"([^"]*)"|'([^']*)/g;
const INITIAL_HELPER_COMMENT = /{{\/\*|{{- \/\*/;
const FINAL_HELPER_COMMENT = /\*\/}}|\*\/ -}}$/;
-const regexifyString = regexifyStringModule.default;
const Template = (props: Props) => {
const { ctx } = useContext(AppCtx);
diff --git a/web/src/utils/regexifyString.test.tsx b/web/src/utils/regexifyString.test.tsx
new file mode 100644
index 000000000..9dd76867f
--- /dev/null
+++ b/web/src/utils/regexifyString.test.tsx
@@ -0,0 +1,38 @@
+import { render, screen } from '@testing-library/react';
+
+import regexifyString from './regexifyString';
+
+describe('regexifyString', () => {
+ it('decorates matches', () => {
+ render(
+ <>
+ {regexifyString({
+ pattern: /hub/gi,
+ decorator: (match, index) => {
+ return {match};
+ },
+ input: 'artifact-hub security-hub',
+ })}
+ >
+ );
+
+ expect(screen.getAllByText('hub')).toHaveLength(2);
+ });
+
+ it('passes the match result to the decorator', () => {
+ render(
+ <>
+ {regexifyString({
+ pattern: /repo-(\d+)/,
+ decorator: (_match, _index, result) => {
+ return {result[1]};
+ },
+ input: 'repo-1 repo-2',
+ })}
+ >
+ );
+
+ expect(screen.getByText('1')).toBeInTheDocument();
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+});
diff --git a/web/src/utils/regexifyString.ts b/web/src/utils/regexifyString.ts
new file mode 100644
index 000000000..bf76befa7
--- /dev/null
+++ b/web/src/utils/regexifyString.ts
@@ -0,0 +1,45 @@
+import type { ReactNode } from 'react';
+
+interface RegexifyStringProps {
+ pattern: RegExp;
+ decorator: (match: string, index: number, result: RegExpExecArray) => ReactNode;
+ input: string;
+}
+
+/**
+ * Splits text by a regex and decorates each match for React rendering.
+ */
+const regexifyString = (props: RegexifyStringProps): ReactNode[] => {
+ const { pattern, decorator, input } = props;
+ // Work on a global copy so callers can pass reusable regex constants.
+ const patternFlags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
+ const globalPattern = new RegExp(pattern.source, patternFlags);
+ const output: ReactNode[] = [];
+ let match = globalPattern.exec(input);
+ let matchIndex = 0;
+ let previousMatchEnd = 0;
+
+ while (match !== null) {
+ const matchStart = match.index;
+ const matchValue = match[0];
+ // Preserve text between matches so React can render the original input.
+ output.push(input.substring(previousMatchEnd, matchStart));
+ output.push(decorator(matchValue, matchIndex, match));
+
+ // Avoid infinite loops when a regex can match an empty string.
+ if (matchValue.length === 0) {
+ globalPattern.lastIndex += 1;
+ }
+ previousMatchEnd = globalPattern.lastIndex;
+ matchIndex += 1;
+ match = globalPattern.exec(input);
+ }
+
+ if (previousMatchEnd < input.length) {
+ output.push(input.substring(previousMatchEnd));
+ }
+
+ return output;
+};
+
+export default regexifyString;
diff --git a/web/yarn.lock b/web/yarn.lock
index 231bfdb4d..c7019baea 100644
--- a/web/yarn.lock
+++ b/web/yarn.lock
@@ -4591,11 +4591,6 @@ refractor@^5.0.0:
hastscript "^9.0.0"
parse-entities "^4.0.0"
-regexify-string@^1.0.17:
- version "1.0.19"
- resolved "https://registry.yarnpkg.com/regexify-string/-/regexify-string-1.0.19.tgz#74fab49b348b8faffbd948218ea3a09e25303010"
- integrity sha512-EREOggl31J6v2Hk3ksPuOof0DMq5QhFfVQ7iDaGQ6BeA1QcrV4rhGvwCES5a72ITMmLBDAOb6cOWbn8/Ja82Ig==
-
rehype-github-alerts@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/rehype-github-alerts/-/rehype-github-alerts-4.2.0.tgz#876f2a5aefcb5f80637077490dc65b14183b1397"