ui: Filesystem @mentions for Chat Form (#26715)
* base : @-mention picker foundation - glob search, picker nav, highlight * feat : @-mention file/folder picker and mention badges in message bubbles * fix: Imports * feat : wire the @-mention picker into the chat form * fix: Bound the glob-search result cache key and prune stale entries
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Shared `file_glob_search` runners with a short-lived result cache, so a
|
||||
* repeated query for the same (type, path, glob, depth) reuses the last
|
||||
* result instead of re-walking the tree.
|
||||
*/
|
||||
|
||||
import { BuiltInTool, GlobSearchType } from '$lib/enums';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import {
|
||||
GLOB_WILDCARD,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
PATH_SEPARATOR,
|
||||
WINDOWS_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
import { lastPathSegment } from './path-display';
|
||||
import {
|
||||
buildGlobSearchArgs,
|
||||
joinPath,
|
||||
rankEntries,
|
||||
type GlobEntry,
|
||||
type GlobSearchArgs
|
||||
} from './working-directory';
|
||||
|
||||
const SEARCH_CACHE_TTL_MS = 2000;
|
||||
|
||||
interface CacheEntry {
|
||||
results: GlobEntry[];
|
||||
base: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const searchCache = new Map<string, CacheEntry>();
|
||||
|
||||
export interface GlobSearchResult {
|
||||
base: string;
|
||||
entries: GlobEntry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function runGlobSearch(
|
||||
args: GlobSearchArgs,
|
||||
type: GlobSearchType,
|
||||
limit: number,
|
||||
signal: AbortSignal
|
||||
): Promise<GlobSearchResult> {
|
||||
const key = `${type}\u0000${args.path}\u0000${args.include}\u0000${args.maxDepth}\u0000${limit}`;
|
||||
const cached = searchCache.get(key);
|
||||
if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
|
||||
return { base: cached.base, entries: cached.results };
|
||||
}
|
||||
|
||||
const res = await ToolsService.executeToolRaw(
|
||||
BuiltInTool.FILE_GLOB_SEARCH,
|
||||
{ path: args.path, type, include: args.include, max_depth: args.maxDepth, limit },
|
||||
signal
|
||||
);
|
||||
|
||||
if (typeof res.error === 'string') return { base: '', entries: [], error: res.error };
|
||||
|
||||
const base = typeof res.base === 'string' ? res.base : '';
|
||||
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
|
||||
const now = Date.now();
|
||||
// prune stale entries so the short-lived cache cannot grow unbounded
|
||||
for (const [k, v] of searchCache) {
|
||||
if (now - v.at >= SEARCH_CACHE_TTL_MS) searchCache.delete(k);
|
||||
}
|
||||
searchCache.set(key, { results: entries, base, at: now });
|
||||
return { base, entries };
|
||||
}
|
||||
|
||||
export interface GlobEntryResult {
|
||||
path: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface GlobSearchChildOptions {
|
||||
type?: GlobSearchType;
|
||||
/** Descend only on a trailing path separator (mention picker); off for
|
||||
* the WD picker, which descends on any exact match. */
|
||||
descendOnTrailingSeparator?: boolean;
|
||||
childMaxDepth?: number;
|
||||
}
|
||||
|
||||
export interface GlobSearchChildResult {
|
||||
base: string;
|
||||
args: GlobSearchArgs;
|
||||
/** Outer ranked entries plus the walked directory's children (absolute). */
|
||||
entries: GlobEntryResult[];
|
||||
/** Absolute path of the directory whose children were appended. */
|
||||
exactDir?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function toEntryResult(e: GlobEntry, base: string): GlobEntryResult {
|
||||
return { path: joinPath(base, e.path), name: lastPathSegment(e.path), type: e.type };
|
||||
}
|
||||
|
||||
/**
|
||||
* One ranked glob search that may also list the matched directory's
|
||||
* children, shared by the WD picker (descend on exact match) and the
|
||||
* mention picker (descend on a trailing `/` or `\`).
|
||||
*/
|
||||
export async function runGlobSearchWithChildren(
|
||||
query: string,
|
||||
scopePath: string,
|
||||
searchDepth: number,
|
||||
limit: number,
|
||||
signal: AbortSignal,
|
||||
options: GlobSearchChildOptions = {}
|
||||
): Promise<GlobSearchChildResult> {
|
||||
const {
|
||||
type = GlobSearchType.ALL,
|
||||
descendOnTrailingSeparator = false,
|
||||
childMaxDepth = PATH_NAV_MAX_DEPTH
|
||||
} = options;
|
||||
|
||||
const args = buildGlobSearchArgs(query, scopePath, searchDepth);
|
||||
const res = await runGlobSearch(args, type, limit, signal);
|
||||
if (res.error) return { base: res.base, args, entries: [], error: res.error };
|
||||
|
||||
const ranked = rankEntries(res.entries, args.rankQuery);
|
||||
const entries = ranked.map((e) => toEntryResult(e, res.base));
|
||||
|
||||
const last = args.last;
|
||||
if (last) {
|
||||
const wantsDescend = descendOnTrailingSeparator
|
||||
? query.endsWith(PATH_SEPARATOR) || query.endsWith(WINDOWS_SEPARATOR)
|
||||
: true;
|
||||
const exact = ranked.find(
|
||||
(e) => e.type === 'dir' && lastPathSegment(e.path).toLowerCase() === last.toLowerCase()
|
||||
);
|
||||
if (wantsDescend && exact) {
|
||||
const exactDir = joinPath(res.base, exact.path);
|
||||
const childRes = await runGlobSearch(
|
||||
{ path: exactDir, include: GLOB_WILDCARD, maxDepth: childMaxDepth, rankQuery: '' },
|
||||
type,
|
||||
limit,
|
||||
signal
|
||||
);
|
||||
if (!childRes.error) {
|
||||
const children = childRes.entries
|
||||
.map((e) => toEntryResult(e, childRes.base))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return { base: res.base, args, entries: [...entries, ...children], exactDir };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { base: res.base, args, entries };
|
||||
}
|
||||
@@ -174,13 +174,47 @@ export {
|
||||
export {
|
||||
splitPathQuery,
|
||||
buildCaseInsensitiveGlob,
|
||||
buildGlobSearchArgs,
|
||||
rankEntries,
|
||||
joinPath,
|
||||
highlightMatch,
|
||||
type GlobEntry,
|
||||
type GlobSearchArgs,
|
||||
type PathQuery
|
||||
} from './working-directory';
|
||||
|
||||
// Shared `file_glob_search` runner with a short-lived result cache
|
||||
export {
|
||||
runGlobSearch,
|
||||
runGlobSearchWithChildren,
|
||||
type GlobEntryResult,
|
||||
type GlobSearchResult
|
||||
} from './glob-search';
|
||||
|
||||
// Mention-token detection (for the `@`-triggered file/folder mention picker)
|
||||
export {
|
||||
findMentionToken,
|
||||
takeMentionDismissSnapshot,
|
||||
type MentionDismissSnapshot
|
||||
} from './mention-token';
|
||||
|
||||
// Mention-chip visual contract shared by the rehype file-badge plugin,
|
||||
// plus the `[name](file://...)` link helpers the mention picker splices in
|
||||
export {
|
||||
fileMentionLinkRe,
|
||||
encodeFileLinkPath,
|
||||
decodeFileLinkPath,
|
||||
MENTION_BADGE_CLASSNAME,
|
||||
MENTION_BADGE_ICON_CLASSNAME,
|
||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS,
|
||||
getMentionBadgeIconPaths,
|
||||
getMentionBadgeLabel,
|
||||
buildMentionInsertion,
|
||||
mentionLinkEndingAt
|
||||
} from './mention-badge';
|
||||
|
||||
// Agentic content utilities (structured section derivation)
|
||||
export {
|
||||
deriveAgenticSections,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { abbreviateHome, lastPathSegment } from './path-display';
|
||||
import {
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS
|
||||
} from '$lib/constants/mention-badge';
|
||||
import { FILE_URI_PREFIX } from '$lib/constants';
|
||||
import { FileMentionEntryType } from '$lib/enums';
|
||||
import type { FileMentionEntry } from '$lib/types';
|
||||
|
||||
export {
|
||||
MENTION_BADGE_CLASSNAME,
|
||||
MENTION_BADGE_ICON_CLASSNAME,
|
||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS
|
||||
} from '$lib/constants/mention-badge';
|
||||
|
||||
// `)` is allowed in a path only when not followed by whitespace or `[`,
|
||||
// so macOS paths parse while adjacent badges still terminate the match.
|
||||
const FILE_MENTION_LINK_SOURCE = String.raw`\[([^\]\n]+?)\]\(file:\/\/((?:[^)\n]|\)(?![\s[]))+)\)`;
|
||||
|
||||
export function fileMentionLinkRe(flags = ''): RegExp {
|
||||
return new RegExp(FILE_MENTION_LINK_SOURCE, flags);
|
||||
}
|
||||
|
||||
// Escape each path segment for a markdown link destination (spaces/parens
|
||||
// break CommonMark); keeps the trailing slash that marks a directory.
|
||||
export function encodeFileLinkPath(path: string): string {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
// Malformed escape sequences fall back to the input unchanged.
|
||||
export function decodeFileLinkPath(path: string): string {
|
||||
try {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => decodeURIComponent(segment))
|
||||
.join('/');
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMentionBadgeIconPaths(path: string): readonly string[] {
|
||||
return path.endsWith('/') ? MENTION_BADGE_FOLDER_ICON_PATHS : MENTION_BADGE_FILE_ICON_PATHS;
|
||||
}
|
||||
|
||||
export function getMentionBadgeLabel(
|
||||
name: string,
|
||||
path: string,
|
||||
showFullPath: boolean,
|
||||
home?: string | null
|
||||
): string {
|
||||
if (!showFullPath) return name;
|
||||
const decoded = decodeFileLinkPath(path.replace(/\/+$/, ''));
|
||||
if (!decoded) return name;
|
||||
return abbreviateHome(decoded, home);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extent of the mention link ending exactly at `caret`, so Backspace there
|
||||
* deletes the whole `[name](file://...)` token in one keystroke instead of
|
||||
* unraveling it character by character. Null when no link ends at `caret`.
|
||||
*/
|
||||
export function mentionLinkEndingAt(
|
||||
value: string,
|
||||
caret: number
|
||||
): { start: number; end: number } | null {
|
||||
const re = fileMentionLinkRe('g');
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(value)) !== null) {
|
||||
const end = match.index + match[0].length;
|
||||
if (end === caret) return { start: match.index, end };
|
||||
if (end > caret) break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the markdown link that replaces a mention token. Entry `path` is
|
||||
* already rooted, so `file://` + `/abs` yields the canonical `file:///`.
|
||||
* Null when the token is invalid.
|
||||
*/
|
||||
export function buildMentionInsertion(
|
||||
entry: FileMentionEntry,
|
||||
value: string,
|
||||
token: { start: number; end: number }
|
||||
): { newValue: string; caretOffset: number } | null {
|
||||
if (token.start < 0 || token.end > value.length || token.start > token.end) return null;
|
||||
// Strip the entry's directory marker so it is not doubled below.
|
||||
const cleanedPath = entry.path.replace(/\/+$/, '');
|
||||
const pathWithSeparator =
|
||||
entry.type === FileMentionEntryType.DIRECTORY ? `${cleanedPath}/` : cleanedPath;
|
||||
const basename = lastPathSegment(cleanedPath) || entry.name;
|
||||
const insertion = `[${basename}](${FILE_URI_PREFIX}${encodeFileLinkPath(pathWithSeparator)}) `;
|
||||
const newValue = value.slice(0, token.start) + insertion + value.slice(token.end);
|
||||
return { newValue, caretOffset: token.start + insertion.length };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// An `@` starts a mention only when preceded by start-of-string or one of
|
||||
// these; identifier chars are not delimiters, so a mid-word `@` does not.
|
||||
const TOKEN_BOUNDARY_CHARS = new Set([
|
||||
' ',
|
||||
'\t',
|
||||
'\n',
|
||||
'\r',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
',',
|
||||
';',
|
||||
':',
|
||||
'"',
|
||||
"'"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Find the most-recent `@`-mention token whose extent includes `cursor`;
|
||||
* the query covers the whole `@...` token regardless of caret position.
|
||||
*/
|
||||
export function findMentionToken(
|
||||
value: string,
|
||||
cursor: number
|
||||
): { start: number; end: number; query: string } | null {
|
||||
if (cursor <= 0 || cursor > value.length) return null;
|
||||
|
||||
let atIndex = -1;
|
||||
for (let i = cursor - 1; i >= 0; i--) {
|
||||
const ch = value[i];
|
||||
if (ch === '@') {
|
||||
const prev = i > 0 ? value[i - 1] : '';
|
||||
if (i === 0 || TOKEN_BOUNDARY_CHARS.has(prev)) {
|
||||
atIndex = i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (TOKEN_BOUNDARY_CHARS.has(ch)) break;
|
||||
}
|
||||
|
||||
if (atIndex === -1) return null;
|
||||
|
||||
let end = atIndex + 1;
|
||||
while (end < value.length && !TOKEN_BOUNDARY_CHARS.has(value[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
return {
|
||||
start: atIndex,
|
||||
end,
|
||||
query: value.slice(atIndex + 1, end)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of a mention token for use as a "dismissed" marker:
|
||||
* while the picker is closed and this exact token is still intact, the
|
||||
* picker does not silently re-open on in-token edits.
|
||||
*/
|
||||
export interface MentionDismissSnapshot {
|
||||
start: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export function takeMentionDismissSnapshot(
|
||||
value: string,
|
||||
cursor: number
|
||||
): MentionDismissSnapshot | null {
|
||||
const token = findMentionToken(value, cursor);
|
||||
if (!token) return null;
|
||||
return { start: token.start, query: token.query };
|
||||
}
|
||||
@@ -9,22 +9,14 @@ import {
|
||||
HOME_TILDE_PREFIX
|
||||
} from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Last non-empty slash-delimited segment of `path`, with trailing
|
||||
* slashes stripped. Returns the input unchanged when no `/` is present.
|
||||
*/
|
||||
export function lastPathSegment(p: string): string {
|
||||
const trimmed = p.replace(TRAILING_SLASHES_REGEX, '');
|
||||
const idx = trimmed.lastIndexOf(PATH_SEPARATOR);
|
||||
return idx === -1 ? trimmed : trimmed.slice(idx + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abbreviate `path` to `~/...` when it sits under `home`, or to `~` when
|
||||
* it equals `home`. Falls back to `lastPathSegment(path)` when home is
|
||||
* unknown or the path is outside it. `~` semantics are reserved for the
|
||||
* home directory, mirroring how shells render it.
|
||||
*/
|
||||
// `~/...` under `home`; falls back to the basename when home is unknown
|
||||
// or the path is outside it.
|
||||
export function abbreviateWorkingDir(
|
||||
path: string | null | undefined,
|
||||
home: string | null | undefined
|
||||
@@ -37,12 +29,8 @@ export function abbreviateWorkingDir(
|
||||
return lastPathSegment(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a leading `home` prefix in `path` with `~`. Unlike
|
||||
* abbreviateWorkingDir, paths outside `home` (or an unknown home) are
|
||||
* returned unchanged - used for tool-call path displays where the full
|
||||
* path matters.
|
||||
*/
|
||||
// Unlike abbreviateWorkingDir, paths outside `home` are returned
|
||||
// unchanged - used where the full path matters.
|
||||
export function abbreviateHome(path: string, home: string | null | undefined): string {
|
||||
if (!home) return path;
|
||||
if (path === home) return HOME_TILDE;
|
||||
@@ -61,10 +49,9 @@ export interface CwdMessageInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a synthetic cwd-change message. The text mirrors what the UI
|
||||
* renders for it; the path travels as `[file:///abs/path](display)` so
|
||||
* both the absolute and the short form are visible to the model and
|
||||
* parseable back by the UI.
|
||||
* Format a synthetic cwd-change message. The path travels as
|
||||
* `[file:///abs/path](display)` so both the absolute and short form are
|
||||
* visible to the model and parseable back by the UI.
|
||||
*/
|
||||
export function formatCwdMessage(cwd: string, home: string | null): string {
|
||||
const display = abbreviateWorkingDir(cwd, home);
|
||||
@@ -72,10 +59,9 @@ export function formatCwdMessage(cwd: string, home: string | null): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a synthetic cwd message back into its parts. The caller must already
|
||||
* know the message is synthetic (via the persisted `isSynthetic` flag); this
|
||||
* only extracts the path from the message text. Returns null when `content`
|
||||
* is not a cwd message.
|
||||
* Parse a synthetic cwd message back into its parts. The caller must
|
||||
* already know the message is synthetic (via the persisted `isSynthetic`
|
||||
* flag); this only extracts the path.
|
||||
*/
|
||||
export function parseCwdMessage(content: string): CwdMessageInfo | null {
|
||||
const trimmed = content.trim();
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* Pure helpers for the working-directory picker search.
|
||||
*
|
||||
* The picker is backed by the server's `file_glob_search` built-in tool.
|
||||
* Queries that start from a root (`/`, `C:\`, `\\host\share`) or from `~`
|
||||
* navigate the directory tree (search the parent for the last segment);
|
||||
* anything else glob-matches home-relative entries. Paths are carried with
|
||||
* `/` separators, which is what the server returns and what Windows accepts.
|
||||
* These helpers build the glob, normalize results and rank them
|
||||
* client-side; the component owns the network/state plumbing.
|
||||
* Pure helpers for the working-directory picker search, backed by the
|
||||
* server's `file_glob_search` tool. Queries starting from a root (`/`,
|
||||
* `C:\`, `\\host\share`) or `~` navigate the tree (search the parent for
|
||||
* the last segment); anything else glob-matches home-relative entries.
|
||||
*/
|
||||
|
||||
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
|
||||
@@ -21,6 +16,7 @@ import {
|
||||
GLOB_WILDCARD,
|
||||
HOME_TILDE,
|
||||
LEADING_SLASHES_REGEX,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
UNC_ROOT_REGEX,
|
||||
WINDOWS_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
@@ -45,10 +41,6 @@ function toPosixSeparators(query: string): string {
|
||||
return query.split(WINDOWS_SEPARATOR).join(PATH_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of the root prefix of `path`, or 0 when it has none. Covers the
|
||||
* POSIX root, a Windows drive (`C:/`) and a UNC share (`//host/share/`).
|
||||
*/
|
||||
export function rootPrefixLength(path: string): number {
|
||||
const unc = path.match(UNC_ROOT_REGEX);
|
||||
if (unc) return unc[0].length;
|
||||
@@ -84,7 +76,6 @@ export function splitPathQuery(query: string): PathQuery | null {
|
||||
return { parent: parentOf(rest.slice(0, idx)), last: rest.slice(idx + 1) };
|
||||
}
|
||||
|
||||
/** Build a case-insensitive glob that matches `query` anywhere within a name. */
|
||||
export function buildCaseInsensitiveGlob(query: string): string {
|
||||
let out = GLOB_WILDCARD;
|
||||
for (const c of query) {
|
||||
@@ -99,7 +90,33 @@ export function buildCaseInsensitiveGlob(query: string): string {
|
||||
return out + GLOB_WILDCARD;
|
||||
}
|
||||
|
||||
/** Exact basename first, then prefix, then substring; lower is better. */
|
||||
export interface GlobSearchArgs {
|
||||
path: string;
|
||||
include: string;
|
||||
maxDepth: number;
|
||||
rankQuery: string;
|
||||
/** Last segment of a path-navigation query (`~/dir/sub`), undefined for
|
||||
* a plain home-relative glob. Lets callers act on the exact targeted
|
||||
* segment (e.g. the WD picker "entering" a directory). */
|
||||
last?: string;
|
||||
}
|
||||
|
||||
export function buildGlobSearchArgs(
|
||||
query: string,
|
||||
scopePath: string,
|
||||
searchDepth: number
|
||||
): GlobSearchArgs {
|
||||
const pathQuery = splitPathQuery(query);
|
||||
const path = pathQuery ? pathQuery.parent : scopePath;
|
||||
const include = pathQuery
|
||||
? pathQuery.last
|
||||
? buildCaseInsensitiveGlob(pathQuery.last)
|
||||
: GLOB_WILDCARD
|
||||
: buildCaseInsensitiveGlob(query);
|
||||
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : searchDepth;
|
||||
return { path, include, maxDepth, rankQuery: pathQuery?.last ?? query, last: pathQuery?.last };
|
||||
}
|
||||
|
||||
const RANK_EXACT = 0;
|
||||
const RANK_PREFIX = 1;
|
||||
const RANK_SUBSTRING = 2;
|
||||
@@ -114,7 +131,6 @@ function rankScore(path: string, query: string): number {
|
||||
return RANK_OTHER;
|
||||
}
|
||||
|
||||
/** Sort entries by relevance, then shorter path, then alphabetically. */
|
||||
export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
|
||||
return [...entries].sort(
|
||||
(a, b) =>
|
||||
@@ -124,13 +140,11 @@ export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
|
||||
);
|
||||
}
|
||||
|
||||
/** Join a base path and a relative segment, avoiding duplicate slashes. */
|
||||
export function joinPath(base: string, rel: string): string {
|
||||
if (!base) return rel;
|
||||
return base.replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR + rel;
|
||||
}
|
||||
|
||||
/** Split `text` into alternating segments at each case-insensitive `query` match. */
|
||||
export function highlightMatch(text: string, query: string): { text: string; match: boolean }[] {
|
||||
if (!query) return [{ text, match: false }];
|
||||
const segments: { text: string; match: boolean }[] = [];
|
||||
|
||||
Reference in New Issue
Block a user