diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index baf241b..21c36f2 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -2,6 +2,10 @@ export interface EditorSettings { editor_command: string | null; } +export interface OpenFileResult { + success: boolean; +} + const DEFAULT_API_BASE = "/api"; function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { @@ -47,4 +51,20 @@ export const settingsApi = { baseUrl, ); }, + + openFile( + path: string, + line?: number, + baseUrl?: string, + ): Promise { + const params = new URLSearchParams({ path }); + if (line !== undefined) { + params.set("line", String(line)); + } + return requestJson( + `/settings/open-file?${params.toString()}`, + { method: "POST" }, + baseUrl, + ); + }, }; diff --git a/frontend/src/components/CodeRef.test.tsx b/frontend/src/components/CodeRef.test.tsx new file mode 100644 index 0000000..d83197f --- /dev/null +++ b/frontend/src/components/CodeRef.test.tsx @@ -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(); + expect(screen.getByText("just some text")).toBeInTheDocument(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("renders a code reference as a clickable button", () => { + render(); + 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(); + 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(); + // 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(); + }); +}); diff --git a/frontend/src/components/CodeRef.tsx b/frontend/src/components/CodeRef.tsx new file mode 100644 index 0000000..d512c08 --- /dev/null +++ b/frontend/src/components/CodeRef.tsx @@ -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 ( + + ); +} + +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 ( + + {part.value} + + ); + } + return {part.value}; + })} + + ); +} diff --git a/frontend/src/components/MessageItem.test.tsx b/frontend/src/components/MessageItem.test.tsx index 036d685..a66ec64 100644 --- a/frontend/src/components/MessageItem.test.tsx +++ b/frontend/src/components/MessageItem.test.tsx @@ -1,7 +1,13 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { MessageItem } from "./MessageItem"; +vi.mock("../api/settings", () => ({ + settingsApi: { + openFile: vi.fn(() => Promise.resolve({ success: true })), + }, +})); + describe("MessageItem component (Story 178 AC3)", () => { it("renders user message as a bubble", () => { render(); @@ -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( + , + ); + + const button = screen.getByRole("button", { name: /src\/main\.rs:42/ }); + expect(button).toBeInTheDocument(); + }); +}); + describe("MessageItem user message code fence rendering (Story 196)", () => { it("renders code fences in user messages as code blocks", () => { const { container } = render( diff --git a/frontend/src/components/MessageItem.tsx b/frontend/src/components/MessageItem.tsx index 686cd92..818d9fe 100644 --- a/frontend/src/components/MessageItem.tsx +++ b/frontend/src/components/MessageItem.tsx @@ -3,23 +3,29 @@ import Markdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import type { Message, ToolCall } from "../types"; +import { InlineCodeWithRefs } from "./CodeRef"; // biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props function CodeBlock({ className, children, ...props }: any) { const match = /language-(\w+)/.exec(className || ""); const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( + const text = String(children); + if (!isInline && match) { + return ( + + {text.replace(/\n$/, "")} + + ); + } + // For inline code, detect and render code references as clickable links + return ( - {children} + ); } diff --git a/server/src/http/settings.rs b/server/src/http/settings.rs index 7a4bfef..b7c9260 100644 --- a/server/src/http/settings.rs +++ b/server/src/http/settings.rs @@ -1,6 +1,6 @@ use crate::http::context::{AppContext, OpenApiResult, bad_request}; 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_json::json; use std::sync::Arc; @@ -22,6 +22,11 @@ struct EditorCommandResponse { editor_command: Option, } +#[derive(Debug, Object, Serialize)] +struct OpenFileResponse { + success: bool, +} + pub struct SettingsApi { pub ctx: Arc, } @@ -39,6 +44,32 @@ impl SettingsApi { 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, + line: Query>, + ) -> OpenApiResult> { + 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"). /// Pass null or empty string to clear the preference. #[oai(path = "/settings/editor", method = "put")] @@ -275,4 +306,64 @@ mod tests { .0; 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()); + } }