Render file reference as link with file preview on hover/click in web app

Overview
- Khoj references files it used in its response as markdown links.
  For example [1](file://path/to/file.txt#line=121)
- Previously these file links were just shown as raw text
- This change renders khoj's inline file references as a proper links
  and shows file content preview (around specified line if deeplink)
  on hover or click in the web app

Details
- Render inline file references as links in chat message on web app.
  Previously references like [1](file://path/to/file.txt#line=120)
  would be shown as plain text. Now they are rendered as links
- Preview file content of referenced files on click or hover.
  If reference uses a deeplink with line number, the file content
  around that line is shown on hover, click. Click allows viewing file
  preview on mobile, unlike hover. Hover is easier with mouse.
This commit is contained in:
Debanjum
2025-08-21 00:06:21 -07:00
parent d8b7e9c8a5
commit e2f377c27b
5 changed files with 454 additions and 4 deletions

View File

@@ -6,11 +6,18 @@ import markdownIt from "markdown-it";
import mditHljs from "markdown-it-highlightjs";
import React, { useEffect, useRef, useState, forwardRef } from "react";
import { createRoot } from "react-dom/client";
import { createPortal } from "react-dom";
import "katex/dist/katex.min.css";
import { TeaserReferencesSection, constructAllReferences } from "../referencePanel/referencePanel";
import {
TeaserReferencesSection,
constructAllReferences,
} from "@/app/components/referencePanel/referencePanel";
import { renderCodeGenImageInline } from "@/app/common/chatFunctions";
import { fileLinksPlugin } from "@/app/components/chatMessage/fileLinksPlugin";
import FileContentSnippet from "@/app/components/chatMessage/FileContentSnippet";
import { useFileContent } from "@/app/components/chatMessage/useFileContent";
import {
ThumbsUp,
@@ -41,7 +48,6 @@ import { convertColorToTextClass } from "@/app/common/colorUtils";
import { AgentData } from "@/app/components/agentCard/agentCard";
import renderMathInElement from "katex/contrib/auto-render";
import "katex/dist/katex.min.css";
import ExcalidrawComponent from "../excalidraw/excalidraw";
import { AttachedFileText } from "../chatInputArea/chatInputArea";
import {
@@ -68,6 +74,8 @@ md.use(mditHljs, {
code: true,
});
md.use(fileLinksPlugin);
export interface Context {
compiled: string;
file: string;
@@ -395,6 +403,23 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
const [excalidrawData, setExcalidrawData] = useState<string>("");
const [mermaidjsData, setMermaidjsData] = useState<string>("");
// State for file content preview on file link click, hover
const [previewOpen, setPreviewOpen] = useState<boolean>(false);
const [previewFilePath, setPreviewFilePath] = useState<string>("");
const [previewLineNumber, setPreviewLineNumber] = useState<number | undefined>(undefined);
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const [previewContent, setPreviewContent] = useState<string>("");
const [hoverOpen, setHoverOpen] = useState<boolean>(false);
const [hoverFilePath, setHoverFilePath] = useState<string>("");
const [hoverLineNumber, setHoverLineNumber] = useState<number | undefined>(undefined);
const [hoverLoading, setHoverLoading] = useState<boolean>(false);
const [hoverError, setHoverError] = useState<string | null>(null);
const [hoverContent, setHoverContent] = useState<string>("");
const [hoverPos, setHoverPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const hoverCloseTimeoutRef = useRef<number | null>(null);
const interruptedRef = useRef<boolean>(false);
const messageRef = useRef<HTMLDivElement>(null);
@@ -451,6 +476,13 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
// Replace file links with base64 data
message = renderCodeGenImageInline(message, props.chatMessage.codeContext);
// Preprocess file:// links so markdown-it processes them
// We convert them to a custom scheme (filelink://) and handle in the plugin
message = message.replace(/\[([^\]]+)\]\(file:\/\/([^)]+)\)/g, (match, text, path) => {
// Use a special scheme that markdown-it will process
return `[${text}](filelink://${path})`;
});
// Add code context files to the message
if (props.chatMessage.codeContext) {
Object.entries(props.chatMessage.codeContext).forEach(([key, value]) => {
@@ -504,7 +536,12 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
.replace(/RIGHTBRACKET/g, "\\]");
// Sanitize and set the rendered markdown
setMarkdownRendered(DOMPurify.sanitize(markdownRendered));
// Configure DOMPurify to allow file link attributes
const cleanMarkdown = DOMPurify.sanitize(markdownRendered, {
ADD_ATTR: ["data-file-path", "data-line-number"],
});
setMarkdownRendered(cleanMarkdown);
}, [props.chatMessage.message, props.chatMessage.images, props.chatMessage.intent]);
useEffect(() => {
@@ -542,6 +579,90 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
}
});
// Event delegation on the message container for reliability
const container = messageRef.current;
const delegatedPointerDown = (ev: Event) => {
const e = ev as MouseEvent;
const target = e.target as HTMLElement | null;
const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
if (!anchor) return;
e.preventDefault();
e.stopPropagation();
const path = anchor.getAttribute("data-file-path") || "";
const line = anchor.getAttribute("data-line-number") || undefined;
if (!path) return;
// Close hover popover if open
setHoverOpen(false);
setPreviewFilePath(path);
setPreviewLineNumber(line ? parseInt(line) : undefined);
setPreviewOpen(true);
};
let currentHoverAnchor: HTMLAnchorElement | null = null;
const delegatedMouseOver = (ev: Event) => {
const e = ev as MouseEvent;
const target = e.target as HTMLElement | null;
const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
if (!anchor) return;
if (currentHoverAnchor === anchor) return;
currentHoverAnchor = anchor;
const rect = anchor.getBoundingClientRect();
const path = anchor.getAttribute("data-file-path") || "";
const line = anchor.getAttribute("data-line-number") || undefined;
if (!path) return;
setHoverPos({ x: Math.max(8, rect.left), y: rect.bottom + 6 });
setHoverFilePath(path);
setHoverLineNumber(line ? parseInt(line) : undefined);
if (hoverCloseTimeoutRef.current) {
window.clearTimeout(hoverCloseTimeoutRef.current);
hoverCloseTimeoutRef.current = null;
}
// Open immediately for reliability
setHoverOpen(true);
};
const delegatedMouseOut = (ev: Event) => {
const e = ev as MouseEvent;
const target = e.target as HTMLElement | null;
const related = e.relatedTarget as HTMLElement | null;
const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
const stillInsideAnchor = !!(related && anchor && anchor.contains(related));
// If moving between descendants of the same anchor, ignore
if (stillInsideAnchor) return;
// Schedule close; will be canceled if we move into the popover
if (hoverCloseTimeoutRef.current) {
window.clearTimeout(hoverCloseTimeoutRef.current);
}
hoverCloseTimeoutRef.current = window.setTimeout(() => {
setHoverOpen(false);
currentHoverAnchor = null;
hoverCloseTimeoutRef.current = null;
}, 200);
};
const delegatedKeyDown = (ev: Event) => {
const e = ev as KeyboardEvent;
const target = e.target as HTMLElement | null;
const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
if (!anchor) return;
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
const path = anchor.getAttribute("data-file-path") || "";
const line = anchor.getAttribute("data-line-number") || undefined;
if (!path) return;
setHoverOpen(false);
setPreviewFilePath(path);
setPreviewLineNumber(line ? parseInt(line) : undefined);
setPreviewOpen(true);
};
container.addEventListener("pointerdown", delegatedPointerDown);
container.addEventListener("keydown", delegatedKeyDown);
container.addEventListener("mouseover", delegatedMouseOver);
container.addEventListener("mouseout", delegatedMouseOut);
renderMathInElement(messageRef.current, {
delimiters: [
{ left: "$$", right: "$$", display: true },
@@ -549,8 +670,51 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
{ left: "\\(", right: "\\)", display: false },
],
});
// Cleanup old listeners when content changes
return () => {
container.removeEventListener("pointerdown", delegatedPointerDown);
container.removeEventListener("keydown", delegatedKeyDown);
container.removeEventListener("mouseover", delegatedMouseOver);
container.removeEventListener("mouseout", delegatedMouseOut);
if (hoverCloseTimeoutRef.current) {
window.clearTimeout(hoverCloseTimeoutRef.current);
hoverCloseTimeoutRef.current = null;
}
};
}
}, [markdownRendered, isHovering, messageRef]);
}, [markdownRendered, messageRef]);
// Fetch file content for dialog and hover using shared hook
const {
content: previewContentHook,
loading: previewLoadingHook,
error: previewErrorHook,
} = useFileContent(previewFilePath, previewOpen);
const {
content: hoverContentHook,
loading: hoverLoadingHook,
error: hoverErrorHook,
} = useFileContent(hoverFilePath, hoverOpen);
useEffect(() => {
setPreviewContent(previewContentHook);
}, [previewContentHook]);
useEffect(() => {
setPreviewLoading(previewLoadingHook);
}, [previewLoadingHook]);
useEffect(() => {
setPreviewError(previewErrorHook);
}, [previewErrorHook]);
useEffect(() => {
setHoverContent(hoverContentHook);
}, [hoverContentHook]);
useEffect(() => {
setHoverLoading(hoverLoadingHook);
}, [hoverLoadingHook]);
useEffect(() => {
setHoverError(hoverErrorHook);
}, [hoverErrorHook]);
function formatDate(timestamp: string) {
// Format date in HH:MM, DD MMM YYYY format
@@ -761,6 +925,120 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
className={styles.chatMessage}
dangerouslySetInnerHTML={{ __html: markdownRendered }}
/>
{/* File preview hover dialog */}
{hoverOpen &&
typeof window !== "undefined" &&
createPortal(
<div
onMouseEnter={() => {
if (hoverCloseTimeoutRef.current) {
window.clearTimeout(hoverCloseTimeoutRef.current);
hoverCloseTimeoutRef.current = null;
}
setHoverOpen(true);
}}
onMouseDown={(e) => {
// If user clicks the hover preview, open the dialog for the same file
e.preventDefault();
e.stopPropagation();
setHoverOpen(false);
if (hoverFilePath) {
setPreviewFilePath(hoverFilePath);
setPreviewLineNumber(hoverLineNumber);
setPreviewOpen(true);
}
}}
onMouseLeave={() => {
if (hoverCloseTimeoutRef.current) {
window.clearTimeout(hoverCloseTimeoutRef.current);
}
hoverCloseTimeoutRef.current = window.setTimeout(() => {
setHoverOpen(false);
hoverCloseTimeoutRef.current = null;
}, 200);
}}
style={{
position: "fixed",
left: hoverPos.x,
top: hoverPos.y,
zIndex: 9999,
}}
className="w-96 max-h-80 rounded-md border bg-popover p-4 text-popover-foreground shadow-md"
>
<div className="space-y-2">
<div className="flex items-center text-sm font-medium">
<span className="truncate">
{hoverFilePath.split("/").pop() || hoverFilePath}
</span>
{hoverLineNumber && (
<span className="text-gray-500 ml-2">
- Line {hoverLineNumber}
</span>
)}
</div>
<ScrollArea className="max-h-60">
{hoverLoading && (
<div className="flex items-center justify-center p-4">
<InlineLoading />
</div>
)}
{!hoverLoading && hoverError && (
<div className="p-3 text-red-500 text-sm">
Error: {hoverError}
</div>
)}
{!hoverLoading && !hoverError && (
<div className="text-sm">
<FileContentSnippet
content={hoverContent}
targetLine={hoverLineNumber}
maxLines={8}
/>
</div>
)}
</ScrollArea>
</div>
</div>,
document.body,
)}
{/* File preview popup dialog */}
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
<div className="truncate min-w-0 break-words break-all text-wrap max-w-full whitespace-normal">
{previewFilePath.split("/").pop() || previewFilePath}
<span className="text-gray-500 ml-2">
{previewLineNumber ? `- Line ${previewLineNumber}` : ""}
</span>
</div>
</DialogTitle>
</DialogHeader>
<div className="text-left">
<ScrollArea className="h-80 w-full rounded-md">
{previewLoading && (
<div className="flex items-center justify-center p-4">
<InlineLoading />
</div>
)}
{!previewLoading && previewError && (
<div className="p-3 text-red-500 text-sm">
Error: {previewError}
</div>
)}
{!previewLoading && !previewError && (
<div className="text-sm">
<FileContentSnippet
content={previewContent}
targetLine={previewLineNumber}
maxLines={20}
/>
</div>
)}
</ScrollArea>
</div>
</DialogContent>
</Dialog>
{excalidrawData && <ExcalidrawComponent data={excalidrawData} />}
{mermaidjsData && <Mermaid chart={mermaidjsData} />}
</div>