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:
@@ -15,8 +15,7 @@
|
||||
SETTING_CONFIG_DEFAULT,
|
||||
INITIAL_FILE_SIZE,
|
||||
PROMPT_CONTENT_SEPARATOR,
|
||||
PROMPT_TRIGGER_PREFIX,
|
||||
RESOURCE_TRIGGER_PREFIX
|
||||
PROMPT_TRIGGER_PREFIX
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
ContentPartType,
|
||||
@@ -39,8 +38,23 @@
|
||||
activeConversation,
|
||||
pendingCwd
|
||||
} from '$lib/stores/conversations.svelte';
|
||||
import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
|
||||
import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
|
||||
import type {
|
||||
FileMentionEntry,
|
||||
GetPromptResult,
|
||||
MCPPromptInfo,
|
||||
MCPResourceInfo,
|
||||
PromptMessage
|
||||
} from '$lib/types';
|
||||
import {
|
||||
buildMentionInsertion,
|
||||
findMentionToken,
|
||||
isIMEComposing,
|
||||
mentionLinkEndingAt,
|
||||
parseClipboardContent,
|
||||
takeMentionDismissSnapshot,
|
||||
type MentionDismissSnapshot,
|
||||
uuid
|
||||
} from '$lib/utils';
|
||||
import {
|
||||
AudioRecorder,
|
||||
convertToWav,
|
||||
@@ -108,11 +122,18 @@
|
||||
let isRecording = $state(false);
|
||||
let recordingSupported = $state(false);
|
||||
|
||||
// Invisible anchor at the form's top edge so the mention popover floats above the box.
|
||||
let mentionAnchor: HTMLDivElement | null = $state(null);
|
||||
|
||||
// Picker State
|
||||
let isPromptPickerOpen = $state(false);
|
||||
let promptSearchQuery = $state('');
|
||||
let isInlineResourcePickerOpen = $state(false);
|
||||
let resourceSearchQuery = $state('');
|
||||
let isMentionPickerOpen = $state(false);
|
||||
let mentionQuery = $state('');
|
||||
|
||||
// Last dismissed `@`-mention token; while intact the picker does not
|
||||
// reopen, so an escaped `@<query>` stays literal until edited.
|
||||
let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
|
||||
|
||||
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
|
||||
|
||||
@@ -219,26 +240,44 @@
|
||||
function handleInput() {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
|
||||
const cursor = textareaRef?.getCaretOffset() ?? value.length;
|
||||
const mentionToken = findMentionToken(value, cursor);
|
||||
|
||||
// A `@` mention takes precedence; typing one switches from any other open picker.
|
||||
if (mentionToken && mentionToken.query.length > 0) {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
|
||||
const isDismissedSticky =
|
||||
mentionDismissedSnapshot !== null &&
|
||||
mentionDismissedSnapshot.start === mentionToken.start &&
|
||||
mentionDismissedSnapshot.query === mentionToken.query;
|
||||
|
||||
if (!isDismissedSticky) {
|
||||
mentionDismissedSnapshot = null;
|
||||
isMentionPickerOpen = true;
|
||||
mentionQuery = mentionToken.query;
|
||||
return;
|
||||
}
|
||||
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
// Token gone or changed: reset the snapshot so a fresh `@` reopens.
|
||||
if (mentionDismissedSnapshot !== null && !mentionToken) {
|
||||
mentionDismissedSnapshot = null;
|
||||
}
|
||||
|
||||
if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) {
|
||||
isPromptPickerOpen = true;
|
||||
promptSearchQuery = value.slice(1);
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
} else if (
|
||||
value.startsWith(RESOURCE_TRIGGER_PREFIX) &&
|
||||
hasServers &&
|
||||
mcpStore.hasResourcesCapability(perChatOverrides)
|
||||
) {
|
||||
isInlineResourcePickerOpen = true;
|
||||
resourceSearchQuery = value.slice(1);
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
} else {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,15 +286,30 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace at a mention link's end deletes the whole token at once.
|
||||
if (event.key === KeyboardKey.BACKSPACE && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
const el = textareaRef?.getElement();
|
||||
if (el instanceof HTMLTextAreaElement && el.selectionStart === el.selectionEnd) {
|
||||
const link = mentionLinkEndingAt(value, el.selectionStart);
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
value = value.slice(0, link.start) + value.slice(link.end);
|
||||
onValueChange?.(value);
|
||||
queueMicrotask(() => textareaRef?.setCaretOffset(link.start));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE && isInlineResourcePickerOpen) {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
if (event.key === KeyboardKey.ESCAPE && isMentionPickerOpen) {
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -432,33 +486,33 @@
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
function handleInlineResourcePickerClose() {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
textareaRef?.focus();
|
||||
function handleMentionPickerClose() {
|
||||
if (isMentionPickerOpen) {
|
||||
const cursor = textareaRef?.getCaretOffset() ?? value.length;
|
||||
mentionDismissedSnapshot = takeMentionDismissSnapshot(value, cursor);
|
||||
}
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
refocusInput();
|
||||
}
|
||||
|
||||
function handleInlineResourceSelect() {
|
||||
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
// Splice the `[name](file:///<abs path>)` link in place of the `@<query>`
|
||||
// token, restoring the caret after the bindable value settles.
|
||||
function handleMentionSelect(entry: FileMentionEntry) {
|
||||
const cursor = textareaRef?.getCaretOffset() ?? value.length;
|
||||
const token = findMentionToken(value, cursor);
|
||||
if (!token) return;
|
||||
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
textareaRef?.focus();
|
||||
}
|
||||
const built = buildMentionInsertion(entry, value, token);
|
||||
if (!built) return;
|
||||
|
||||
function handleBrowseResources() {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
value = built.newValue;
|
||||
onValueChange?.(built.newValue);
|
||||
|
||||
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
|
||||
isResourceDialogOpen = true;
|
||||
queueMicrotask(() => {
|
||||
textareaRef?.focus();
|
||||
textareaRef?.setCaretOffset(built.caretOffset);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMicClick() {
|
||||
@@ -505,17 +559,25 @@
|
||||
bind:this={pickersRef}
|
||||
{isPromptPickerOpen}
|
||||
{promptSearchQuery}
|
||||
{isInlineResourcePickerOpen}
|
||||
{resourceSearchQuery}
|
||||
{isMentionPickerOpen}
|
||||
{mentionQuery}
|
||||
{mentionAnchor}
|
||||
scopePath={cwd}
|
||||
onPromptPickerClose={handlePromptPickerClose}
|
||||
onInlineResourcePickerClose={handleInlineResourcePickerClose}
|
||||
onInlineResourceSelect={handleInlineResourceSelect}
|
||||
onMentionPickerClose={handleMentionPickerClose}
|
||||
onMentionOpened={() => textareaRef?.focus()}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
onPromptLoadStart={handlePromptLoadStart}
|
||||
onPromptLoadComplete={handlePromptLoadComplete}
|
||||
onPromptLoadError={handlePromptLoadError}
|
||||
onInlineResourceBrowse={handleBrowseResources}
|
||||
/>
|
||||
|
||||
<div
|
||||
bind:this={mentionAnchor}
|
||||
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="{INPUT_CLASSES} overflow-hidden rounded-4xl md:rounded-3xl backdrop-blur-md {disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
<script lang="ts">
|
||||
import { File, Folder } from '@lucide/svelte';
|
||||
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { BuiltInTool, FileMentionEntryType, GlobSearchType } from '$lib/enums';
|
||||
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
|
||||
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
|
||||
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import type { FileMentionEntry } from '$lib/types';
|
||||
import {
|
||||
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
|
||||
HOME_TILDE,
|
||||
SEARCH_DEBOUNCE_MS
|
||||
} from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Floating file/folder mention picker. The chat input is the search
|
||||
* surface: `query` (typed after `@`) drives a `file_glob_search` tool
|
||||
* call scoped to `scopePath`. The parent owns the "dismissed token,
|
||||
* don't re-open until it changes" snapshot.
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
customAnchor?: HTMLElement | null;
|
||||
scopePath?: string | null;
|
||||
onClose: () => void;
|
||||
onSelect: (entry: FileMentionEntry) => void;
|
||||
/** Fired when `isOpen` becomes true, so the host can keep focus on the chat input. */
|
||||
onOpened?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isOpen,
|
||||
query,
|
||||
customAnchor = null,
|
||||
scopePath = null,
|
||||
onClose,
|
||||
onSelect,
|
||||
onOpened
|
||||
}: Props = $props();
|
||||
|
||||
const nav = usePickerNavigation({
|
||||
isOpen: () => isOpen,
|
||||
count: () => displayedItems.length,
|
||||
onClose: () => onClose(),
|
||||
onSelect: (index) => handleSelect(displayedItems[index])
|
||||
});
|
||||
|
||||
// When the server does not expose file_glob_search (started without
|
||||
// --tools) or the user disabled it, the picker still opens but explains
|
||||
// why instead of firing searches that would only fail.
|
||||
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH));
|
||||
const fileSearchEnabled = $derived(
|
||||
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
|
||||
);
|
||||
|
||||
let searchResults = $state<FileMentionEntry[]>([]);
|
||||
let searchError = $state<string | null>(null);
|
||||
|
||||
// Coerce the depth setting to a positive integer; an invalid value
|
||||
// would otherwise reach the server as max_depth 0 = unlimited.
|
||||
const searchDepth = $derived.by(() => {
|
||||
const n = Number(config().mentionSearchMaxDepth);
|
||||
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH;
|
||||
});
|
||||
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
|
||||
// A smaller window than the WD picker suffices: entries are ranked client-side.
|
||||
const MENTION_SEARCH_LIMIT = 50;
|
||||
|
||||
const search = useDebouncedSearch({
|
||||
debounceMs: SEARCH_DEBOUNCE_MS,
|
||||
canRun: () => isOpen && fileSearchEnabled,
|
||||
getQuery: () => trimmedQuery,
|
||||
run: async (query, signal, isCurrent) => {
|
||||
try {
|
||||
// A trailing path separator targets a directory, so also list its
|
||||
// children. Accept both `/` and `\`.
|
||||
const res = await runGlobSearchWithChildren(
|
||||
query,
|
||||
scopePath ?? home ?? HOME_TILDE,
|
||||
searchDepth,
|
||||
MENTION_SEARCH_LIMIT,
|
||||
signal,
|
||||
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
|
||||
);
|
||||
if (!isCurrent()) return;
|
||||
if (res.error) {
|
||||
searchResults = [];
|
||||
searchError = res.error;
|
||||
return;
|
||||
}
|
||||
const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
|
||||
path: e.path,
|
||||
name: e.name,
|
||||
type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
|
||||
});
|
||||
searchResults = res.entries.map(toEntry);
|
||||
searchError = null;
|
||||
} catch (err) {
|
||||
if (!isCurrent() || signal.aborted) return;
|
||||
searchResults = [];
|
||||
searchError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const trimmedQuery = $derived((query ?? '').trim());
|
||||
const displayedItems = $derived(searchResults);
|
||||
|
||||
const emptyMessage = $derived.by(() => {
|
||||
if (fileSearchKey === null) {
|
||||
return 'File search is unavailable on this server (started without --tools)';
|
||||
}
|
||||
if (!fileSearchEnabled) {
|
||||
return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
|
||||
}
|
||||
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
|
||||
});
|
||||
|
||||
const showTooltip = $derived(!isMobile.current);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
void toolsStore.resolveServerHome();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
nav.reset(0);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) onOpened?.();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const q = (query ?? '').trim();
|
||||
if (!isOpen || !q || !fileSearchEnabled) {
|
||||
search.cancel();
|
||||
searchResults = [];
|
||||
searchError = null;
|
||||
return;
|
||||
}
|
||||
search.setLoading(true);
|
||||
search.run(q);
|
||||
});
|
||||
|
||||
function handleSelect(entry: FileMentionEntry) {
|
||||
onSelect(entry);
|
||||
onClose();
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
return nav.handleKeydown(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
|
||||
from closing the picker when the user clicks inside the textarea.
|
||||
We open programmatically via `open={isOpen}`, so it is inert
|
||||
(tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
|
||||
Positioning comes from `customAnchor` at the form's top edge. -->
|
||||
<Popover.Trigger
|
||||
class="pointer-events-none absolute inset-0 opacity-0"
|
||||
tabindex={-1}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="sr-only">Open file mention picker</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={12}
|
||||
{customAnchor}
|
||||
preventScroll={false}
|
||||
onkeydown={handleKeydown}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
class={[
|
||||
'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
|
||||
className
|
||||
]}
|
||||
>
|
||||
<ChatFormPickerList
|
||||
items={displayedItems}
|
||||
isLoading={search.isSearching}
|
||||
selectedIndex={nav.hoveredIndex}
|
||||
showSearchInput={false}
|
||||
searchQuery={query ?? ''}
|
||||
{emptyMessage}
|
||||
itemKey={(entry) => entry.type + ':' + entry.path}
|
||||
scrollTrigger={nav.scrollTrigger}
|
||||
>
|
||||
{#snippet item(entry, index, isSelected)}
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
{isSelected}
|
||||
onclick={() => handleSelect(entry)}
|
||||
onmouseenter={() => nav.setHover(index)}
|
||||
>
|
||||
{@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File}
|
||||
<Icon
|
||||
class={[
|
||||
'mt-0.5 h-4 w-4 shrink-0',
|
||||
entry.type === FileMentionEntryType.DIRECTORY
|
||||
? 'text-amber-500'
|
||||
: 'text-muted-foreground'
|
||||
]}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
{#if showTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<span {...props} class="truncate text-sm font-medium">{entry.name}</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{entry.path}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<span class="truncate text-sm font-medium">{entry.name}</span>
|
||||
{/if}
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-left text-xs">
|
||||
<HighlightedMatch text={abbreviateHome(entry.path, home)} query={trimmedQuery} />
|
||||
</span>
|
||||
</div>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
+51
-22
@@ -2,6 +2,7 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
||||
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
|
||||
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
@@ -11,11 +12,19 @@
|
||||
searchQuery: string;
|
||||
showSearchInput: boolean;
|
||||
searchPlaceholder?: string;
|
||||
// Omit to distinguish "haven't searched yet" from "search returned nothing".
|
||||
emptyMessage?: string;
|
||||
autofocus?: boolean;
|
||||
inputRef?: HTMLInputElement | null;
|
||||
onSearchClose?: () => void;
|
||||
itemKey: (item: T, index: number) => string;
|
||||
item: Snippet<[T, number, boolean]>;
|
||||
skeleton?: Snippet;
|
||||
skeletonCount?: number;
|
||||
footer?: Snippet;
|
||||
// Counter bumped by the picker on keyboard nav; scrolls the selected
|
||||
// row into view without scrolling on hover or result replacement.
|
||||
scrollTrigger?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -25,49 +34,69 @@
|
||||
searchQuery = $bindable(),
|
||||
showSearchInput,
|
||||
searchPlaceholder = 'Search...',
|
||||
emptyMessage = 'No items available',
|
||||
emptyMessage,
|
||||
autofocus = false,
|
||||
inputRef = $bindable(null),
|
||||
onSearchClose,
|
||||
itemKey,
|
||||
item,
|
||||
skeleton,
|
||||
footer
|
||||
skeletonCount = 6,
|
||||
footer,
|
||||
scrollTrigger
|
||||
}: Props = $props();
|
||||
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) {
|
||||
const selectedElement = listContainer.querySelector(
|
||||
`[data-picker-index="${selectedIndex}"]`
|
||||
) as HTMLElement;
|
||||
let listPaddingTop = $derived(
|
||||
showSearchInput ? (isLoading || items.length > 0 ? 'pt-13' : 'pt-10') : ''
|
||||
);
|
||||
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}
|
||||
// selectedIndex/items.length are untracked so hover and result replacement
|
||||
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
|
||||
useScrollActiveRow({
|
||||
getTrigger: () => scrollTrigger,
|
||||
getContainer: () => listContainer,
|
||||
getIndex: () => selectedIndex,
|
||||
getCount: () => items.length,
|
||||
dataIndex: 'picker'
|
||||
});
|
||||
</script>
|
||||
|
||||
<ScrollArea>
|
||||
{#if showSearchInput}
|
||||
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
|
||||
<SearchInput placeholder={searchPlaceholder} bind:value={searchQuery} />
|
||||
<SearchInput
|
||||
{autofocus}
|
||||
placeholder={searchPlaceholder}
|
||||
bind:value={searchQuery}
|
||||
bind:ref={inputRef}
|
||||
onClose={onSearchClose}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
bind:this={listContainer}
|
||||
class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, showSearchInput && 'pt-13']}
|
||||
>
|
||||
<div bind:this={listContainer} class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, listPaddingTop]}>
|
||||
{#if isLoading}
|
||||
{#if skeleton}
|
||||
{@render skeleton()}
|
||||
{:else}
|
||||
<div aria-busy="true" aria-live="polite" class="flex flex-col">
|
||||
{#each { length: skeletonCount } as _, rowIndex (rowIndex)}
|
||||
<div class="flex items-start gap-3 rounded-lg px-3 py-2">
|
||||
<div class="mt-0.5 size-4 shrink-0 animate-pulse rounded-md bg-muted/60"></div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="h-5 w-2/5 animate-pulse rounded-sm bg-muted/60"></div>
|
||||
<div class="h-4 w-1/3 animate-pulse rounded-sm bg-muted/40"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if items && items.length === 0}
|
||||
{#if emptyMessage}
|
||||
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{/if}
|
||||
{:else if items.length === 0}
|
||||
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{:else}
|
||||
{#each items as itemData, index (itemKey(itemData, index))}
|
||||
{@render item(itemData, index, index === selectedIndex)}
|
||||
|
||||
+15
-2
@@ -3,21 +3,34 @@
|
||||
|
||||
interface Props {
|
||||
isSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
onclick: () => void;
|
||||
onmouseenter?: () => void;
|
||||
dataIndex?: number;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { isSelected = false, onclick, dataIndex, children }: Props = $props();
|
||||
let {
|
||||
class: className = '',
|
||||
isSelected = false,
|
||||
disabled = false,
|
||||
onclick,
|
||||
onmouseenter,
|
||||
dataIndex,
|
||||
children
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-picker-index={dataIndex}
|
||||
{disabled}
|
||||
{onclick}
|
||||
{onmouseenter}
|
||||
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
|
||||
? 'bg-accent/50'
|
||||
: ''}"
|
||||
: ''} {disabled ? 'cursor-not-allowed opacity-50' : ''} {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
+1
@@ -42,6 +42,7 @@
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
|
||||
preventScroll={false}
|
||||
onkeydown={onKeydown}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import type { MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { FolderOpen } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
ChatFormPickerPopover,
|
||||
ChatFormPickerList,
|
||||
ChatFormPickerListItem,
|
||||
ChatFormPickerItemHeader,
|
||||
ChatFormPickerListItemSkeleton
|
||||
} from '$lib/components/app/chat';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen?: boolean;
|
||||
searchQuery?: string;
|
||||
onClose?: () => void;
|
||||
onResourceSelect?: (resource: MCPResourceInfo) => void;
|
||||
onBrowse?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isOpen = false,
|
||||
searchQuery = '',
|
||||
onClose,
|
||||
onResourceSelect,
|
||||
onBrowse
|
||||
}: Props = $props();
|
||||
|
||||
let resources = $state<MCPResourceInfo[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let selectedIndex = $state(0);
|
||||
let internalSearchQuery = $state('');
|
||||
|
||||
let serverSettingsMap = $derived.by(() => {
|
||||
const servers = mcpStore.getServers();
|
||||
const map = new SvelteMap<string, MCPServerSettingsEntry>();
|
||||
|
||||
for (const server of servers) {
|
||||
map.set(server.id, server);
|
||||
}
|
||||
|
||||
return map;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
loadResources();
|
||||
selectedIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (filteredResources.length > 0 && selectedIndex >= filteredResources.length) {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadResources() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (!initialized) {
|
||||
resources = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await mcpStore.fetchAllResources();
|
||||
resources = mcpResourceStore.getAllResourceInfos();
|
||||
} catch (error) {
|
||||
console.error('[ChatFormPickerMcpResources] Failed to load resources:', error);
|
||||
resources = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceClick(resource: MCPResourceInfo) {
|
||||
mcpStore.attachResource(resource.uri);
|
||||
|
||||
onResourceSelect?.(resource);
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function isResourceAttached(uri: string): boolean {
|
||||
return mcpResourceStore.isAttached(uri);
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (!isOpen) return false;
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
onClose?.();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
|
||||
if (filteredResources.length > 0) {
|
||||
selectedIndex = (selectedIndex + 1) % filteredResources.length;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
if (filteredResources.length > 0) {
|
||||
selectedIndex = selectedIndex === 0 ? filteredResources.length - 1 : selectedIndex - 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
if (filteredResources[selectedIndex]) {
|
||||
handleResourceClick(filteredResources[selectedIndex]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let filteredResources = $derived.by(() => {
|
||||
const sortedServers = mcpStore.getServers();
|
||||
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
|
||||
|
||||
const sortedResources = [...resources].sort((a, b) => {
|
||||
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const query = (searchQuery || internalSearchQuery).toLowerCase();
|
||||
if (!query) return sortedResources;
|
||||
|
||||
return sortedResources.filter(
|
||||
(resource) =>
|
||||
resource.name.toLowerCase().includes(query) ||
|
||||
resource.title?.toLowerCase().includes(query) ||
|
||||
resource.description?.toLowerCase().includes(query) ||
|
||||
resource.uri.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
let showSearchInput = $derived(resources.length > 3);
|
||||
</script>
|
||||
|
||||
<ChatFormPickerPopover
|
||||
bind:isOpen
|
||||
class={className}
|
||||
srLabel="Open resource picker"
|
||||
{onClose}
|
||||
onKeydown={handleKeydown}
|
||||
>
|
||||
<ChatFormPickerList
|
||||
items={filteredResources}
|
||||
{isLoading}
|
||||
{selectedIndex}
|
||||
bind:searchQuery={internalSearchQuery}
|
||||
{showSearchInput}
|
||||
searchPlaceholder="Search resources..."
|
||||
emptyMessage="No MCP resources available"
|
||||
itemKey={(resource) => resource.serverName + ':' + resource.uri}
|
||||
>
|
||||
{#snippet item(resource, index, isSelected)}
|
||||
{@const server = serverSettingsMap.get(resource.serverName)}
|
||||
{@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName}
|
||||
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
{isSelected}
|
||||
onclick={() => handleResourceClick(resource)}
|
||||
>
|
||||
<ChatFormPickerItemHeader
|
||||
{server}
|
||||
{serverLabel}
|
||||
title={resource.title || resource.name}
|
||||
description={resource.description}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if isResourceAttached(resource.uri)}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
|
||||
>
|
||||
attached
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet subtitle()}
|
||||
<p class="mt-0.5 truncate text-xs text-muted-foreground/60">
|
||||
{resource.uri}
|
||||
</p>
|
||||
{/snippet}
|
||||
</ChatFormPickerItemHeader>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
|
||||
{#snippet skeleton()}
|
||||
<ChatFormPickerListItemSkeleton />
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
{#if onBrowse && resources.length > 3}
|
||||
<Button
|
||||
class="fixed right-3 bottom-3"
|
||||
type="button"
|
||||
onclick={onBrowse}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<FolderOpen class="h-3 w-3" />
|
||||
|
||||
Browse all
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</ChatFormPickerPopover>
|
||||
+28
-22
@@ -1,16 +1,19 @@
|
||||
<script lang="ts">
|
||||
import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
|
||||
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
|
||||
import ChatFormPickerMcpResources from './ChatFormPickerMcpResources.svelte';
|
||||
import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
|
||||
import type { FileMentionEntry, GetPromptResult, MCPPromptInfo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
isPromptPickerOpen?: boolean;
|
||||
promptSearchQuery?: string;
|
||||
isInlineResourcePickerOpen?: boolean;
|
||||
resourceSearchQuery?: string;
|
||||
isMentionPickerOpen?: boolean;
|
||||
mentionQuery?: string;
|
||||
mentionAnchor?: HTMLElement | null;
|
||||
scopePath?: string | null;
|
||||
onPromptPickerClose?: () => void;
|
||||
onInlineResourcePickerClose?: () => void;
|
||||
onInlineResourceSelect?: () => void;
|
||||
onMentionPickerClose?: () => void;
|
||||
onMentionOpened?: () => void;
|
||||
onMentionSelect?: (entry: FileMentionEntry) => void;
|
||||
onPromptLoadStart?: (
|
||||
placeholderId: string,
|
||||
promptInfo: MCPPromptInfo,
|
||||
@@ -18,25 +21,26 @@
|
||||
) => void;
|
||||
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
|
||||
onPromptLoadError?: (placeholderId: string, error: string) => void;
|
||||
onInlineResourceBrowse?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isPromptPickerOpen,
|
||||
promptSearchQuery,
|
||||
isInlineResourcePickerOpen,
|
||||
resourceSearchQuery,
|
||||
isMentionPickerOpen,
|
||||
mentionQuery,
|
||||
mentionAnchor,
|
||||
scopePath,
|
||||
onPromptPickerClose,
|
||||
onInlineResourcePickerClose,
|
||||
onInlineResourceSelect,
|
||||
onMentionPickerClose,
|
||||
onMentionOpened,
|
||||
onMentionSelect,
|
||||
onPromptLoadStart,
|
||||
onPromptLoadComplete,
|
||||
onPromptLoadError,
|
||||
onInlineResourceBrowse
|
||||
onPromptLoadError
|
||||
}: Props = $props();
|
||||
|
||||
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
|
||||
let resourcePickerRef: ChatFormPickerMcpResources | undefined = $state(undefined);
|
||||
let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
|
||||
|
||||
/**
|
||||
* Delegates keyboard events to the active picker child.
|
||||
@@ -47,7 +51,7 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isInlineResourcePickerOpen && resourcePickerRef?.handleKeydown(event)) {
|
||||
if (isMentionPickerOpen && mentionPickerRef?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -65,11 +69,13 @@
|
||||
{onPromptLoadError}
|
||||
/>
|
||||
|
||||
<ChatFormPickerMcpResources
|
||||
bind:this={resourcePickerRef}
|
||||
isOpen={isInlineResourcePickerOpen}
|
||||
searchQuery={resourceSearchQuery}
|
||||
onClose={onInlineResourcePickerClose}
|
||||
onResourceSelect={onInlineResourceSelect}
|
||||
onBrowse={onInlineResourceBrowse}
|
||||
<ChatFormMentionPicker
|
||||
bind:this={mentionPickerRef}
|
||||
isOpen={isMentionPickerOpen ?? false}
|
||||
query={mentionQuery ?? ''}
|
||||
customAnchor={mentionAnchor}
|
||||
scopePath={scopePath ?? null}
|
||||
onClose={onMentionPickerClose ?? (() => {})}
|
||||
onOpened={onMentionOpened}
|
||||
onSelect={onMentionSelect ?? (() => {})}
|
||||
/>
|
||||
|
||||
@@ -48,6 +48,16 @@
|
||||
textareaElement.style.height = '1rem';
|
||||
}
|
||||
}
|
||||
|
||||
// Plain-text caret offsets for the mention-splice flow.
|
||||
export function getCaretOffset(): number {
|
||||
if (!textareaElement) return 0;
|
||||
return textareaElement.selectionStart ?? textareaElement.value.length;
|
||||
}
|
||||
|
||||
export function setCaretOffset(offset: number) {
|
||||
textareaElement?.setSelectionRange(offset, offset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 {className}">
|
||||
|
||||
@@ -351,14 +351,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha
|
||||
* Generic scrollable list for picker popovers. Provides search input,
|
||||
* scroll-into-view for keyboard navigation, loading skeletons, empty state,
|
||||
* and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
|
||||
*/
|
||||
export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte';
|
||||
|
||||
/**
|
||||
* Generic button wrapper for picker list items. Provides consistent styling,
|
||||
* hover/selected states, and data-picker-index attribute for scroll-into-view.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
|
||||
*/
|
||||
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte';
|
||||
|
||||
@@ -376,30 +376,19 @@ export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/
|
||||
export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
|
||||
|
||||
/**
|
||||
* **ChatFormPickerMcpResources** - MCP resource selection interface
|
||||
*
|
||||
* Floating picker for browsing and attaching MCP Server Resources.
|
||||
* Triggered by typing `@` in the chat input.
|
||||
* Loads resources from connected MCP servers and allows users to attach them to the chat context.
|
||||
*
|
||||
* **Features:**
|
||||
* - Search/filter resources by name, title, description, or URI across all connected servers
|
||||
* - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
|
||||
* - Shows attached state for already-attached resources
|
||||
* - Loading states with skeleton placeholders
|
||||
* - Server information header per resource for visual identification
|
||||
*
|
||||
* **Exported API:**
|
||||
* - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
|
||||
* `@`-triggered file/folder mention picker. Resolves `@<query>` in the chat
|
||||
* input to a filesystem match via the server's `file_glob_search` built-in
|
||||
* tool, scoped to the conversation cwd (or server home when unset).
|
||||
* Selection splices a `[name](file:///<abs path>)` link into the input.
|
||||
*/
|
||||
export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte';
|
||||
export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
|
||||
|
||||
/**
|
||||
* **ChatFormPickers** - Chat input picker container
|
||||
*
|
||||
* Container component that hosts both MCP prompt and MCP resource pickers.
|
||||
* Container component that hosts the MCP prompt and file mention pickers.
|
||||
* Manages shared state, keyboard navigation, and coordination between the two
|
||||
* picker interfaces. Used within ChatForm for `@`-triggered pickers.
|
||||
* picker interfaces. Used within ChatForm.
|
||||
*/
|
||||
export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
|
||||
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
|
||||
import { rehypeFileBadge } from './plugins/rehype/file-badge';
|
||||
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
|
||||
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
|
||||
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
|
||||
@@ -174,6 +175,7 @@
|
||||
}) // Add syntax highlighting
|
||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
|
||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||
.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
|
||||
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
|
||||
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
|
||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Rehype plugin that rewrites `file://` markdown anchors into the inline
|
||||
* @-mention chip, reusing the visual contract from
|
||||
* `$lib/constants/mention-badge`.
|
||||
*
|
||||
* The chip is presentational: `file://` navigation is blocked from
|
||||
* http(s) pages, so the anchor becomes a plain `<span>` (no link role,
|
||||
* no tab stop); the full path stays available on `title`.
|
||||
*/
|
||||
|
||||
import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
|
||||
import {
|
||||
FILE_URI_PREFIX,
|
||||
MENTION_BADGE_CLASSNAME,
|
||||
MENTION_BADGE_ICON_CLASSNAME,
|
||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
PATH_SEPARATOR,
|
||||
SETTINGS_KEYS
|
||||
} from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import type { Plugin } from 'unified';
|
||||
import type { Root, Element } from 'hast';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
// Trailing path separators mark a directory and are kept out of the label.
|
||||
const TRAILING_SEPARATOR_REGEX = /\/+$/;
|
||||
|
||||
function decodeHrefPath(href: string): string {
|
||||
const stripped = href.startsWith(FILE_URI_PREFIX) ? href.slice(FILE_URI_PREFIX.length) : href;
|
||||
return decodeFileLinkPath(stripped);
|
||||
}
|
||||
|
||||
function labelFromFileUrl(href: string): string {
|
||||
const decoded = decodeHrefPath(href);
|
||||
const trimmed = decoded.replace(TRAILING_SEPARATOR_REGEX, '');
|
||||
const slash = trimmed.lastIndexOf(PATH_SEPARATOR);
|
||||
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
|
||||
}
|
||||
|
||||
// A trailing `/` in the target marks a directory and selects the folder
|
||||
// icon, matching the convention the mention picker inserts with.
|
||||
function iconElement(href: string): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'svg',
|
||||
properties: {
|
||||
...MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
className: MENTION_BADGE_ICON_CLASSNAME.split(' ').filter(Boolean)
|
||||
},
|
||||
children: getMentionBadgeIconPaths(href).map((d) => ({
|
||||
type: 'element',
|
||||
tagName: 'path',
|
||||
properties: { d },
|
||||
children: []
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export const rehypeFileBadge: Plugin<[], Root> = () => {
|
||||
return (tree: Root) => {
|
||||
visit(tree, 'element', (node: Element) => {
|
||||
if (node.tagName !== 'a') return;
|
||||
|
||||
const props = node.properties ?? {};
|
||||
const href = typeof props.href === 'string' ? props.href : null;
|
||||
|
||||
if (!href || !href.startsWith(FILE_URI_PREFIX)) return;
|
||||
|
||||
const label = labelFromFileUrl(href);
|
||||
const titleAttr = typeof props.title === 'string' ? props.title : href;
|
||||
const decodedPath = decodeHrefPath(href);
|
||||
|
||||
node.tagName = 'span';
|
||||
node.properties = {
|
||||
className: MENTION_BADGE_CLASSNAME.split(' ').filter(Boolean),
|
||||
title: titleAttr.startsWith(FILE_URI_PREFIX) ? decodedPath : titleAttr
|
||||
};
|
||||
node.children = [
|
||||
iconElement(href),
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: { className: ['shrink-0', 'truncate'] },
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: getMentionBadgeLabel(
|
||||
label,
|
||||
decodedPath,
|
||||
settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
|
||||
toolsStore.serverHome
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { highlightMatch } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
query: string;
|
||||
matchClass?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
text,
|
||||
query,
|
||||
matchClass = 'rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30'
|
||||
}: Props = $props();
|
||||
|
||||
let segments = $derived(highlightMatch(text, query));
|
||||
</script>
|
||||
|
||||
{#each segments as seg, i (i)}
|
||||
{#if seg.match}
|
||||
<mark class={matchClass}>{seg.text}</mark>
|
||||
{:else}
|
||||
{seg.text}
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -42,3 +42,11 @@ export { default as KeyValuePairs } from './KeyValuePairs.svelte';
|
||||
* Supports placeholder, autofocus, and change callbacks.
|
||||
*/
|
||||
export { default as SearchInput } from './SearchInput.svelte';
|
||||
|
||||
/**
|
||||
* **HighlightedMatch** - Substring-match text highlight
|
||||
*
|
||||
* Renders `text` with each case-insensitive occurrence of `query` wrapped
|
||||
* in `<mark>`.
|
||||
*/
|
||||
export { default as HighlightedMatch } from './HighlightedMatch.svelte';
|
||||
|
||||
@@ -2,5 +2,4 @@ export const INITIAL_FILE_SIZE = 0;
|
||||
export const PROMPT_CONTENT_SEPARATOR = '\n\n';
|
||||
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
|
||||
export const PROMPT_TRIGGER_PREFIX = '/';
|
||||
export const RESOURCE_TRIGGER_PREFIX = '@';
|
||||
export const NEW_CHAT_DRAFT_KEY = '__new_chat__';
|
||||
|
||||
@@ -39,6 +39,7 @@ export * from './max-bundle-size';
|
||||
export * from './mcp';
|
||||
export * from './mcp-form';
|
||||
export * from './mcp-resource';
|
||||
export * from './mention-badge';
|
||||
export * from './message-export';
|
||||
export * from './path-display';
|
||||
export * from './model-id';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Visual contract for message @-mention badges. Svelte cannot be mounted
|
||||
* from a hast tree, so the rehype file-badge plugin emits the shared class
|
||||
* string below; keeping it here as a literal lets Tailwind's source
|
||||
* scanner generate the utility classes.
|
||||
*/
|
||||
export const MENTION_BADGE_CLASSNAME =
|
||||
'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground';
|
||||
|
||||
export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
|
||||
|
||||
/**
|
||||
* SVG attributes shared by the hast-built badge icons; the rehype plugin
|
||||
* spreads them onto the `<svg>` `properties`.
|
||||
*/
|
||||
export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly<Record<string, string>> = {
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '2',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'aria-hidden': 'true'
|
||||
};
|
||||
|
||||
/**
|
||||
* SVG path strings for the badge's inline icon; each entry becomes one
|
||||
* `<path>` child of the wrapper `<svg>`. Paths match `lucide-svelte`'s
|
||||
* current `File` and `Folder` glyphs.
|
||||
*/
|
||||
export const MENTION_BADGE_FILE_ICON_PATHS: readonly string[] = [
|
||||
'M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z',
|
||||
'M14 2v5a1 1 0 0 0 1 1h5'
|
||||
];
|
||||
|
||||
export const MENTION_BADGE_FOLDER_ICON_PATHS: readonly string[] = [
|
||||
'M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'
|
||||
];
|
||||
@@ -31,8 +31,10 @@ export const SETTINGS_KEYS = {
|
||||
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
|
||||
SHOW_MODEL_TAGS: 'showModelTags',
|
||||
SHOW_BUILD_VERSION: 'showBuildVersion',
|
||||
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
|
||||
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
|
||||
RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
|
||||
MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
|
||||
// Sampling
|
||||
TEMPERATURE: 'temperature',
|
||||
DYNATEMP_RANGE: 'dynatemp_range',
|
||||
|
||||
@@ -23,7 +23,12 @@ import type {
|
||||
SettingsSectionEntry,
|
||||
SettingsSection
|
||||
} from '$lib/types';
|
||||
import { CLI_FLAGS, DEFAULT_MCP_CONFIG } from '$lib/constants';
|
||||
import { CLI_FLAGS } from './cli-flags';
|
||||
import { DEFAULT_MCP_CONFIG } from './mcp';
|
||||
import {
|
||||
FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
|
||||
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH
|
||||
} from './working-directory';
|
||||
import { SETTINGS_KEYS } from './settings-keys';
|
||||
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
|
||||
import { TITLE_GENERATION } from './title-generation';
|
||||
@@ -298,6 +303,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS,
|
||||
label: 'Show full path in mentions',
|
||||
help: 'Display the full file system path inside file and folder @-mention badges instead of just the file or folder name.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -555,6 +568,18 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
isPositiveInteger: true
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH,
|
||||
label: 'Mention search depth',
|
||||
help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.',
|
||||
defaultValue: FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
|
||||
placeholder: `${FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH}`,
|
||||
min: 1,
|
||||
max: FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
isPositiveInteger: true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -38,3 +38,9 @@ export const PATH_NAV_MAX_DEPTH = 1;
|
||||
// Native folder-picker resolution searches a shallow, bounded window.
|
||||
export const NATIVE_MAX_DEPTH = 4;
|
||||
export const NATIVE_LIMIT = 20;
|
||||
|
||||
/** Upper bound the mention search depth setting accepts. The server itself imposes no depth cap (0 = unlimited); this is a UI sanity bound. */
|
||||
export const FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH = 32;
|
||||
|
||||
/** Depth the pickers fall back to when the user setting is invalid. */
|
||||
export const FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH = 10;
|
||||
|
||||
@@ -78,3 +78,8 @@ export enum PdfViewMode {
|
||||
TEXT = 'text',
|
||||
PAGES = 'pages'
|
||||
}
|
||||
|
||||
export enum FileMentionEntryType {
|
||||
FILE = 'file',
|
||||
DIRECTORY = 'directory'
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ export {
|
||||
MessageRole,
|
||||
MessageType,
|
||||
PdfViewMode,
|
||||
ReasoningFormat
|
||||
ReasoningFormat,
|
||||
FileMentionEntryType
|
||||
} from './chat.enums';
|
||||
|
||||
export { SessionRecordType } from './conversation-import.enums';
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum KeyboardKey {
|
||||
ARROW_DOWN = 'ArrowDown',
|
||||
ARROW_LEFT = 'ArrowLeft',
|
||||
ARROW_RIGHT = 'ArrowRight',
|
||||
BACKSPACE = 'Backspace',
|
||||
TAB = 'Tab',
|
||||
B_LOWER = 'b',
|
||||
D_LOWER = 'd',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
|
||||
/**
|
||||
* Shared debounced async-search machinery for the chat-form pickers:
|
||||
* AbortController + sequence counter to discard stale responses, a
|
||||
* debounce, and a live `isSearching` flag.
|
||||
*/
|
||||
|
||||
export interface UseDebouncedSearchOptions {
|
||||
debounceMs: number;
|
||||
/** Fire-time guard: a scheduled call that outlives a reset is dropped. */
|
||||
canRun: () => boolean;
|
||||
/** Live query, used to drop a scheduled call whose query changed. */
|
||||
getQuery: () => string;
|
||||
/** Perform the search and commit results; bail out when `isCurrent()` is false. */
|
||||
run: (query: string, signal: AbortSignal, isCurrent: () => boolean) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function useDebouncedSearch(opts: UseDebouncedSearchOptions) {
|
||||
let controller: AbortController | null = null;
|
||||
let searchSeq = 0;
|
||||
let isSearching = $state(false);
|
||||
|
||||
function isCurrent(seq: number) {
|
||||
return seq === searchSeq;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
controller?.abort();
|
||||
searchSeq++;
|
||||
isSearching = false;
|
||||
}
|
||||
|
||||
const schedule = debounce((query: string) => {
|
||||
if (!opts.canRun() || query !== opts.getQuery().trim()) return;
|
||||
void start(query);
|
||||
}, opts.debounceMs);
|
||||
|
||||
async function start(query: string) {
|
||||
cancel();
|
||||
const fresh = new AbortController();
|
||||
controller = fresh;
|
||||
const mySeq = ++searchSeq;
|
||||
isSearching = true;
|
||||
try {
|
||||
await opts.run(query, fresh.signal, () => isCurrent(mySeq));
|
||||
} finally {
|
||||
if (isCurrent(mySeq)) isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get isSearching() {
|
||||
return isSearching;
|
||||
},
|
||||
/** Bump the loading flag synchronously (e.g. before the debounce fires). */
|
||||
setLoading(value: boolean) {
|
||||
isSearching = value;
|
||||
},
|
||||
run(query: string) {
|
||||
schedule(query);
|
||||
},
|
||||
cancel
|
||||
};
|
||||
}
|
||||
|
||||
export type UseDebouncedSearchReturn = ReturnType<typeof useDebouncedSearch>;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Shared keyboard navigation state for the chat-form pickers: a highlighted
|
||||
* row, a scroll trigger, and Arrow/Escape/Enter handling.
|
||||
*/
|
||||
export interface UsePickerNavigationOptions {
|
||||
/** Gates all key handling. */
|
||||
isOpen: () => boolean;
|
||||
count: () => number;
|
||||
/**
|
||||
* Resolve the row to highlight for a movement step, or -1 when no move
|
||||
* is possible. Defaults to plain wraparound across `count()`.
|
||||
*/
|
||||
step?: (from: number, dir: 1 | -1) => number;
|
||||
onClose: () => void;
|
||||
/** Called on Enter when `hoveredIndex` points at a selectable row. */
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
function wrapStep(from: number, dir: 1 | -1, count: number): number {
|
||||
return dir === 1 ? (from + 1) % count : from <= 0 ? count - 1 : from - 1;
|
||||
}
|
||||
|
||||
export function usePickerNavigation(opts: UsePickerNavigationOptions) {
|
||||
let hoveredIndex = $state(-1);
|
||||
let scrollTrigger = $state(0);
|
||||
|
||||
function resolve(from: number, dir: 1 | -1): number {
|
||||
const n = opts.count();
|
||||
if (n === 0) return -1;
|
||||
if (opts.step) return opts.step(from, dir);
|
||||
return wrapStep(from, dir, n);
|
||||
}
|
||||
|
||||
function move(dir: 1 | -1) {
|
||||
const next = resolve(hoveredIndex, dir);
|
||||
if (next >= 0) {
|
||||
hoveredIndex = next;
|
||||
scrollTrigger++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the highlight without bumping the scroll trigger. */
|
||||
function reset(index: number) {
|
||||
hoveredIndex = index;
|
||||
}
|
||||
|
||||
/** Bump the scroll trigger without moving the highlight. */
|
||||
function bumpScroll() {
|
||||
scrollTrigger++;
|
||||
}
|
||||
|
||||
/** Mouse hover highlights a row but must NOT bump the scroll trigger. */
|
||||
function setHover(index: number) {
|
||||
hoveredIndex = index;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (!opts.isOpen()) return false;
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
opts.onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
if (hoveredIndex >= 0 && hoveredIndex < opts.count()) {
|
||||
event.preventDefault();
|
||||
opts.onSelect(hoveredIndex);
|
||||
return true;
|
||||
}
|
||||
// No selectable row - let the caller's Enter-to-submit run.
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
get hoveredIndex() {
|
||||
return hoveredIndex;
|
||||
},
|
||||
get scrollTrigger() {
|
||||
return scrollTrigger;
|
||||
},
|
||||
reset,
|
||||
setHover,
|
||||
move,
|
||||
bumpScroll,
|
||||
handleKeydown
|
||||
};
|
||||
}
|
||||
|
||||
export type UsePickerNavigationReturn = ReturnType<typeof usePickerNavigation>;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
/**
|
||||
* Scrolls the highlighted row of a picker list into view when the scroll
|
||||
* trigger is bumped, without scrolling on mouse hover or result
|
||||
* replacement.
|
||||
*/
|
||||
export interface UseScrollActiveRowOptions {
|
||||
/** Counter bumped by keyboard nav; `undefined` disables the effect. */
|
||||
getTrigger: () => number | undefined;
|
||||
getContainer: () => HTMLDivElement | null;
|
||||
getIndex: () => number;
|
||||
getCount: () => number;
|
||||
/** Attribute prefix, e.g. 'picker' for `[data-picker-index="0"]`. */
|
||||
dataIndex: string;
|
||||
}
|
||||
|
||||
export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
|
||||
let lastTrigger: number | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const trigger = opts.getTrigger();
|
||||
if (trigger === undefined) return;
|
||||
|
||||
// Skip the initial run on mount: the list opens with the first row
|
||||
// already in view, and scrolling here fires before the popover is
|
||||
// positioned, which would scroll the whole page to the top.
|
||||
if (lastTrigger === null) {
|
||||
lastTrigger = trigger;
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger === lastTrigger) return;
|
||||
lastTrigger = trigger;
|
||||
untrack(() => {
|
||||
const container = opts.getContainer();
|
||||
const index = opts.getIndex();
|
||||
if (!container || index < 0 || index >= opts.getCount()) return;
|
||||
const row = container.querySelector(
|
||||
`[data-${opts.dataIndex}-index="${index}"]`
|
||||
) as HTMLElement | null;
|
||||
row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type UseScrollActiveRowReturn = ReturnType<typeof useScrollActiveRow>;
|
||||
Vendored
+11
-1
@@ -1,4 +1,4 @@
|
||||
import type { ErrorDialogType } from '$lib/enums';
|
||||
import type { ErrorDialogType, FileMentionEntryType } from '$lib/enums';
|
||||
import type { ApiChatCompletionToolCall } from './api';
|
||||
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
|
||||
|
||||
@@ -166,3 +166,13 @@ export interface FileProcessingResult {
|
||||
extras: DatabaseMessageExtra[];
|
||||
emptyFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A file or folder picked in the @-mention picker. `path` is the absolute
|
||||
* server-side path; `name` is the basename.
|
||||
*/
|
||||
export interface FileMentionEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
type: FileMentionEntryType;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@ export type {
|
||||
LiveProcessingStats,
|
||||
LiveGenerationStats,
|
||||
AttachmentDisplayItemsOptions,
|
||||
FileProcessingResult
|
||||
FileProcessingResult,
|
||||
FileMentionEntry
|
||||
} from './chat.d';
|
||||
|
||||
// Database types
|
||||
|
||||
Vendored
+6
@@ -31,6 +31,9 @@ export interface SettingsEntry {
|
||||
radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>;
|
||||
isExperimental?: boolean;
|
||||
isPositiveInteger?: boolean;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
dependsOn?: string;
|
||||
sync?: {
|
||||
serverKey: string;
|
||||
@@ -52,6 +55,9 @@ export interface SettingsFieldConfig {
|
||||
type: SettingsFieldType;
|
||||
isExperimental?: boolean;
|
||||
isPositiveInteger?: boolean;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
dependsOn?: string;
|
||||
help?: string;
|
||||
options?: Array<{ value: string; label: string; icon?: typeof Icon }>;
|
||||
|
||||
@@ -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