Skip to content

Commit a93fb5a

Browse files
committed
feat(textarea): support mentioning url
1 parent 19e7f77 commit a93fb5a

File tree

8 files changed

+131
-32
lines changed

8 files changed

+131
-32
lines changed

webview-ui/src/components/chat/ChatContextBar.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,25 @@ export const ChatContextBar = ({
3131
const items: ContextItem[] = []
3232

3333
validMentions.forEach((mention, index) => {
34+
let iconAlt = "File"
35+
if (mention.type === "folder") {
36+
iconAlt = "Folder"
37+
} else if (mention.type === "url") {
38+
iconAlt = "URL"
39+
} else if (mention.type === "problems") {
40+
iconAlt = "Problems"
41+
} else if (mention.type === "terminal") {
42+
iconAlt = "Terminal"
43+
} else if (mention.type === "git") {
44+
iconAlt = "Git"
45+
}
46+
3447
items.push({
3548
type: "mention",
3649
icon: mention.icon,
3750
displayName: mention.displayName,
3851
originalIndex: index,
39-
iconAlt: mention.type === "folder" ? "Folder" : "File",
52+
iconAlt,
4053
})
4154
})
4255

webview-ui/src/components/chat/ChatLexicalTextArea.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -444,12 +444,10 @@ export const ChatLexicalTextArea = forwardRef<LexicalEditor, ChatTextAreaProps>(
444444
setSelectedType(null)
445445

446446
const insertMention = mentionPluginRef.current?.insertMention
447-
if (insertMention && value) {
447+
if (insertMention) {
448448
let insertValue = value || ""
449449

450-
if (type === ContextMenuOptionType.URL) {
451-
insertValue = value || ""
452-
} else if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
450+
if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
453451
insertValue = value || ""
454452
} else if (type === ContextMenuOptionType.Problems) {
455453
insertValue = "problems"
@@ -486,6 +484,12 @@ export const ChatLexicalTextArea = forwardRef<LexicalEditor, ChatTextAreaProps>(
486484
}
487485
}, [showContextMenu, isMouseDownOnMenu])
488486

487+
useEffect(() => {
488+
if (!showContextMenu) {
489+
setSelectedType(null)
490+
}
491+
}, [showContextMenu])
492+
489493
const handleMenuMouseDown = useCallback(() => {
490494
setIsMouseDownOnMenu(true)
491495
}, [])

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -414,10 +414,9 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
414414

415415
if (optionsLength === 0) return prevIndex
416416

417-
// Find selectable options (non-URL types)
417+
// Find selectable options
418418
const selectableOptions = options.filter(
419419
(option) =>
420-
option.type !== ContextMenuOptionType.URL &&
421420
option.type !== ContextMenuOptionType.NoResults &&
422421
option.type !== ContextMenuOptionType.SectionHeader,
423422
)
@@ -450,7 +449,6 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
450449
)[selectedMenuIndex]
451450
if (
452451
selectedOption &&
453-
selectedOption.type !== ContextMenuOptionType.URL &&
454452
selectedOption.type !== ContextMenuOptionType.NoResults &&
455453
selectedOption.type !== ContextMenuOptionType.SectionHeader
456454
) {

webview-ui/src/components/chat/ContextMenu.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,11 +247,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
247247
}
248248

249249
const isOptionSelectable = (option: ContextMenuQueryItem): boolean => {
250-
return (
251-
option.type !== ContextMenuOptionType.NoResults &&
252-
option.type !== ContextMenuOptionType.URL &&
253-
option.type !== ContextMenuOptionType.SectionHeader
254-
)
250+
return option.type !== ContextMenuOptionType.NoResults && option.type !== ContextMenuOptionType.SectionHeader
255251
}
256252

257253
const handleSettingsClick = (e: React.MouseEvent) => {

webview-ui/src/components/chat/lexical/LexicalContextMenuPlugin.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,9 @@ export function LexicalContextMenuPlugin({
7979

8080
if (optionsLength === 0) return prevIndex
8181

82-
// Find selectable options (non-URL types)
82+
// Find selectable options
8383
const selectableOptions = options.filter(
8484
(option) =>
85-
option.type !== ContextMenuOptionType.URL &&
8685
option.type !== ContextMenuOptionType.NoResults &&
8786
option.type !== ContextMenuOptionType.SectionHeader,
8887
)
@@ -124,10 +123,9 @@ export function LexicalContextMenuPlugin({
124123

125124
if (optionsLength === 0) return prevIndex
126125

127-
// Find selectable options (non-URL types)
126+
// Find selectable options
128127
const selectableOptions = options.filter(
129128
(option) =>
130-
option.type !== ContextMenuOptionType.URL &&
131129
option.type !== ContextMenuOptionType.NoResults &&
132130
option.type !== ContextMenuOptionType.SectionHeader,
133131
)
@@ -167,7 +165,6 @@ export function LexicalContextMenuPlugin({
167165
)[selectedMenuIndex]
168166
if (
169167
selectedOption &&
170-
selectedOption.type !== ContextMenuOptionType.URL &&
171168
selectedOption.type !== ContextMenuOptionType.NoResults &&
172169
selectedOption.type !== ContextMenuOptionType.SectionHeader
173170
) {
@@ -196,7 +193,6 @@ export function LexicalContextMenuPlugin({
196193
)[selectedMenuIndex]
197194
if (
198195
selectedOption &&
199-
selectedOption.type !== ContextMenuOptionType.URL &&
200196
selectedOption.type !== ContextMenuOptionType.NoResults &&
201197
selectedOption.type !== ContextMenuOptionType.SectionHeader
202198
) {

webview-ui/src/components/chat/lexical/LexicalMentionPlugin.tsx

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface MentionInfo {
2323
path: string
2424
displayName: string
2525
icon: string
26-
type: "file" | "folder"
26+
type: "file" | "folder" | "url" | "problems" | "terminal" | "git"
2727
}
2828

2929
export interface LexicalMentionPluginRef {
@@ -129,23 +129,55 @@ export const LexicalMentionPlugin = forwardRef<LexicalMentionPluginRef, LexicalM
129129
// Extract just the paths for display name calculation
130130
const mentionPaths = mentionNodes.map((node) => node.path)
131131

132+
// SVG data URL for link icon
133+
const linkIconSvg = `data:image/svg+xml,${encodeURIComponent(
134+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"/></svg>',
135+
)}`
136+
132137
// Convert to MentionInfo objects with all necessary display information
133138
const mentions: MentionInfo[] = mentionNodes.map((node) => {
134139
// Use stored type data if available, otherwise fall back to path-based detection
135140
const storedType = node.data?.type
136-
let type: "file" | "folder"
137-
138-
if (storedType === "file" || storedType === "folder") {
139-
type = storedType
141+
let type: "file" | "folder" | "url" | "problems" | "terminal" | "git"
142+
let displayName: string
143+
let icon: string
144+
145+
// Determine type and display info based on stored data or path
146+
if (storedType === ContextMenuOptionType.URL) {
147+
type = "url"
148+
displayName = node.path
149+
icon = linkIconSvg
150+
} else if (storedType === ContextMenuOptionType.Problems) {
151+
type = "problems"
152+
displayName = "Problems"
153+
icon = linkIconSvg // You can change this to a different icon if needed
154+
} else if (storedType === ContextMenuOptionType.Terminal) {
155+
type = "terminal"
156+
displayName = "Terminal"
157+
icon = linkIconSvg // You can change this to a different icon if needed
158+
} else if (storedType === ContextMenuOptionType.Git) {
159+
type = "git"
160+
displayName = node.path
161+
icon = linkIconSvg // You can change this to a different icon if needed
162+
} else if (storedType === ContextMenuOptionType.File) {
163+
type = "file"
164+
displayName = getDisplayName(node.path, mentionPaths)
165+
icon = getMaterialIconForMention(node.path, "file")
166+
} else if (storedType === ContextMenuOptionType.Folder) {
167+
type = "folder"
168+
displayName = getDisplayName(node.path, mentionPaths)
169+
icon = getMaterialIconForMention(node.path, "folder")
140170
} else {
141171
// Fall back to path-based detection for backward compatibility
142172
type = isFolder(node.path) ? "folder" : "file"
173+
displayName = getDisplayName(node.path, mentionPaths)
174+
icon = getMaterialIconForMention(node.path, type)
143175
}
144176

145177
return {
146178
path: node.path,
147-
displayName: getDisplayName(node.path, mentionPaths),
148-
icon: getMaterialIconForMention(node.path, type),
179+
displayName,
180+
icon,
149181
type,
150182
}
151183
})

webview-ui/src/components/chat/lexical/LexicalPastePlugin.tsx

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext
22
import {
33
$getSelection,
44
$isRangeSelection,
5+
$isTextNode,
56
$createTextNode,
67
COMMAND_PRIORITY_HIGH,
78
PASTE_COMMAND,
@@ -59,10 +60,73 @@ export const LexicalPastePlugin = ({
5960
return true
6061
}
6162

62-
// Handle URL pasting - let Lexical handle it normally
63+
// Handle URL pasting after @
6364
const urlRegex = /^\S+:\/\/\S+$/
6465
if (text && urlRegex.test(text.trim())) {
65-
// Let Lexical's default paste handle URLs
66+
// Check if cursor is after @
67+
const anchor = selection.anchor
68+
const anchorNode = anchor.getNode()
69+
70+
if (anchorNode && anchorNode.getTextContent) {
71+
const textContent = anchorNode.getTextContent()
72+
const offset = anchor.offset
73+
const textBeforeCursor = textContent.slice(0, offset)
74+
75+
// Check if there's an @ before the cursor
76+
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
77+
if (lastAtIndex !== -1 && lastAtIndex === offset - 1) {
78+
// Cursor is right after @, convert to mention
79+
event.preventDefault()
80+
81+
editor.update(() => {
82+
const currentSelection = $getSelection()
83+
if (!$isRangeSelection(currentSelection)) {
84+
return
85+
}
86+
87+
const currentAnchor = currentSelection.anchor
88+
const currentNode = currentAnchor.getNode()
89+
90+
// Ensure we're working with a text node
91+
if (!$isTextNode(currentNode)) {
92+
return
93+
}
94+
95+
const currentOffset = currentAnchor.offset
96+
const currentText = currentNode.getTextContent()
97+
98+
const atIndex = currentText.lastIndexOf("@", currentOffset - 1)
99+
if (atIndex === -1) return
100+
101+
const beforeText = currentText.slice(0, atIndex)
102+
const afterText = currentText.slice(currentOffset)
103+
104+
const mentionNode = $createMentionNode(text.trim(), "@", undefined, {
105+
type: ContextMenuOptionType.URL,
106+
})
107+
108+
if (beforeText) {
109+
currentNode.setTextContent(beforeText)
110+
currentNode.insertAfter(mentionNode)
111+
} else {
112+
currentNode.replace(mentionNode)
113+
}
114+
115+
if (afterText) {
116+
const afterTextNode = new TextNode(afterText)
117+
mentionNode.insertAfter(afterTextNode)
118+
}
119+
120+
const spaceNode = new TextNode(" ")
121+
mentionNode.insertAfter(spaceNode)
122+
spaceNode.select()
123+
})
124+
125+
return true
126+
}
127+
}
128+
129+
// Let Lexical's default paste handle URLs if not after @
66130
return false
67131
}
68132

webview-ui/src/utils/context-mentions.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,6 @@ export function getContextMenuOptions(
252252
return [
253253
{ type: ContextMenuOptionType.Problems },
254254
{ type: ContextMenuOptionType.Terminal },
255-
{ type: ContextMenuOptionType.URL },
256255
{ type: ContextMenuOptionType.Folder },
257256
{ type: ContextMenuOptionType.File },
258257
{ type: ContextMenuOptionType.Git },
@@ -279,9 +278,6 @@ export function getContextMenuOptions(
279278
if ("terminal".startsWith(lowerQuery)) {
280279
suggestions.push({ type: ContextMenuOptionType.Terminal })
281280
}
282-
if (query.startsWith("http")) {
283-
suggestions.push({ type: ContextMenuOptionType.URL, value: query })
284-
}
285281

286282
// Add exact SHA matches to suggestions
287283
if (/^[a-f0-9]{7,40}$/i.test(lowerQuery)) {

0 commit comments

Comments
 (0)