Skip to content

Commit e027454

Browse files
committed
feat: allow linking posthog error events
1 parent 35c721f commit e027454

File tree

5 files changed

+459
-48
lines changed

5 files changed

+459
-48
lines changed

src/renderer/components/FileMentionList.tsx

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,19 @@ import {
88
useRef,
99
useState,
1010
} from "react";
11+
import type { MentionItem } from "@shared/types";
1112

12-
export interface FileMentionListProps {
13-
items: Array<{ path: string; name: string }>;
14-
command: (item: { id: string; label: string }) => void;
13+
export interface MentionListProps {
14+
items: MentionItem[];
15+
command: (item: { id: string; label: string; type?: string; urlId?: string }) => void;
1516
}
1617

17-
export interface FileMentionListRef {
18+
export interface MentionListRef {
1819
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
1920
}
2021

21-
export const FileMentionList = forwardRef(
22-
(props: FileMentionListProps, ref: ForwardedRef<FileMentionListRef>) => {
22+
export const MentionList = forwardRef(
23+
(props: MentionListProps, ref: ForwardedRef<MentionListRef>) => {
2324
const [selectedIndex, setSelectedIndex] = useState(0);
2425
const containerRef = useRef<HTMLDivElement>(null);
2526
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
@@ -48,7 +49,22 @@ export const FileMentionList = forwardRef(
4849
const selectItem = (index: number) => {
4950
const item = props.items[index];
5051
if (item) {
51-
props.command({ id: item.path, label: item.name });
52+
if (item.path) {
53+
// File item
54+
props.command({
55+
id: item.path,
56+
label: item.name || item.path.split('/').pop() || item.path,
57+
type: 'file'
58+
});
59+
} else if (item.url) {
60+
// URL item
61+
props.command({
62+
id: item.url,
63+
label: item.label || item.url,
64+
type: item.type || 'generic',
65+
urlId: item.urlId
66+
});
67+
}
5268
}
5369
};
5470

@@ -131,24 +147,44 @@ export const FileMentionList = forwardRef(
131147
overflow: "auto",
132148
}}
133149
>
134-
{props.items.map((item, index) => (
135-
<Flex
136-
key={item.path}
137-
ref={(el) => {
138-
itemRefs.current[index] = el;
139-
}}
140-
className={`file-mention-item ${index === selectedIndex ? "is-selected" : ""}`}
141-
onClick={() => selectItem(index)}
142-
onMouseEnter={() => setSelectedIndex(index)}
143-
>
144-
<Flex direction="column" gap="1">
145-
<Text size="1">{item.path}</Text>
150+
{props.items.map((item, index) => {
151+
const key = item.path || item.url || `item-${index}`;
152+
const displayText = item.path ? item.path : (item.label || item.url || 'Unknown item');
153+
const itemType = item.type === 'file' ? 'File' : (item.type || 'URL');
154+
155+
return (
156+
<Flex
157+
key={key}
158+
ref={(el) => {
159+
itemRefs.current[index] = el;
160+
}}
161+
className={`file-mention-item ${index === selectedIndex ? "is-selected" : ""}`}
162+
onClick={() => selectItem(index)}
163+
onMouseEnter={() => setSelectedIndex(index)}
164+
style={{
165+
padding: "var(--space-2)",
166+
cursor: "pointer",
167+
backgroundColor: index === selectedIndex ? "var(--gray-a3)" : "transparent",
168+
borderRadius: "var(--radius-1)",
169+
}}
170+
>
171+
<Flex direction="column" gap="1">
172+
<Text size="2" weight="medium">{displayText}</Text>
173+
{item.type && item.type !== 'file' && (
174+
<Text size="1" color="gray">{itemType}</Text>
175+
)}
176+
</Flex>
146177
</Flex>
147-
</Flex>
148-
))}
178+
);
179+
})}
149180
</Box>
150181
);
151182
},
152183
);
153184

154-
FileMentionList.displayName = "FileMentionList";
185+
MentionList.displayName = "MentionList";
186+
187+
// Backward compatibility export
188+
export const FileMentionList = MentionList;
189+
export type FileMentionListRef = MentionListRef;
190+
export type FileMentionListProps = MentionListProps;

src/renderer/components/RichTextEditor.tsx

Lines changed: 160 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import type { SuggestionOptions } from "@tiptap/suggestion";
1010
import { useEffect, useRef, useState } from "react";
1111
import { createRoot } from "react-dom/client";
1212
import { 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";
1415
import { FormattingToolbar } from "./FormattingToolbar";
16+
import type { MentionItem } from "@shared/types";
1517

1618
interface 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

Comments
 (0)