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';
|
||||
|
||||
Reference in New Issue
Block a user