feat(story-193): clickable code references in frontend
This commit is contained in:
@@ -2,6 +2,10 @@ export interface EditorSettings {
|
|||||||
editor_command: string | null;
|
editor_command: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenFileResult {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_API_BASE = "/api";
|
const DEFAULT_API_BASE = "/api";
|
||||||
|
|
||||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||||
@@ -47,4 +51,20 @@ export const settingsApi = {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openFile(
|
||||||
|
path: string,
|
||||||
|
line?: number,
|
||||||
|
baseUrl?: string,
|
||||||
|
): Promise<OpenFileResult> {
|
||||||
|
const params = new URLSearchParams({ path });
|
||||||
|
if (line !== undefined) {
|
||||||
|
params.set("line", String(line));
|
||||||
|
}
|
||||||
|
return requestJson<OpenFileResult>(
|
||||||
|
`/settings/open-file?${params.toString()}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
87
frontend/src/components/CodeRef.test.tsx
Normal file
87
frontend/src/components/CodeRef.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { InlineCodeWithRefs, parseCodeRefs } from "./CodeRef";
|
||||||
|
|
||||||
|
// Mock the settingsApi so we don't make real HTTP calls in tests
|
||||||
|
vi.mock("../api/settings", () => ({
|
||||||
|
settingsApi: {
|
||||||
|
openFile: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("parseCodeRefs (Story 193)", () => {
|
||||||
|
it("returns a single text part for plain text with no code refs", () => {
|
||||||
|
const parts = parseCodeRefs("Hello world, no code here");
|
||||||
|
expect(parts).toHaveLength(1);
|
||||||
|
expect(parts[0]).toEqual({ type: "text", value: "Hello world, no code here" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a simple code reference", () => {
|
||||||
|
const parts = parseCodeRefs("src/main.rs:42");
|
||||||
|
expect(parts).toHaveLength(1);
|
||||||
|
expect(parts[0]).toMatchObject({ type: "ref", path: "src/main.rs", line: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a code reference embedded in surrounding text", () => {
|
||||||
|
const parts = parseCodeRefs("See src/lib.rs:100 for details");
|
||||||
|
expect(parts).toHaveLength(3);
|
||||||
|
expect(parts[0]).toEqual({ type: "text", value: "See " });
|
||||||
|
expect(parts[1]).toMatchObject({ type: "ref", path: "src/lib.rs", line: 100 });
|
||||||
|
expect(parts[2]).toEqual({ type: "text", value: " for details" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects multiple code references", () => {
|
||||||
|
const parts = parseCodeRefs("Check src/a.rs:1 and src/b.ts:200");
|
||||||
|
const refs = parts.filter((p) => p.type === "ref");
|
||||||
|
expect(refs).toHaveLength(2);
|
||||||
|
expect(refs[0]).toMatchObject({ path: "src/a.rs", line: 1 });
|
||||||
|
expect(refs[1]).toMatchObject({ path: "src/b.ts", line: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match text without a file extension", () => {
|
||||||
|
const parts = parseCodeRefs("something:42");
|
||||||
|
// "something" has no dot so it should not match
|
||||||
|
expect(parts.every((p) => p.type === "text")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches nested paths with multiple slashes", () => {
|
||||||
|
const parts = parseCodeRefs("frontend/src/components/Chat.tsx:55");
|
||||||
|
expect(parts).toHaveLength(1);
|
||||||
|
expect(parts[0]).toMatchObject({
|
||||||
|
type: "ref",
|
||||||
|
path: "frontend/src/components/Chat.tsx",
|
||||||
|
line: 55,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InlineCodeWithRefs component (Story 193)", () => {
|
||||||
|
it("renders plain text without buttons", () => {
|
||||||
|
render(<InlineCodeWithRefs text="just some text" />);
|
||||||
|
expect(screen.getByText("just some text")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a code reference as a clickable button", () => {
|
||||||
|
render(<InlineCodeWithRefs text="src/main.rs:42" />);
|
||||||
|
const button = screen.getByRole("button", { name: /src\/main\.rs:42/ });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
expect(button).toHaveAttribute("title", "Open src/main.rs:42 in editor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls settingsApi.openFile when a code reference is clicked", async () => {
|
||||||
|
const { settingsApi } = await import("../api/settings");
|
||||||
|
render(<InlineCodeWithRefs text="src/main.rs:42" />);
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(settingsApi.openFile).toHaveBeenCalledWith("src/main.rs", 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders mixed text and code references correctly", () => {
|
||||||
|
render(<InlineCodeWithRefs text="See src/lib.rs:10 for the impl" />);
|
||||||
|
// getByText normalizes text (trims whitespace), so "See " → "See"
|
||||||
|
expect(screen.getByText("See")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("for the impl")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
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>;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { MessageItem } from "./MessageItem";
|
import { MessageItem } from "./MessageItem";
|
||||||
|
|
||||||
|
vi.mock("../api/settings", () => ({
|
||||||
|
settingsApi: {
|
||||||
|
openFile: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe("MessageItem component (Story 178 AC3)", () => {
|
describe("MessageItem component (Story 178 AC3)", () => {
|
||||||
it("renders user message as a bubble", () => {
|
it("renders user message as a bubble", () => {
|
||||||
render(<MessageItem msg={{ role: "user", content: "Hello there!" }} />);
|
render(<MessageItem msg={{ role: "user", content: "Hello there!" }} />);
|
||||||
@@ -72,6 +78,22 @@ describe("MessageItem component (Story 178 AC3)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("MessageItem code reference rendering (Story 193)", () => {
|
||||||
|
it("renders inline code with a code reference as a clickable button in assistant messages", () => {
|
||||||
|
render(
|
||||||
|
<MessageItem
|
||||||
|
msg={{
|
||||||
|
role: "assistant",
|
||||||
|
content: "Check `src/main.rs:42` for the implementation.",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: /src\/main\.rs:42/ });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("MessageItem user message code fence rendering (Story 196)", () => {
|
describe("MessageItem user message code fence rendering (Story 196)", () => {
|
||||||
it("renders code fences in user messages as code blocks", () => {
|
it("renders code fences in user messages as code blocks", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -3,23 +3,29 @@ import Markdown from "react-markdown";
|
|||||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
import type { Message, ToolCall } from "../types";
|
import type { Message, ToolCall } from "../types";
|
||||||
|
import { InlineCodeWithRefs } from "./CodeRef";
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
|
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
|
||||||
function CodeBlock({ className, children, ...props }: any) {
|
function CodeBlock({ className, children, ...props }: any) {
|
||||||
const match = /language-(\w+)/.exec(className || "");
|
const match = /language-(\w+)/.exec(className || "");
|
||||||
const isInline = !className;
|
const isInline = !className;
|
||||||
return !isInline && match ? (
|
const text = String(children);
|
||||||
|
if (!isInline && match) {
|
||||||
|
return (
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: oneDark style types are incompatible
|
// biome-ignore lint/suspicious/noExplicitAny: oneDark style types are incompatible
|
||||||
style={oneDark as any}
|
style={oneDark as any}
|
||||||
language={match[1]}
|
language={match[1]}
|
||||||
PreTag="div"
|
PreTag="div"
|
||||||
>
|
>
|
||||||
{String(children).replace(/\n$/, "")}
|
{text.replace(/\n$/, "")}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
) : (
|
);
|
||||||
|
}
|
||||||
|
// For inline code, detect and render code references as clickable links
|
||||||
|
return (
|
||||||
<code className={className} {...props}>
|
<code className={className} {...props}>
|
||||||
{children}
|
<InlineCodeWithRefs text={text} />
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||||
use crate::store::StoreOps;
|
use crate::store::StoreOps;
|
||||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -22,6 +22,11 @@ struct EditorCommandResponse {
|
|||||||
editor_command: Option<String>,
|
editor_command: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Object, Serialize)]
|
||||||
|
struct OpenFileResponse {
|
||||||
|
success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SettingsApi {
|
pub struct SettingsApi {
|
||||||
pub ctx: Arc<AppContext>,
|
pub ctx: Arc<AppContext>,
|
||||||
}
|
}
|
||||||
@@ -39,6 +44,32 @@ impl SettingsApi {
|
|||||||
Ok(Json(EditorCommandResponse { editor_command }))
|
Ok(Json(EditorCommandResponse { editor_command }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a file in the configured editor at the given line number.
|
||||||
|
///
|
||||||
|
/// Invokes the stored editor CLI (e.g. "zed", "code") with `path:line` as the argument.
|
||||||
|
/// Returns an error if no editor is configured or if the process fails to spawn.
|
||||||
|
#[oai(path = "/settings/open-file", method = "post")]
|
||||||
|
async fn open_file(
|
||||||
|
&self,
|
||||||
|
path: Query<String>,
|
||||||
|
line: Query<Option<u32>>,
|
||||||
|
) -> OpenApiResult<Json<OpenFileResponse>> {
|
||||||
|
let editor_command = get_editor_command_from_store(&self.ctx)
|
||||||
|
.ok_or_else(|| bad_request("No editor configured".to_string()))?;
|
||||||
|
|
||||||
|
let file_ref = match line.0 {
|
||||||
|
Some(l) => format!("{}:{}", path.0, l),
|
||||||
|
None => path.0.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
std::process::Command::new(&editor_command)
|
||||||
|
.arg(&file_ref)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| bad_request(format!("Failed to open editor: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(OpenFileResponse { success: true }))
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the preferred editor command (e.g. "zed", "code", "cursor").
|
/// Set the preferred editor command (e.g. "zed", "code", "cursor").
|
||||||
/// Pass null or empty string to clear the preference.
|
/// Pass null or empty string to clear the preference.
|
||||||
#[oai(path = "/settings/editor", method = "put")]
|
#[oai(path = "/settings/editor", method = "put")]
|
||||||
@@ -275,4 +306,64 @@ mod tests {
|
|||||||
.0;
|
.0;
|
||||||
assert!(result.editor_command.is_none());
|
assert!(result.editor_command.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn open_file_returns_error_when_no_editor_configured() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let api = make_api(&dir);
|
||||||
|
let result = api
|
||||||
|
.open_file(Query("src/main.rs".to_string()), Query(Some(42)))
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.status(), poem::http::StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn open_file_spawns_editor_with_path_and_line() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let api = make_api(&dir);
|
||||||
|
// Configure the editor to "echo" which is a safe no-op command
|
||||||
|
api.set_editor(Json(EditorCommandPayload {
|
||||||
|
editor_command: Some("echo".to_string()),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let result = api
|
||||||
|
.open_file(Query("src/main.rs".to_string()), Query(Some(42)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.0.success);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn open_file_spawns_editor_with_path_only_when_no_line() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let api = make_api(&dir);
|
||||||
|
api.set_editor(Json(EditorCommandPayload {
|
||||||
|
editor_command: Some("echo".to_string()),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let result = api
|
||||||
|
.open_file(Query("src/lib.rs".to_string()), Query(None))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.0.success);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn open_file_returns_error_for_nonexistent_editor() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let api = make_api(&dir);
|
||||||
|
api.set_editor(Json(EditorCommandPayload {
|
||||||
|
editor_command: Some("this_editor_does_not_exist_xyz_abc".to_string()),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let result = api
|
||||||
|
.open_file(Query("src/main.rs".to_string()), Query(Some(1)))
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user