| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226 |
- import { mentionRegex } from "../../../src/shared/context-mentions"
- import { Fzf } from "fzf"
- export function insertMention(
- text: string,
- position: number,
- value: string,
- ): { newValue: string; mentionIndex: number } {
- const beforeCursor = text.slice(0, position)
- const afterCursor = text.slice(position)
- // Find the position of the last '@' symbol before the cursor
- const lastAtIndex = beforeCursor.lastIndexOf("@")
- let newValue: string
- let mentionIndex: number
- if (lastAtIndex !== -1) {
- // If there's an '@' symbol, replace everything after it with the new mention
- const beforeMention = text.slice(0, lastAtIndex)
- newValue = beforeMention + "@" + value + " " + afterCursor.replace(/^[^\s]*/, "")
- mentionIndex = lastAtIndex
- } else {
- // If there's no '@' symbol, insert the mention at the cursor position
- newValue = beforeCursor + "@" + value + " " + afterCursor
- mentionIndex = position
- }
- return { newValue, mentionIndex }
- }
- export function removeMention(text: string, position: number): { newText: string; newPosition: number } {
- const beforeCursor = text.slice(0, position)
- const afterCursor = text.slice(position)
- // Check if we're at the end of a mention
- const matchEnd = beforeCursor.match(new RegExp(mentionRegex.source + "$"))
- if (matchEnd) {
- // If we're at the end of a mention, remove it
- const newText = text.slice(0, position - matchEnd[0].length) + afterCursor.replace(" ", "") // removes the first space after the mention
- const newPosition = position - matchEnd[0].length
- return { newText, newPosition }
- }
- // If we're not at the end of a mention, just return the original text and position
- return { newText: text, newPosition: position }
- }
- export enum ContextMenuOptionType {
- OpenedFile = "openedFile",
- File = "file",
- Folder = "folder",
- Problems = "problems",
- URL = "url",
- Git = "git",
- NoResults = "noResults",
- }
- export interface ContextMenuQueryItem {
- type: ContextMenuOptionType
- value?: string
- label?: string
- description?: string
- icon?: string
- }
- export function getContextMenuOptions(
- query: string,
- selectedType: ContextMenuOptionType | null = null,
- queryItems: ContextMenuQueryItem[],
- ): ContextMenuQueryItem[] {
- const workingChanges: ContextMenuQueryItem = {
- type: ContextMenuOptionType.Git,
- value: "git-changes",
- label: "Working changes",
- description: "Current uncommitted changes",
- icon: "$(git-commit)",
- }
- if (query === "") {
- if (selectedType === ContextMenuOptionType.File) {
- const files = queryItems
- .filter(
- (item) =>
- item.type === ContextMenuOptionType.File || item.type === ContextMenuOptionType.OpenedFile,
- )
- .map((item) => ({
- type: item.type,
- value: item.value,
- }))
- return files.length > 0 ? files : [{ type: ContextMenuOptionType.NoResults }]
- }
- if (selectedType === ContextMenuOptionType.Folder) {
- const folders = queryItems
- .filter((item) => item.type === ContextMenuOptionType.Folder)
- .map((item) => ({ type: ContextMenuOptionType.Folder, value: item.value }))
- return folders.length > 0 ? folders : [{ type: ContextMenuOptionType.NoResults }]
- }
- if (selectedType === ContextMenuOptionType.Git) {
- const commits = queryItems.filter((item) => item.type === ContextMenuOptionType.Git)
- return commits.length > 0 ? [workingChanges, ...commits] : [workingChanges]
- }
- return [
- { type: ContextMenuOptionType.Problems },
- { type: ContextMenuOptionType.URL },
- { type: ContextMenuOptionType.Folder },
- { type: ContextMenuOptionType.File },
- { type: ContextMenuOptionType.Git },
- ]
- }
- const lowerQuery = query.toLowerCase()
- const suggestions: ContextMenuQueryItem[] = []
- // Check for top-level option matches
- if ("git".startsWith(lowerQuery)) {
- suggestions.push({
- type: ContextMenuOptionType.Git,
- label: "Git Commits",
- description: "Search repository history",
- icon: "$(git-commit)",
- })
- } else if ("git-changes".startsWith(lowerQuery)) {
- suggestions.push(workingChanges)
- }
- if ("problems".startsWith(lowerQuery)) {
- suggestions.push({ type: ContextMenuOptionType.Problems })
- }
- if (query.startsWith("http")) {
- suggestions.push({ type: ContextMenuOptionType.URL, value: query })
- }
- // Add exact SHA matches to suggestions
- if (/^[a-f0-9]{7,40}$/i.test(lowerQuery)) {
- const exactMatches = queryItems.filter(
- (item) => item.type === ContextMenuOptionType.Git && item.value?.toLowerCase() === lowerQuery,
- )
- if (exactMatches.length > 0) {
- suggestions.push(...exactMatches)
- } else {
- // If no exact match but valid SHA format, add as option
- suggestions.push({
- type: ContextMenuOptionType.Git,
- value: lowerQuery,
- label: `Commit ${lowerQuery}`,
- description: "Git commit hash",
- icon: "$(git-commit)",
- })
- }
- }
- // Create searchable strings array for fzf
- const searchableItems = queryItems.map((item) => ({
- original: item,
- searchStr: [item.value, item.label, item.description].filter(Boolean).join(" "),
- }))
- // Initialize fzf instance for fuzzy search
- const fzf = new Fzf(searchableItems, {
- selector: (item) => item.searchStr,
- })
- // Get fuzzy matching items
- const matchingItems = query ? fzf.find(query).map((result) => result.item.original) : []
- // Separate matches by type
- const fileMatches = matchingItems.filter(
- (item) =>
- item.type === ContextMenuOptionType.File ||
- item.type === ContextMenuOptionType.OpenedFile ||
- item.type === ContextMenuOptionType.Folder,
- )
- const gitMatches = matchingItems.filter((item) => item.type === ContextMenuOptionType.Git)
- const otherMatches = matchingItems.filter(
- (item) =>
- item.type !== ContextMenuOptionType.File &&
- item.type !== ContextMenuOptionType.OpenedFile &&
- item.type !== ContextMenuOptionType.Folder &&
- item.type !== ContextMenuOptionType.Git,
- )
- // Combine suggestions with matching items in the desired order
- if (suggestions.length > 0 || matchingItems.length > 0) {
- const allItems = [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches]
- // Remove duplicates based on type and value
- const seen = new Set()
- const deduped = allItems.filter((item) => {
- const key = `${item.type}-${item.value}`
- if (seen.has(key)) return false
- seen.add(key)
- return true
- })
- return deduped
- }
- return [{ type: ContextMenuOptionType.NoResults }]
- }
- export function shouldShowContextMenu(text: string, position: number): boolean {
- const beforeCursor = text.slice(0, position)
- const atIndex = beforeCursor.lastIndexOf("@")
- if (atIndex === -1) return false
- const textAfterAt = beforeCursor.slice(atIndex + 1)
- // Check if there's any whitespace after the '@'
- if (/\s/.test(textAfterAt)) return false
- // Don't show the menu if it's a URL
- if (textAfterAt.toLowerCase().startsWith("http")) return false
- // Don't show the menu if it's a problems
- if (textAfterAt.toLowerCase().startsWith("problems")) return false
- // NOTE: it's okay that menu shows when there's trailing punctuation since user could be inputting a path with marks
- // Show the menu if there's just '@' or '@' followed by some text (but not a URL)
- return true
- }
|