mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 16:02:12 +00:00
feat: add workspace mentions to AI composer (#11264)
* feat: add workspace mentions to AI composer * fix: ignore embedded mention text in composer * fix: sync only standalone composer mentions * fix: keep punctuation-adjacent mentions active * fix: use unicode mention boundaries * fix: handle unicode mention prefixes * fix: avoid rescanning mention prefixes
This commit is contained in:
@@ -13,9 +13,10 @@
|
||||
item: Item
|
||||
builders: ReturnType<typeof createDropdownMenu>['builders']
|
||||
meltItem: MenubarMenuElements['item']
|
||||
close?: (afterClose?: () => void) => void
|
||||
}
|
||||
|
||||
let { item, builders, meltItem }: Props = $props()
|
||||
let { item, builders, meltItem, close }: Props = $props()
|
||||
|
||||
const {
|
||||
elements: { subTrigger, subMenu },
|
||||
@@ -23,6 +24,8 @@
|
||||
} = untrack(() => builders).createSubmenu()
|
||||
|
||||
let subItems = $derived((item.submenuItems ?? []).filter((i) => !i.hide))
|
||||
const CustomSubmenu = $derived(item.customSubmenu)
|
||||
const closeMenu = $derived(close ?? (() => {}))
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -46,14 +49,20 @@
|
||||
{#if $subOpen}
|
||||
<div
|
||||
use:melt={$subMenu}
|
||||
class="z-[6000] bg-surface-tertiary dark:border w-48 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
|
||||
class="z-[6000] bg-surface-tertiary dark:border {CustomSubmenu
|
||||
? ''
|
||||
: 'w-48 overflow-y-auto py-1'} origin-top-right rounded-lg shadow-lg focus:outline-none"
|
||||
>
|
||||
{#each subItems as subItem}
|
||||
{#if subItem.separatorTop}
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
{/if}
|
||||
{@render subMenuItem(subItem)}
|
||||
{/each}
|
||||
{#if CustomSubmenu}
|
||||
<CustomSubmenu {...item.customSubmenuProps} close={closeMenu} />
|
||||
{:else}
|
||||
{#each subItems as subItem}
|
||||
{#if subItem.separatorTop}
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
{/if}
|
||||
{@render subMenuItem(subItem)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -132,8 +132,9 @@
|
||||
})
|
||||
})
|
||||
|
||||
export function close() {
|
||||
export function close(afterClose?: () => void) {
|
||||
open = false
|
||||
if (afterClose) setTimeout(afterClose, 10)
|
||||
}
|
||||
|
||||
async function computeItems(): Promise<Item[]> {
|
||||
@@ -209,7 +210,7 @@
|
||||
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
|
||||
style={`${customWidth ? `width: ${customWidth}px;` : ''} max-height: ${maxHeight || '50vh'};`}
|
||||
>
|
||||
<DropdownV2Inner {aiId} items={computeItems} meltItem={item} {builders} />
|
||||
<DropdownV2Inner {aiId} items={computeItems} meltItem={item} {builders} {close} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
|
||||
meltItem: MenubarMenuElements['item']
|
||||
builders?: ReturnType<typeof createDropdownMenu>['builders']
|
||||
close?: (afterClose?: () => void) => void
|
||||
}
|
||||
|
||||
let { aiId, items = [], meltItem, builders }: Props = $props()
|
||||
let { aiId, items = [], meltItem, builders, close }: Props = $props()
|
||||
|
||||
let computedItems: Item[] | undefined = $state(undefined)
|
||||
async function computeItems() {
|
||||
@@ -102,8 +103,8 @@
|
||||
{#if item.separatorTop}
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
{/if}
|
||||
{#if item.submenuItems && builders}
|
||||
<DropdownSubmenuItem {item} {builders} {meltItem} />
|
||||
{#if (item.submenuItems || item.customSubmenu) && builders}
|
||||
<DropdownSubmenuItem {item} {builders} {meltItem} close={close ?? (() => {})} />
|
||||
{:else if item.disabled && item.tooltip}
|
||||
<!-- Wrapper carries the native `title`; the disabled button's `pointer-events-none`
|
||||
lets the hover reach it so the user learns why the item is disabled. -->
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import AIChatMessage from './AIChatMessage.svelte'
|
||||
import AppAvailableContextList from './AppAvailableContextList.svelte'
|
||||
import ChatContextPicker from './ChatContextPicker.svelte'
|
||||
import WorkspaceMentionPicker from './WorkspaceMentionPicker.svelte'
|
||||
import { type Snippet } from 'svelte'
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -548,6 +549,11 @@
|
||||
await imageWork
|
||||
}
|
||||
|
||||
function mentionWorkspaceItem(element: ContextElement) {
|
||||
void aiChatInput?.addContextToSelection(element)
|
||||
aiChatInput?.insertMention(element.title)
|
||||
}
|
||||
|
||||
function onFolderInputChange(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement
|
||||
// webkitdirectory files carry webkitRelativePath (`folder/sub/file`); addFiles groups
|
||||
@@ -1055,6 +1061,19 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(inGlobal
|
||||
? [
|
||||
{
|
||||
displayName: 'Mention file',
|
||||
icon: AtSign,
|
||||
customSubmenu: WorkspaceMentionPicker,
|
||||
customSubmenuProps: {
|
||||
onSelect: mentionWorkspaceItem,
|
||||
onAfterClose: () => aiChatInput?.focusInput()
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(skillItems
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
|
||||
import { formatMention } from './mention'
|
||||
import { formatMention, hasMention, removeMentionFromText } from './mention'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { tick, untrack, type Snippet } from 'svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
@@ -588,40 +588,33 @@
|
||||
|
||||
const contextKey = contextElementKey
|
||||
|
||||
/** Append `@title` to the textarea so the button-picker path stays in
|
||||
* sync with the inline `@<word>` mention path — both leave a visible
|
||||
* token tied to the selectedContext entry, which the textarea diffs on
|
||||
* to auto-remove items when the user deletes them. No-op when the
|
||||
* mention is already present so re-picking the same item doesn't
|
||||
* leave duplicate tokens. */
|
||||
/** Insert `@title` so button/menu picker paths stay in sync with
|
||||
* the inline `@<word>` mention path — both leave a visible token tied
|
||||
* to the selectedContext entry, which the textarea diffs on to
|
||||
* auto-remove items when the user deletes them. No-op when the same
|
||||
* standalone mention is already present, so picking the same item
|
||||
* again doesn't leave duplicate tokens. */
|
||||
export function insertMention(title: string) {
|
||||
const target = `@${title}`
|
||||
if (draft.text.split(/\s+/).includes(target)) return
|
||||
if (hasMention(draft.text, title)) return
|
||||
if (contextTextareaComponent) {
|
||||
void contextTextareaComponent.insertMention(title)
|
||||
return
|
||||
}
|
||||
const sep = draft.text.length === 0 || /\s$/.test(draft.text) ? '' : ' '
|
||||
draft.text = `${draft.text}${sep}${target} `
|
||||
draft.text = `${draft.text}${sep}${formatMention(title)} `
|
||||
}
|
||||
|
||||
/** Strip every `@title` token from the textarea — used when the user
|
||||
* deletes the corresponding badge so the badge X-button mirrors the
|
||||
* inverse (text-delete-to-badge-remove) sync. Only matches `@title` as a
|
||||
* standalone token (boundary on both sides) so substring matches don't
|
||||
* bleed into other words; only the whitespace adjacent to the removed
|
||||
* mention is collapsed so unrelated double-spaces stay intact. */
|
||||
* inverse (text-delete-to-badge-remove) sync. Only matches tokens whose
|
||||
* `@` starts a word, so embedded text like `owner@app.ts` is left alone
|
||||
* while punctuation and non-spacing text after the token still work. */
|
||||
export function removeMention(title: string) {
|
||||
// Pre-zap the textarea's mention diff snapshot so the upcoming strip
|
||||
// doesn't refire the removal effect on a same-title sibling — the host
|
||||
// has already mutated `selectedContext` to drop the targeted entry.
|
||||
contextTextareaComponent?.unsyncMention(title)
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`(^|\\s)@${escaped}(\\s|$)`, 'g')
|
||||
draft.text = draft.text.replace(re, (_m, lead, trail) => {
|
||||
// Boundary on at least one side → drop the mention entirely.
|
||||
if (!lead || !trail) return ''
|
||||
// Middle of text: keep ONE of the bracketing whitespace chars so
|
||||
// the surviving tokens are still separated; preserve the leading
|
||||
// one verbatim so newlines/tabs aren't downgraded to spaces.
|
||||
return lead
|
||||
})
|
||||
draft.text = removeMentionFromText(draft.text, title)
|
||||
}
|
||||
|
||||
export function focusInput() {
|
||||
|
||||
@@ -10,7 +10,13 @@
|
||||
import { zIndexes } from '$lib/zIndexes'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
|
||||
import { MENTION_RE, mentionTitle, formatMention } from './mention'
|
||||
import {
|
||||
MENTION_RE,
|
||||
mentionTitle,
|
||||
formatMention,
|
||||
hasMentionLeadingBoundary,
|
||||
mentionTitlesInText
|
||||
} from './mention'
|
||||
import { createFloatingActions, createVirtualElement } from 'svelte-floating-ui'
|
||||
import { flip, offset, shift } from 'svelte-floating-ui/dom'
|
||||
import {
|
||||
@@ -65,17 +71,11 @@
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
function extractMentions(text: string): Set<string> {
|
||||
const out = new Set<string>()
|
||||
for (const m of text.matchAll(MENTION_RE)) out.add(mentionTitle(m[0]))
|
||||
return out
|
||||
}
|
||||
|
||||
// Titles currently appearing as `@title` mentions in the textarea. Compared
|
||||
// against the previous snapshot in a $effect (NOT inside handleInput —
|
||||
// the picker mutates `value` programmatically via `updateInstructionsWithContext`,
|
||||
// which doesn't fire `oninput`, so a handleInput-only diff goes stale).
|
||||
const mentionedTitles = $derived(extractMentions(value))
|
||||
const mentionedTitles = $derived(mentionTitlesInText(value))
|
||||
let prevMentionedTitles = $state<Set<string>>(new Set())
|
||||
|
||||
let showContextTooltip = $state(false)
|
||||
@@ -268,7 +268,11 @@
|
||||
if (!att) return match
|
||||
return `<span data-paste-id="${att.id}" class="rounded bg-surface-secondary text-secondary cursor-pointer pointer-events-auto">${match}</span>`
|
||||
})
|
||||
html = html.replace(MENTION_RE, (match) => {
|
||||
html = html.replace(MENTION_RE, (match, ...args) => {
|
||||
const offset = args[args.length - 2]
|
||||
if (typeof offset !== 'number' || !hasMentionLeadingBoundary(html, offset)) {
|
||||
return match
|
||||
}
|
||||
const title = unescapeHtml(mentionTitle(match))
|
||||
const inContext =
|
||||
availableContext.find((c) => c.title === title) ||
|
||||
@@ -566,6 +570,23 @@
|
||||
showContextTooltip = false
|
||||
}
|
||||
|
||||
export async function insertMention(title: string) {
|
||||
const token = formatMention(title)
|
||||
const selectionStart = textarea?.selectionStart ?? value.length
|
||||
const selectionEnd = textarea?.selectionEnd ?? selectionStart
|
||||
const { from, to, ids } = tokensOverlapping(selectionStart, selectionEnd)
|
||||
const before = value.slice(0, from)
|
||||
const after = value.slice(to)
|
||||
const prefix = before.length === 0 || /\s$/.test(before) ? '' : ' '
|
||||
const suffix = after.length === 0 ? ' ' : /^\s/.test(after) ? '' : ' '
|
||||
const inserted = `${prefix}${token}${suffix}`
|
||||
replacePasteRange(from, to, ids, inserted)
|
||||
await tick()
|
||||
const pos = from + inserted.length
|
||||
textarea?.setSelectionRange(pos, pos)
|
||||
textarea?.focus()
|
||||
}
|
||||
|
||||
function refreshCommandSkills() {
|
||||
if (commandSkillsRefreshInFlight) return
|
||||
commandSkillsRefreshInFlight = true
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<!--
|
||||
@component
|
||||
Workspace-only picker for the AI session composer's `+ -> Mention file`
|
||||
submenu. It mirrors ChatContextPicker's workspace branch, but starts directly
|
||||
at the workspace root because the parent menu item already names the scope.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Folder } from 'lucide-svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import DrillPicker from '$lib/components/DrillPicker.svelte'
|
||||
import type { DrillBranch, DrillLeaf, DrillNode } from '$lib/components/drillPicker'
|
||||
import {
|
||||
workspaceItemDisplayPath,
|
||||
type WorkspaceItem,
|
||||
type WorkspaceItemKind
|
||||
} from '$lib/components/workspacePicker'
|
||||
import { useWorkspaceItemsLoader } from '$lib/components/workspaceItemsLoader.svelte'
|
||||
import { buildWorkspaceTree, relativizeWorkspacePath } from '$lib/components/workspaceTree'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import type {
|
||||
ContextElement,
|
||||
WorkspaceAppElement,
|
||||
WorkspaceFlowElement,
|
||||
WorkspaceScriptElement
|
||||
} from './context'
|
||||
|
||||
interface Props {
|
||||
onSelect: (element: ContextElement) => void
|
||||
onAfterClose?: () => void
|
||||
close: (afterClose?: () => void) => void
|
||||
}
|
||||
|
||||
type DrillPickerHandle = {
|
||||
handleKeydown: (e: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
let { onSelect, onAfterClose, close }: Props = $props()
|
||||
let picker: DrillPickerHandle | undefined = $state()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const WORKSPACE_KINDS: WorkspaceItemKind[] = ['flow', 'script', 'app']
|
||||
const loader = useWorkspaceItemsLoader(
|
||||
() => $operatingWorkspace,
|
||||
() => WORKSPACE_KINDS
|
||||
)
|
||||
|
||||
const loadedForTree = $derived.by(() => {
|
||||
const loaded = loader.loaded
|
||||
if (!loaded.app) return loaded
|
||||
return { ...loaded, app: loaded.app.filter((a) => a.raw_app) }
|
||||
})
|
||||
|
||||
const tree = $derived<DrillNode<WorkspaceItem>[]>(
|
||||
buildWorkspaceTree({
|
||||
loaded: loadedForTree,
|
||||
kinds: WORKSPACE_KINDS,
|
||||
loadingKind: loader.loadingKind,
|
||||
layout: 'flat'
|
||||
}) as DrillNode<WorkspaceItem>[]
|
||||
)
|
||||
|
||||
function workspaceElement(item: WorkspaceItem): ContextElement | undefined {
|
||||
if (item.kind === 'script') {
|
||||
return {
|
||||
type: 'workspace_script',
|
||||
path: item.path,
|
||||
title: item.path,
|
||||
summary: item.summary,
|
||||
deletable: true
|
||||
} satisfies WorkspaceScriptElement & { deletable: boolean }
|
||||
}
|
||||
if (item.kind === 'flow') {
|
||||
return {
|
||||
type: 'workspace_flow',
|
||||
path: item.path,
|
||||
title: item.path,
|
||||
summary: item.summary,
|
||||
deletable: true
|
||||
} satisfies WorkspaceFlowElement & { deletable: boolean }
|
||||
}
|
||||
if (item.kind === 'app') {
|
||||
return {
|
||||
type: 'workspace_app',
|
||||
path: item.path,
|
||||
title: item.path,
|
||||
summary: item.summary,
|
||||
deletable: true
|
||||
} satisfies WorkspaceAppElement & { deletable: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
function handlePick(leaf: DrillLeaf<WorkspaceItem>) {
|
||||
const element = workspaceElement(leaf.data)
|
||||
if (!element) return
|
||||
onSelect(element)
|
||||
close(onAfterClose)
|
||||
}
|
||||
|
||||
function handleScopeChange(_scope: string[]) {
|
||||
loader.ensureAll()
|
||||
}
|
||||
|
||||
const pickerKeys = new Set([
|
||||
'ArrowDown',
|
||||
'ArrowUp',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Backspace',
|
||||
'Enter',
|
||||
'Tab',
|
||||
'Home',
|
||||
'End'
|
||||
])
|
||||
|
||||
function handleKeydownCapture(e: KeyboardEvent) {
|
||||
if (!pickerKeys.has(e.key)) return
|
||||
e.stopImmediatePropagation()
|
||||
picker?.handleKeydown(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet leafIcon(leaf: DrillLeaf<WorkspaceItem>)}
|
||||
<RowIcon kind={leaf.data.kind} size={12} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet branchIcon(branch: DrillBranch<WorkspaceItem>)}
|
||||
{#if branch.icon}
|
||||
{@const Icon = branch.icon}
|
||||
<Icon size={12} class="shrink-0 text-tertiary" />
|
||||
{:else}
|
||||
<Folder size={12} class="shrink-0 text-tertiary" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- Capture picker-owned keys before Melt's submenu listener handles menu movement. -->
|
||||
<div onkeydowncapture={handleKeydownCapture}>
|
||||
<DrillPicker
|
||||
bind:this={picker}
|
||||
{tree}
|
||||
onPick={handlePick}
|
||||
{leafIcon}
|
||||
{branchIcon}
|
||||
leafSecondary={(leaf, scope) => relativizeWorkspacePath(workspaceItemDisplayPath(leaf.data), scope)}
|
||||
onScopeChange={handleScopeChange}
|
||||
onFilterChange={loader.onFilterChange}
|
||||
rootLoading={WORKSPACE_KINDS.some((k) => !loader.loaded[k] && loader.loadingKind[k])}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MENTION_RE, mentionTitle, formatMention } from './mention'
|
||||
import {
|
||||
MENTION_RE,
|
||||
mentionTitle,
|
||||
formatMention,
|
||||
hasMention,
|
||||
mentionTitlesInText,
|
||||
removeMentionFromText
|
||||
} from './mention'
|
||||
|
||||
describe('formatMention', () => {
|
||||
it('leaves a simple name bare', () => {
|
||||
@@ -52,3 +59,83 @@ describe('MENTION_RE', () => {
|
||||
expect(mentionTitle(m[0])).toBe(name)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMentionFromText', () => {
|
||||
it('removes a bare mention and keeps neighboring words separated', () => {
|
||||
expect(removeMentionFromText('before @app.ts after', 'app.ts')).toBe('before after')
|
||||
})
|
||||
|
||||
it('removes a bracketed mention', () => {
|
||||
expect(removeMentionFromText('before @[my folder/a b.ts] after', 'my folder/a b.ts')).toBe(
|
||||
'before after'
|
||||
)
|
||||
})
|
||||
|
||||
it('removes an escaped bracketed mention', () => {
|
||||
expect(removeMentionFromText('before @[notes \\] draft.md] after', 'notes ] draft.md')).toBe(
|
||||
'before after'
|
||||
)
|
||||
})
|
||||
|
||||
it('removes edge mentions without leaving extra whitespace', () => {
|
||||
expect(removeMentionFromText('@app.ts after', 'app.ts')).toBe('after')
|
||||
expect(removeMentionFromText('before @app.ts', 'app.ts')).toBe('before')
|
||||
})
|
||||
|
||||
it('keeps embedded mention-like text intact', () => {
|
||||
expect(removeMentionFromText('Contact owner@app.ts about @app.ts', 'app.ts')).toBe(
|
||||
'Contact owner@app.ts about'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Unicode-prefixed embedded mention-like text intact', () => {
|
||||
expect(removeMentionFromText('Contact 用户@app.ts about @app.ts', 'app.ts')).toBe(
|
||||
'Contact 用户@app.ts about'
|
||||
)
|
||||
expect(removeMentionFromText('Contact हिंदी@app.ts about @app.ts', 'app.ts')).toBe(
|
||||
'Contact हिंदी@app.ts about'
|
||||
)
|
||||
expect(removeMentionFromText('Contact 𐐀@app.ts about @app.ts', 'app.ts')).toBe(
|
||||
'Contact 𐐀@app.ts about'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasMention', () => {
|
||||
it('does not treat embedded mention-like text as a selected context mention', () => {
|
||||
expect(hasMention('Contact owner@app.ts', 'app.ts')).toBe(false)
|
||||
expect(hasMention('Contact owner@app.ts about @app.ts', 'app.ts')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not treat Unicode-prefixed embedded text as a mention', () => {
|
||||
expect(hasMention('Contact 用户@app.ts', 'app.ts')).toBe(false)
|
||||
expect(hasMention('Contact हिंदी@app.ts', 'app.ts')).toBe(false)
|
||||
expect(hasMention('Contact 𐐀@app.ts', 'app.ts')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats punctuation next to a mention as a boundary', () => {
|
||||
expect(hasMention('use @app.ts, then compare', 'app.ts')).toBe(true)
|
||||
expect(hasMention('open (@app.ts)', 'app.ts')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mentionTitlesInText', () => {
|
||||
it('extracts only standalone mentions for context synchronization', () => {
|
||||
expect([...mentionTitlesInText('Contact owner@app.ts about @app.ts')]).toEqual(['app.ts'])
|
||||
expect([...mentionTitlesInText('Contact owner@app.ts')]).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores Unicode-prefixed embedded text for context synchronization', () => {
|
||||
expect([...mentionTitlesInText('Contact 用户@app.ts')]).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps punctuation-adjacent mentions synchronized', () => {
|
||||
expect([...mentionTitlesInText('use @app.ts, then compare')]).toEqual(['app.ts'])
|
||||
expect([...mentionTitlesInText('open (@app.ts)')]).toEqual(['app.ts'])
|
||||
})
|
||||
|
||||
it('ignores combining-mark and astral-letter embedded text', () => {
|
||||
expect([...mentionTitlesInText('Contact हिंदी@app.ts')]).toEqual([])
|
||||
expect([...mentionTitlesInText('Contact 𐐀@app.ts')]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,3 +29,59 @@ const BARE_SAFE = /^[\w/.\-]+$/
|
||||
export function formatMention(name: string): string {
|
||||
return BARE_SAFE.test(name) ? `@${name}` : `@[${name.replace(/[\\\]]/g, '\\$&')}]`
|
||||
}
|
||||
|
||||
function isWordChar(char: string | undefined): boolean {
|
||||
return char !== undefined && /[\p{L}\p{N}\p{M}_]/u.test(char)
|
||||
}
|
||||
|
||||
function codePointBefore(text: string, index: number): string | undefined {
|
||||
if (index <= 0) return undefined
|
||||
const prev = text.charCodeAt(index - 1)
|
||||
if (prev >= 0xdc00 && prev <= 0xdfff && index > 1) return text.slice(index - 2, index)
|
||||
return text[index - 1]
|
||||
}
|
||||
|
||||
// A mention is owned by this parser when the `@` starts a token. The trailing side is
|
||||
// intentionally open: non-spacing scripts often continue immediately after a mention,
|
||||
// while embedded forms like `owner@app.ts` are rejected by the leading boundary.
|
||||
export function hasMentionLeadingBoundary(text: string, index: number): boolean {
|
||||
return !isWordChar(codePointBefore(text, index))
|
||||
}
|
||||
|
||||
export function isStandaloneMention(text: string, match: RegExpMatchArray): boolean {
|
||||
if (match.index === undefined) return false
|
||||
return hasMentionLeadingBoundary(text, match.index)
|
||||
}
|
||||
|
||||
export function hasMention(text: string, title: string): boolean {
|
||||
return [...text.matchAll(MENTION_RE)].some(
|
||||
(m) => mentionTitle(m[0]) === title && isStandaloneMention(text, m)
|
||||
)
|
||||
}
|
||||
|
||||
export function mentionTitlesInText(text: string): Set<string> {
|
||||
const out = new Set<string>()
|
||||
for (const m of text.matchAll(MENTION_RE)) {
|
||||
if (isStandaloneMention(text, m)) out.add(mentionTitle(m[0]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function removeMentionFromText(text: string, title: string): string {
|
||||
let out = ''
|
||||
let last = 0
|
||||
for (const m of text.matchAll(MENTION_RE)) {
|
||||
if (m.index === undefined || mentionTitle(m[0]) !== title) continue
|
||||
let start = m.index
|
||||
let end = m.index + m[0].length
|
||||
if (!isStandaloneMention(text, m)) continue
|
||||
const hasLead = start > 0 && /\s/.test(text[start - 1])
|
||||
const hasTrail = end < text.length && /\s/.test(text[end])
|
||||
if (hasLead && !hasTrail) start -= 1
|
||||
if (!hasLead && hasTrail) end += 1
|
||||
if (hasLead && hasTrail) end += 1
|
||||
out += text.slice(last, start)
|
||||
last = end
|
||||
}
|
||||
return out + text.slice(last)
|
||||
}
|
||||
|
||||
@@ -1677,6 +1677,8 @@ export type Item = {
|
||||
iconColor?: string
|
||||
/** Extra props for `icon`, for an icon that does not take lucide's `size`. */
|
||||
iconProps?: Record<string, any>
|
||||
customSubmenu?: import('svelte').Component<any>
|
||||
customSubmenuProps?: Record<string, unknown>
|
||||
href?: string
|
||||
hrefTarget?: '_blank' | '_self' | '_parent' | '_top'
|
||||
disabled?: boolean
|
||||
|
||||
Reference in New Issue
Block a user