Squash merge of feature/story-206 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
119 lines
2.6 KiB
TypeScript
119 lines
2.6 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;
|
|
|
|
match = re.exec(text);
|
|
while (match !== 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;
|
|
match = re.exec(text);
|
|
}
|
|
|
|
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) => {
|
|
if (
|
|
part.type === "ref" &&
|
|
part.path !== undefined &&
|
|
part.line !== undefined
|
|
) {
|
|
return (
|
|
<CodeRefLink
|
|
key={`ref-${part.path}:${part.line}`}
|
|
path={part.path}
|
|
line={part.line}
|
|
>
|
|
{part.value}
|
|
</CodeRefLink>
|
|
);
|
|
}
|
|
return <span key={`text-${part.value}`}>{part.value}</span>;
|
|
})}
|
|
</>
|
|
);
|
|
}
|