Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chat: support line numbers in at-files #3174

Merged
merged 17 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions lib/shared/src/chat/input/at-mentioned.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { it, expect, describe } from 'vitest'

import { getAtMentionedInputText, extractMentionQuery } from './at-mentioned'

describe('getAtMentionedInputText', () => {
it('returns null when filePath is empty', () => {
const result = getAtMentionedInputText('', 'Hello @world', 5)
expect(result).toBeUndefined()
})

it('returns null when caretPosition is invalid', () => {
const result = getAtMentionedInputText('@src/file.ts', 'Hello world', -1)
expect(result).toBeUndefined()
})

// Explain:
// 1. Text is from the user form input with the {CURSOR} representing the caretPosition:
// 'Hello @user/fil{CURSOR} @another/file.ts'
// 2. When a user hits tab / space, we will replace the {CURSOR} with the "completed" file name:
// 'Hello @user/file.ts @another/file.ts'
it('replaces all at-mentions', () => {
const result = getAtMentionedInputText(
'@src/file.ts', // file name
'Hello @user/fil @another/file.ts', // form input
'Hello @user/fil'.length // caretPosition
)
expect(result).toEqual({
newInput: 'Hello @src/file.ts @another/file.ts',
newInputCaretPosition: 19,
})
})

it('handles at-mention with no preceding space', () => {
const result = getAtMentionedInputText(
'@src/file.ts',
'Hello @src/file.ts',
'Hello @src/file.ts'.length
)
expect(result).toEqual({
newInput: 'Hello @src/file.ts ',
newInputCaretPosition: 19,
})
})

it('returns undefined if no @ in input', () => {
const result = getAtMentionedInputText('@src/file.ts', 'Hello world', 5)
expect(result).toBeUndefined()
})

it('returns updated input text and caret position', () => {
const result = getAtMentionedInputText(
'@src/file.ts',
'Hello @src/file.ts: world',
'Hello @src/file.ts:'.length,
true
)
expect(result).toEqual({
newInput: 'Hello @src/file.ts: world',
newInputCaretPosition: 19,
})
})

it('handles no text after caret', () => {
const result = getAtMentionedInputText(
'@src/file.ts',
'Hello @src/file.ts ',
'Hello @src/file.ts'.length
)
expect(result).toEqual({
newInput: 'Hello @src/file.ts ',
newInputCaretPosition: 19,
})
})

it('handles colon based on param', () => {
let result = getAtMentionedInputText(
'@src/file.ts',
'Hello @src/file.ts ',
'Hello @src/file.ts '.length,
true
)
expect(result?.newInput).toContain('@src/file.ts:')

result = getAtMentionedInputText(
'@src/file.ts',
'Hello @src/file.ts',
'Hello @src/file.ts'.length,
false
)
expect(result?.newInput).toContain('@src/file.ts ')
})

it('keeps range', () => {
const result = getAtMentionedInputText(
'@src/file.ts',
'Hello @src/file.ts:1-7',
'Hello @src/file.ts:1-7'.length,
false
)
expect(result?.newInput).toContain('Hello @src/file.ts:1-7 ')
})
})

describe('extractMentionQuery', () => {
it('returns empty string if no @ in input', () => {
const query = extractMentionQuery('Hello world', 'Hello world'.length)
expect(query).toEqual('')
})

it('returns empty string if caret before last @', () => {
const query = extractMentionQuery('@foo Hello world', 0)
expect(query).toEqual('')
})

it('returns empty string if there is no space in front of @', () => {
const query = extractMentionQuery('Explain@foo', 0)
expect(query).toEqual('')
})

it('extracts mention between last @ and caret', () => {
const query = extractMentionQuery('@foo/bar Hello @world', '@foo/bar Hello @world'.length)
expect(query).toEqual('@world')
})

it('handles no text and space after caret', () => {
const query = extractMentionQuery('@foo/bar', '@foo/bar'.length)
expect(query).toEqual('@foo/bar')
})

it('handles space at caret after query', () => {
const query = extractMentionQuery('@foo/bar ', '@foo/bar '.length)
expect(query).toEqual('@foo/bar ')
})

it('returns full mention query with suffix', () => {
const query = extractMentionQuery('@foo/bar:10 world', '@foo/bar:10 '.length)
expect(query).toEqual('@foo/bar:10 world')
})
})
133 changes: 133 additions & 0 deletions lib/shared/src/chat/input/at-mentioned.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { ContextFile } from '../../codebase-context/messages'
import { displayPath } from '../../editor/displayPath'

