fix(ui-conversation): decorate pasted file paths as whole tokens
Closed @"…" paths and unquoted @ tokens with a separator or extension now decorate in full, matching the spans the sent message renders as references.
This commit is contained in:
1 parent
c403ca4d77
commit
deebbf3191
3 files changed
+40
-26
No files matched your search
@@ -2,7 +2,7 @@
|
||||
* Plain-text reference scan (the plain-text-reference decision;
|
||||
* see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
|
||||
* a `/name` or `@name` token whose name is on the trigger's lexicon, and
|
||||
* syntax-recognizable `@dir/` folder tokens. Pure derivation — the editor's
|
||||
* syntax-recognizable `@path` file and folder tokens. Pure derivation — the editor's
|
||||
* text-ref entity transform consumes these ranges; editing the text out of
|
||||
* match shape simply drops the range next scan.
|
||||
*/
|
||||
@@ -10,9 +10,9 @@
|
||||
/**
|
||||
* One plain-text reference range (the plain-text-reference decision;
|
||||
* see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
|
||||
* a `/name` or `@name` token
|
||||
* whose name is on the trigger's lexicon. Pure derivation — editing the text
|
||||
* out of match shape simply drops the range next scan.
|
||||
* a `/name` or `@name` token whose name is on the trigger's lexicon, or an
|
||||
* `@path` file or folder token. Pure derivation — editing the text out of
|
||||
* match shape simply drops the range next scan.
|
||||
*/
|
||||
export interface TextRefRange {
|
||||
readonly start: number
|
||||
@@ -23,30 +23,40 @@ export interface TextRefRange {
|
||||
/** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */
|
||||
const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
|
||||
/** One `@` path token: a quoted path through its closing quote or line end, or an unquoted run. */
|
||||
const PATH_TOKEN_RE = /(^|\s)(@(?:"[^"\n]*"?|[^\s"]+))/g
|
||||
const PATH_TOKEN_RE = /(^|\s)(@(?:"[^"\n]*"?|[^\s"]\S*))/g
|
||||
/**
|
||||
* What may follow an unquoted or open-quoted folder token's slash: trailing
|
||||
* punctuation, then whitespace or the draft end. The class matches the
|
||||
* punctuation that `folderReferenceRule` in ui-reference strips from a sent token.
|
||||
* Punctuation that ends prose rather than a path: the characters the
|
||||
* sent-message file and folder rules in ui-reference strip from the end of an
|
||||
* unquoted token.
|
||||
*/
|
||||
const FOLDER_END_RE = /[.,;:!?,。;:!?]*(?:\s|$)/y
|
||||
const TRAILING_PUNCTUATION = new Set('.,;:!?,。;:!?')
|
||||
/** An unquoted token names a path only with a separator or an extension; a bare `@name` stays plain until a lexicon claims it. */
|
||||
const PATH_LIKE_RE = /^@.*[/\\.]/u
|
||||
|
||||
/**
|
||||
* End of the whole `@dir/` folder token at `start`, so `@src/a.ts` and
|
||||
* `@"a/b c.md"` never match their directory prefix. A closed quote ends the
|
||||
* token when it directly follows the slash; an unquoted or open-quoted token
|
||||
* (a drill in progress) ends at its last slash.
|
||||
* End of the whole `@path` file or folder token at `start`, so a path is never
|
||||
* cut at its directory prefix. A closed quote ends at its quote and an
|
||||
* unquoted token with a separator or extension drops trailing punctuation,
|
||||
* the spans the sent-message rules render. An open quote (a folder drill in
|
||||
* progress) ends at its last slash when only trailing punctuation and
|
||||
* whitespace or the draft end follow it.
|
||||
* @param draft - draft text.
|
||||
* @param start - offset of the token's `@`.
|
||||
* @param token - the path token at `start`.
|
||||
* @returns the folder token's end offset, or undefined for a non-folder token.
|
||||
* @returns the token's end offset, or undefined when it names no path.
|
||||
*/
|
||||
function folderEnd(draft: string, start: number, token: string): number | undefined {
|
||||
if (token.startsWith('@"') && token.endsWith('/"')) return start + token.length
|
||||
function pathEnd(draft: string, start: number, token: string): number | undefined {
|
||||
if (!token.startsWith('@"')) {
|
||||
let length = token.length
|
||||
while (TRAILING_PUNCTUATION.has(token.charAt(length - 1))) length -= 1
|
||||
return PATH_LIKE_RE.test(token.slice(0, length)) ? start + length : undefined
|
||||
}
|
||||
if (token.length > 3 && token.endsWith('"')) return start + token.length
|
||||
const slash = token.lastIndexOf('/')
|
||||
if (slash < 0 || token.endsWith('"')) return undefined
|
||||
FOLDER_END_RE.lastIndex = start + slash + 1
|
||||
return FOLDER_END_RE.test(draft) ? start + slash + 1 : undefined
|
||||
if (slash < 0) return undefined
|
||||
let next = start + slash + 1
|
||||
while (TRAILING_PUNCTUATION.has(draft.charAt(next))) next += 1
|
||||
return next === draft.length || /\s/u.test(draft.charAt(next)) ? start + slash + 1 : undefined
|
||||
}
|
||||
/**
|
||||
* What may follow a `/name` token: whitespace or the draft end, the boundary
|
||||
@@ -85,7 +95,7 @@ export function scanTextRefs(
|
||||
let path: RegExpExecArray | null
|
||||
while ((path = PATH_TOKEN_RE.exec(draft)) !== null) {
|
||||
const start = path.index + (path[1]?.length ?? 0)
|
||||
const end = folderEnd(draft, start, path[2] ?? '')
|
||||
const end = pathEnd(draft, start, path[2] ?? '')
|
||||
if (end !== undefined && !out.some(range => range.start < end && range.end > start)) {
|
||||
out.push({ start, end, trigger: '@' })
|
||||
}
|
||||
|
||||
@@ -812,10 +812,14 @@ describe('plain-text reference catalogs', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('matches whole folder tokens, never the directory prefix of a pasted file path', () => {
|
||||
expect(scanTextRefs('@src/deep/file.txt @"/a/nv/SM100 ISA.html" 看 @"a/ b.md" @src/.env @src/de', new Map())).toEqual([])
|
||||
const text = '@"dir a/" @src/deep/。 @dir/ 请看 @"src/",然后 @"open dir/ x'
|
||||
expect(scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end)))
|
||||
it('matches whole file and folder path tokens, never a directory prefix', () => {
|
||||
const slices = (text: string) => scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end))
|
||||
expect(slices('@src/deep/file.txt, @"/a/nv/SM100 ISA.html" 看 @"a/ b.md"。 @src/.env @C:\\src\\a.ts @src/de'))
|
||||
.toEqual(['@src/deep/file.txt', '@"/a/nv/SM100 ISA.html"', '@"a/ b.md"', '@src/.env', '@C:\\src\\a.ts', '@src/de'])
|
||||
expect(slices('@"dir a/" @src/deep/。 @dir/ 请看 @"src/",然后 @"open dir/ x'))
|
||||
.toEqual(['@"dir a/"', '@src/deep/', '@dir/', '@"src/"', '@"open dir/'])
|
||||
expect(slices('@person @"" @"a/b')).toEqual([])
|
||||
expect(slices('@"open @')).toEqual([])
|
||||
expect(slices('drill @"open dir/。')).toEqual(['@"open dir/'])
|
||||
})
|
||||
})
|
||||
@@ -1875,13 +1875,13 @@ describe('decorations', () => {
|
||||
expect(shell.snapshot.draft).toBe('see @src/components/')
|
||||
})
|
||||
|
||||
it('a pasted file path stays plain: only a whole folder token decorates', async () => {
|
||||
it('a pasted path decorates as one whole file or folder token', async () => {
|
||||
const { view, shell, textarea } = bench()
|
||||
const marks = () => [...view.container.querySelectorAll('[data-composer-text-ref]')].map(el => el.textContent)
|
||||
const text = 'read @"/a/nv/SM100 ISA.html" and @src/deep/file.txt then @src/deep/ first'
|
||||
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => text } })
|
||||
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe(text) })
|
||||
expect(marks()).toEqual(['@src/deep/'])
|
||||
expect(marks()).toEqual(['@"/a/nv/SM100 ISA.html"', '@src/deep/file.txt', '@src/deep/'])
|
||||
})
|
||||
|
||||
it('a plain-text reference keeps its DOM node while typing ahead of it (bug #2793 regression)', () => {
|
||||
|
||||
Reference in new issue
Block a user