context-mentions.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { mentionRegex } from "../../../src/shared/context-mentions"
  2. import { Fzf } from "fzf"
  3. export function insertMention(
  4. text: string,
  5. position: number,
  6. value: string,
  7. ): { newValue: string; mentionIndex: number } {
  8. const beforeCursor = text.slice(0, position)
  9. const afterCursor = text.slice(position)
  10. // Find the position of the last '@' symbol before the cursor
  11. const lastAtIndex = beforeCursor.lastIndexOf("@")
  12. let newValue: string
  13. let mentionIndex: number
  14. if (lastAtIndex !== -1) {
  15. // If there's an '@' symbol, replace everything after it with the new mention
  16. const beforeMention = text.slice(0, lastAtIndex)
  17. newValue = beforeMention + "@" + value + " " + afterCursor.replace(/^[^\s]*/, "")
  18. mentionIndex = lastAtIndex
  19. } else {
  20. // If there's no '@' symbol, insert the mention at the cursor position
  21. newValue = beforeCursor + "@" + value + " " + afterCursor
  22. mentionIndex = position
  23. }
  24. return { newValue, mentionIndex }
  25. }
  26. export function removeMention(text: string, position: number): { newText: string; newPosition: number } {
  27. const beforeCursor = text.slice(0, position)
  28. const afterCursor = text.slice(position)
  29. // Check if we're at the end of a mention
  30. const matchEnd = beforeCursor.match(new RegExp(mentionRegex.source + "$"))
  31. if (matchEnd) {
  32. // If we're at the end of a mention, remove it
  33. const newText = text.slice(0, position - matchEnd[0].length) + afterCursor.replace(" ", "") // removes the first space after the mention
  34. const newPosition = position - matchEnd[0].length
  35. return { newText, newPosition }
  36. }
  37. // If we're not at the end of a mention, just return the original text and position
  38. return { newText: text, newPosition: position }
  39. }
  40. export enum ContextMenuOptionType {
  41. OpenedFile = "openedFile",
  42. File = "file",
  43. Folder = "folder",
  44. Problems = "problems",
  45. URL = "url",
  46. Git = "git",
  47. NoResults = "noResults",
  48. }
  49. export interface ContextMenuQueryItem {
  50. type: ContextMenuOptionType
  51. value?: string
  52. label?: string
  53. description?: string
  54. icon?: string
  55. }
  56. export function getContextMenuOptions(
  57. query: string,
  58. selectedType: ContextMenuOptionType | null = null,
  59. queryItems: ContextMenuQueryItem[],
  60. ): ContextMenuQueryItem[] {
  61. const workingChanges: ContextMenuQueryItem = {
  62. type: ContextMenuOptionType.Git,
  63. value: "git-changes",
  64. label: "Working changes",
  65. description: "Current uncommitted changes",
  66. icon: "$(git-commit)",
  67. }
  68. if (query === "") {
  69. if (selectedType === ContextMenuOptionType.File) {
  70. const files = queryItems
  71. .filter(
  72. (item) =>
  73. item.type === ContextMenuOptionType.File || item.type === ContextMenuOptionType.OpenedFile,
  74. )
  75. .map((item) => ({
  76. type: item.type,
  77. value: item.value,
  78. }))
  79. return files.length > 0 ? files : [{ type: ContextMenuOptionType.NoResults }]
  80. }
  81. if (selectedType === ContextMenuOptionType.Folder) {
  82. const folders = queryItems
  83. .filter((item) => item.type === ContextMenuOptionType.Folder)
  84. .map((item) => ({ type: ContextMenuOptionType.Folder, value: item.value }))
  85. return folders.length > 0 ? folders : [{ type: ContextMenuOptionType.NoResults }]
  86. }
  87. if (selectedType === ContextMenuOptionType.Git) {
  88. const commits = queryItems.filter((item) => item.type === ContextMenuOptionType.Git)
  89. return commits.length > 0 ? [workingChanges, ...commits] : [workingChanges]
  90. }
  91. return [
  92. { type: ContextMenuOptionType.Problems },
  93. { type: ContextMenuOptionType.URL },
  94. { type: ContextMenuOptionType.Folder },
  95. { type: ContextMenuOptionType.File },
  96. { type: ContextMenuOptionType.Git },
  97. ]
  98. }
  99. const lowerQuery = query.toLowerCase()
  100. const suggestions: ContextMenuQueryItem[] = []
  101. // Check for top-level option matches
  102. if ("git".startsWith(lowerQuery)) {
  103. suggestions.push({
  104. type: ContextMenuOptionType.Git,
  105. label: "Git Commits",
  106. description: "Search repository history",
  107. icon: "$(git-commit)",
  108. })
  109. } else if ("git-changes".startsWith(lowerQuery)) {
  110. suggestions.push(workingChanges)
  111. }
  112. if ("problems".startsWith(lowerQuery)) {
  113. suggestions.push({ type: ContextMenuOptionType.Problems })
  114. }
  115. if (query.startsWith("http")) {
  116. suggestions.push({ type: ContextMenuOptionType.URL, value: query })
  117. }
  118. // Add exact SHA matches to suggestions
  119. if (/^[a-f0-9]{7,40}$/i.test(lowerQuery)) {
  120. const exactMatches = queryItems.filter(
  121. (item) => item.type === ContextMenuOptionType.Git && item.value?.toLowerCase() === lowerQuery,
  122. )
  123. if (exactMatches.length > 0) {
  124. suggestions.push(...exactMatches)
  125. } else {
  126. // If no exact match but valid SHA format, add as option
  127. suggestions.push({
  128. type: ContextMenuOptionType.Git,
  129. value: lowerQuery,
  130. label: `Commit ${lowerQuery}`,
  131. description: "Git commit hash",
  132. icon: "$(git-commit)",
  133. })
  134. }
  135. }
  136. // Create searchable strings array for fzf
  137. const searchableItems = queryItems.map((item) => ({
  138. original: item,
  139. searchStr: [item.value, item.label, item.description].filter(Boolean).join(" "),
  140. }))
  141. // Initialize fzf instance for fuzzy search
  142. const fzf = new Fzf(searchableItems, {
  143. selector: (item) => item.searchStr,
  144. })
  145. // Get fuzzy matching items
  146. const matchingItems = query ? fzf.find(query).map((result) => result.item.original) : []
  147. // Separate matches by type
  148. const fileMatches = matchingItems.filter(
  149. (item) =>
  150. item.type === ContextMenuOptionType.File ||
  151. item.type === ContextMenuOptionType.OpenedFile ||
  152. item.type === ContextMenuOptionType.Folder,
  153. )
  154. const gitMatches = matchingItems.filter((item) => item.type === ContextMenuOptionType.Git)
  155. const otherMatches = matchingItems.filter(
  156. (item) =>
  157. item.type !== ContextMenuOptionType.File &&
  158. item.type !== ContextMenuOptionType.OpenedFile &&
  159. item.type !== ContextMenuOptionType.Folder &&
  160. item.type !== ContextMenuOptionType.Git,
  161. )
  162. // Combine suggestions with matching items in the desired order
  163. if (suggestions.length > 0 || matchingItems.length > 0) {
  164. const allItems = [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches]
  165. // Remove duplicates based on type and value
  166. const seen = new Set()
  167. const deduped = allItems.filter((item) => {
  168. const key = `${item.type}-${item.value}`
  169. if (seen.has(key)) return false
  170. seen.add(key)
  171. return true
  172. })
  173. return deduped
  174. }
  175. return [{ type: ContextMenuOptionType.NoResults }]
  176. }
  177. export function shouldShowContextMenu(text: string, position: number): boolean {
  178. const beforeCursor = text.slice(0, position)
  179. const atIndex = beforeCursor.lastIndexOf("@")
  180. if (atIndex === -1) return false
  181. const textAfterAt = beforeCursor.slice(atIndex + 1)
  182. // Check if there's any whitespace after the '@'
  183. if (/\s/.test(textAfterAt)) return false
  184. // Don't show the menu if it's a URL
  185. if (textAfterAt.toLowerCase().startsWith("http")) return false
  186. // Don't show the menu if it's a problems
  187. if (textAfterAt.toLowerCase().startsWith("problems")) return false
  188. // NOTE: it's okay that menu shows when there's trailing punctuation since user could be inputting a path with marks
  189. // Show the menu if there's just '@' or '@' followed by some text (but not a URL)
  190. return true
  191. }