/**
* Generates updated input text and caret position for auto-completing
* @mentions in a chat input box.
*
* Takes the file path display text, current input text, caret position,
* and whether the query ends with colon, and returns an object with the
* updated input text and new caret position.
*
* Trims any existing @file text, inserts the new file path display text,
* retains any text after the caret position, and moves the caret to the
* end of the inserted display text.
*/
export function getAtMentionedInputText(
fileDisplayText: string,
formInput: string,
inputCaretPosition: number,
queryEndsWithColon = false
): { newInput: string; newInputCaretPosition: number } | undefined {
const inputBeforeCaret = formInput.slice(0, inputCaretPosition) || ''
const lastAtIndex = inputBeforeCaret.lastIndexOf('@')
if (lastAtIndex < 0 || !formInput.trim()) {
return undefined
}

if (/:\d+(-)?(\d+)?( )?$/.test(inputBeforeCaret)) {
// Add a space after inputBeforeCaret to formInput
const newInput = formInput.replace(inputBeforeCaret, `${inputBeforeCaret} `)
return { newInput, newInputCaretPosition: inputCaretPosition }
}

// Trims any existing @file text from the input.
const inputPrefix = inputBeforeCaret.slice(0, lastAtIndex)
const afterCaret = formInput.slice(inputCaretPosition)
const spaceAfterCaret = afterCaret.indexOf(' ')
const inputSuffix = !spaceAfterCaret ? afterCaret : afterCaret.slice(spaceAfterCaret)
// Add empty space at the end to end the file matching process,
// if the query ends with colon, add a colon instead as it's used for initial range selection.
const colon = queryEndsWithColon ? ':' : ''
const newInput = `${inputPrefix}${fileDisplayText}${colon} ${inputSuffix.trimStart()}`

// Move the caret to the end of the newly added file display text,
// including the length of text exisited before the lastAtIndex
// + 1 empty whitespace added after the fileDisplayText
const newInputCaretPosition = fileDisplayText.length + inputPrefix.length + 1
return { newInput, newInputCaretPosition }
}

/**
* Gets the display text for a context file to be completed into the chat when a user
* selects a file.
*
* This is also used to reconstruct the map from the chat history or edit that stores context
* files).
*
* e.g. @foo/bar.ts or @foo/bar.ts:1-15#baz
*/
export function getContextFileDisplayText(contextFile: ContextFile, inputBeforeCaret?: string): string {
const isSymbol = contextFile.type === 'symbol'
const displayText = `@${displayPath(contextFile.uri)}`

// If the inputBeforeCaret string is provided, check if it matches the
// expected pattern for an @mention with range query, and if so,
// return it as this is not an autocomplete request.
if (inputBeforeCaret) {
const AtQueryRegex = /(^| )@[^ ]*:\d+(-\d+)?$/
const atQuery = AtQueryRegex.exec(inputBeforeCaret)?.[0]
if (atQuery?.startsWith(`${displayText}:`)) {
return atQuery
}
}

if (!isSymbol) {
return displayText
}

const startLine = contextFile.range?.start?.line ?? 0
const endLine = contextFile.range?.end?.line
const range = endLine ? `:${startLine + 1}-${endLine + 1}` : ''
const symbolName = isSymbol ? `#${contextFile.symbolName}` : ''
return `${displayText}${range}${symbolName}`.trim()
}

/**
* Extracts the mention query string from the given input string and caret position.
*
* Splits the input into before and after caret sections. Finds the last '@' before
* the caret and extracts the text between it and the caret position as the mention
* query.
*/
export const extractMentionQuery = (input: string, caretPos: number) => {
// Extract mention query by splitting input value into before/after caret sections.
const inputBeforeCaret = input.slice(0, caretPos) || ''
const inputAfterCaret = input.slice(caretPos) || ''
// Find the last '@' index in inputBeforeCaret to determine if it's an @mention
const lastAtIndex = inputBeforeCaret.lastIndexOf('@')
if (caretPos < 1 || lastAtIndex < 0 || caretPos <= lastAtIndex) {
return ''
}
if (lastAtIndex - 1 > 0 && input[lastAtIndex - 1] !== ' ') {
return ''
}

// Extracts text between last '@' and caret position as mention query
// by getting the input value after the last '@' in inputBeforeCaret
const inputPrefix = inputBeforeCaret.slice(lastAtIndex)
const inputSuffix = inputAfterCaret.split(' ')?.[0]
return inputPrefix + inputSuffix
}

/**
* Extracts the at mention query from the given input string and caret position.
*
* Calls extractMentionQuery to extract the mention query if there is a caret position.
* Otherwise checks if it is an at range query and returns the input.
* Returns empty string if no query.
*/
export const getAtMentionQuery = (input: string, caretPos: number) => {
return caretPos ? extractMentionQuery(input, caretPos) : isAtRange(input) ? input : ''
}

/**
* At mention should start with @ and contains no whitespaces in between
*/
export const isAtMention = (text: string) => /^@[^ ]*( )?$/.test(text)

/**
* Checks if the given text is an at-range query of the form '@start:end'
* or '@start-end'.
*/
export const isAtRange = (text: string) => /(^| )@[^ ]*:(\d+)?(-)?(\d+)?$/.test(text)
8 changes: 8 additions & 0 deletions lib/shared/src/chat/input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
getAtMentionQuery,
getAtMentionedInputText,
getContextFileDisplayText,
isAtRange,
isAtMention,
} from './at-mentioned'
export { verifyContextFilesFromInput } from './user-context'
Loading
Loading