feat(story-193): clickable code references in frontend
This commit is contained in:
108
frontend/src/components/CodeRef.tsx
Normal file
108
frontend/src/components/CodeRef.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as React from "react";
|
||||
import { settingsApi } from "../api/settings";
|
||||
|
||||
// Matches patterns like `src/main.rs:42` or `path/to/file.tsx:123`
|
||||
// Path must contain at least one dot (file extension) and a colon followed by digits.
|
||||
const CODE_REF_PATTERN = /\b([\w.\-/]+\.\w+):(\d+)\b/g;
|
||||
|
||||
export interface CodeRefPart {
|
||||
type: "text" | "ref";
|
||||
value: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string into text and code-reference parts.
|
||||
* Code references have the format `path/to/file.ext:line`.
|
||||
*/
|
||||
export function parseCodeRefs(text: string): CodeRefPart[] {
|
||||
const parts: CodeRefPart[] = [];
|
||||
let lastIndex = 0;
|
||||
const re = new RegExp(CODE_REF_PATTERN.source, "g");
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: "text", value: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
parts.push({
|
||||
type: "ref",
|
||||
value: match[0],
|
||||
path: match[1],
|
||||
line: Number(match[2]),
|
||||
});
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: "text", value: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
interface CodeRefLinkProps {
|
||||
path: string;
|
||||
line: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function CodeRefLink({ path, line, children }: CodeRefLinkProps) {
|
||||
const handleClick = React.useCallback(() => {
|
||||
settingsApi.openFile(path, line).catch(() => {
|
||||
// Silently ignore errors (e.g. no editor configured)
|
||||
});
|
||||
}, [path, line]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
title={`Open ${path}:${line} in editor`}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
color: "#7ec8e3",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "inherit",
|
||||
textDecoration: "underline",
|
||||
textDecorationStyle: "dotted",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface InlineCodeWithRefsProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders inline text with code references converted to clickable links.
|
||||
*/
|
||||
export function InlineCodeWithRefs({ text }: InlineCodeWithRefsProps) {
|
||||
const parts = parseCodeRefs(text);
|
||||
|
||||
if (parts.length === 1 && parts[0].type === "text") {
|
||||
return <>{text}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
if (part.type === "ref" && part.path !== undefined && part.line !== undefined) {
|
||||
return (
|
||||
<CodeRefLink key={`ref-${i}`} path={part.path} line={part.line}>
|
||||
{part.value}
|
||||
</CodeRefLink>
|
||||
);
|
||||
}
|
||||
return <span key={`text-${i}`}>{part.value}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user