@@ -10,8 +10,10 @@ import type { SuggestionOptions } from "@tiptap/suggestion";
1010import { useEffect , useRef , useState } from "react" ;
1111import { createRoot } from "react-dom/client" ;
1212import { markdownToTiptap , tiptapToMarkdown } from "../utils/tiptap-converter" ;
13- import { FileMentionList , type FileMentionListRef } from "./FileMentionList" ;
13+ import { parsePostHogUrl , isUrl , extractUrlFromMarkdown } from "../utils/posthog-url-parser" ;
14+ import { MentionList , type MentionListRef } from "./FileMentionList" ;
1415import { FormattingToolbar } from "./FormattingToolbar" ;
16+ import type { MentionItem } from "@shared/types" ;
1517
1618interface RichTextEditorProps {
1719 value : string ;
@@ -38,15 +40,15 @@ export function RichTextEditor({
3840 showToolbar = true ,
3941 minHeight = "100px" ,
4042} : RichTextEditorProps ) {
41- const [ files , setFiles ] = useState < Array < { path : string ; name : string } > > ( [ ] ) ;
42- const filesRef = useRef ( files ) ;
43+ const [ mentionItems , setMentionItems ] = useState < MentionItem [ ] > ( [ ] ) ;
44+ const mentionItemsRef = useRef ( mentionItems ) ;
4345 const onChangeRef = useRef ( onChange ) ;
4446 const repoPathRef = useRef ( repoPath ) ;
4547
4648 // Keep refs updated
4749 useEffect ( ( ) => {
48- filesRef . current = files ;
49- } , [ files ] ) ;
50+ mentionItemsRef . current = mentionItems ;
51+ } , [ mentionItems ] ) ;
5052
5153 useEffect ( ( ) => {
5254 onChangeRef . current = onChange ;
@@ -75,26 +77,116 @@ export function RichTextEditor({
7577 Placeholder . configure ( {
7678 placeholder,
7779 } ) ,
78- Mention . configure ( {
80+ Mention . extend ( {
81+ addAttributes ( ) {
82+ return {
83+ id : {
84+ default : null ,
85+ } ,
86+ label : {
87+ default : null ,
88+ } ,
89+ type : {
90+ default : 'file' ,
91+ } ,
92+ urlId : {
93+ default : null ,
94+ } ,
95+ } ;
96+ } ,
97+ renderText ( { node } ) {
98+ // Use the label for display, fallback to id
99+ return `@${ node . attrs . label || node . attrs . id } ` ;
100+ } ,
101+ } ) . configure ( {
79102 HTMLAttributes : {
80103 class : "file-mention" ,
81104 } ,
82105 suggestion : {
106+ char : '@' ,
107+ allowSpaces : true ,
108+ command : ( { editor, range, props } ) => {
109+ // Insert mention with all attributes
110+ const nodeAfter = editor . view . state . selection . $to . nodeAfter ;
111+ const overrideSpace = nodeAfter ?. text ?. startsWith ( ' ' ) ;
112+
113+ if ( overrideSpace ) {
114+ range . to += 1 ;
115+ }
116+
117+ editor
118+ . chain ( )
119+ . focus ( )
120+ . insertContentAt ( range , [
121+ {
122+ type : 'mention' ,
123+ attrs : {
124+ id : props . id ,
125+ label : props . label ,
126+ type : props . type || 'file' ,
127+ urlId : props . urlId ,
128+ } ,
129+ } ,
130+ {
131+ type : 'text' ,
132+ text : ' ' ,
133+ } ,
134+ ] )
135+ . run ( ) ;
136+ } ,
83137 items : async ( { query } : { query : string } ) => {
84- if ( ! repoPathRef . current ) return [ ] ;
138+ const items : MentionItem [ ] = [ ] ;
139+
140+ // Extract URL from markdown link syntax if present: [text](url)
141+ const urlToCheck = extractUrlFromMarkdown ( query ) ;
142+
143+ // Check if the query looks like a URL
144+ if ( isUrl ( urlToCheck ) ) {
145+ const postHogInfo = parsePostHogUrl ( urlToCheck ) ;
146+ if ( postHogInfo ) {
147+ // It's a PostHog URL
148+ items . push ( {
149+ url : urlToCheck ,
150+ type : postHogInfo . type ,
151+ label : postHogInfo . label ,
152+ id : postHogInfo . id ,
153+ urlId : postHogInfo . id ,
154+ } ) ;
155+ } else {
156+ // It's a generic URL
157+ try {
158+ const urlObj = new URL ( urlToCheck ) ;
159+ items . push ( {
160+ url : urlToCheck ,
161+ type : 'generic' ,
162+ label : urlObj . hostname ,
163+ } ) ;
164+ } catch {
165+ // Invalid URL, ignore
166+ }
167+ }
168+ }
85169
86- try {
87- const results = await window . electronAPI ?. listRepoFiles (
88- repoPathRef . current ,
89- query ,
90- ) ;
91- const fileList = results || [ ] ;
92- setFiles ( fileList ) ;
93- return fileList ;
94- } catch ( error ) {
95- console . error ( "Error fetching files:" , error ) ;
96- return [ ] ;
170+ // Only search for files if we haven't detected a URL
171+ if ( repoPathRef . current && query . length > 0 && ! isUrl ( urlToCheck ) ) {
172+ try {
173+ const results = await window . electronAPI ?. listRepoFiles (
174+ repoPathRef . current ,
175+ query ,
176+ ) ;
177+ const fileItems = ( results || [ ] ) . map ( ( file ) => ( {
178+ path : file . path ,
179+ name : file . name ,
180+ type : 'file' as const ,
181+ } ) ) ;
182+ items . push ( ...fileItems ) ;
183+ } catch ( error ) {
184+ console . error ( "Error fetching files:" , error ) ;
185+ }
97186 }
187+
188+ setMentionItems ( items ) ;
189+ return items ;
98190 } ,
99191 render : ( ) => {
100192 let component : { destroy : ( ) => void } | null = null ;
@@ -123,13 +215,13 @@ export function RichTextEditor({
123215 } ,
124216 } ;
125217
126- const ref : { current : FileMentionListRef | null } = {
218+ const ref : { current : MentionListRef | null } = {
127219 current : null ,
128220 } ;
129221
130222 root . render (
131- < FileMentionList
132- items = { filesRef . current }
223+ < MentionList
224+ items = { mentionItemsRef . current }
133225 command = { props . command }
134226 ref = { ( instance ) => {
135227 ref . current = instance ;
@@ -144,13 +236,13 @@ export function RichTextEditor({
144236 onUpdate : ( props : any ) => {
145237 if ( ! root ) return ;
146238
147- const ref : { current : FileMentionListRef | null } = {
239+ const ref : { current : MentionListRef | null } = {
148240 current : null ,
149241 } ;
150242
151243 root . render (
152- < FileMentionList
153- items = { filesRef . current }
244+ < MentionList
245+ items = { mentionItemsRef . current }
154246 command = { props . command }
155247 ref = { ( instance ) => {
156248 ref . current = instance ;
@@ -208,6 +300,50 @@ export function RichTextEditor({
208300 class : "rich-text-editor-content" ,
209301 style : `outline: none; min-height: ${ minHeight } ; padding: var(--space-3);` ,
210302 } ,
303+ handlePaste : ( view , event ) => {
304+ // Check if we're in a mention context (text before cursor has @)
305+ const { state } = view ;
306+ const { selection } = state ;
307+ const { $from } = selection ;
308+
309+ // Get text before cursor in current paragraph
310+ const textBefore = $from . parent . textBetween (
311+ Math . max ( 0 , $from . parentOffset - 500 ) ,
312+ $from . parentOffset ,
313+ undefined ,
314+ '\ufffc'
315+ ) ;
316+
317+ // Check if there's an @ symbol before the cursor without any whitespace before it
318+ // or if @ is the last character before cursor (just typed @)
319+ const lastAtIndex = textBefore . lastIndexOf ( '@' ) ;
320+ if ( lastAtIndex !== - 1 ) {
321+ const textAfterAt = textBefore . substring ( lastAtIndex + 1 ) ;
322+
323+ // We're in mention mode if:
324+ // 1. There's no space between @ and cursor, OR
325+ // 2. @ is immediately before cursor
326+ if ( ! textAfterAt . includes ( ' ' ) || lastAtIndex === textBefore . length - 1 ) {
327+ const clipboardData = event . clipboardData ;
328+ if ( clipboardData ) {
329+ const pastedText = clipboardData . getData ( 'text/plain' ) . trim ( ) ;
330+
331+ // If pasted content is a URL, prevent default paste and insert as plain text
332+ if ( pastedText && isUrl ( pastedText ) ) {
333+ event . preventDefault ( ) ;
334+
335+ // Insert the URL as plain text to trigger mention suggestion
336+ const transaction = state . tr . insertText ( pastedText ) ;
337+ view . dispatch ( transaction ) ;
338+
339+ return true ;
340+ }
341+ }
342+ }
343+ }
344+
345+ return false ;
346+ } ,
211347 handleKeyDown : ( _view , event ) => {
212348 // Custom keyboard shortcuts
213349 if ( event . ctrlKey || event . metaKey ) {
0 commit comments