109 lines
2.5 KiB
TypeScript
109 lines
2.5 KiB
TypeScript
|
|
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>;
|
||
|
|
})}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|