diff --git a/src/features/debugger/__tests__/debugConsoleLinks.test.tsx b/src/features/debugger/__tests__/debugConsoleLinks.test.tsx new file mode 100644 index 000000000..3e0e78ecc --- /dev/null +++ b/src/features/debugger/__tests__/debugConsoleLinks.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { usePlatform } from "@/platform/platformInfo"; +import { adoptSession, appendConsole, useDebug } from "../state"; +import { DebugConsole } from "../ui/DebugConsole"; + +const nav = vi.hoisted(() => ({ + openTarget: vi.fn(async () => {}), + openUrl: vi.fn(async () => {}), +})); + +vi.mock("@/features/run/navigate", async () => { + const actual = await vi.importActual("@/features/run/navigate"); + return { + ...actual, + openTarget: nav.openTarget, + openUrl: nav.openUrl, + }; +}); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 18, + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ index, start: index * 18, size: 18, key: index })), + scrollToIndex: () => {}, + measureElement: () => {}, + measure: () => {}, + }), +})); + +beforeEach(() => { + usePlatform.setState({ os: "macos" }); + useDebug.setState({ sessions: {}, console: {}, active: null }); + nav.openTarget.mockClear(); + nav.openUrl.mockClear(); + window.getSelection()?.removeAllRanges(); +}); + +describe("Debug console links", () => { + it("renders clickable file and URL links in output", () => { + const session = adoptSession(1); + session.launch = { + name: "test", + adapter: "delve", + request: "launch", + program: "main.go", + args: [], + cwd: "/repo/demo-app", + env: {}, + extraJson: null, + }; + + appendConsole(1, "stdout", " /Users/dev/demo-app/pkg/api/server.go:42:\n"); + appendConsole(1, "stdout", "2026-01-01T12:00:00.000+0000 DEBUG pkg/store/db.go:15 failed\n"); + appendConsole(1, "stdout", "Listening on http://localhost:8080/api\n"); + appendConsole(1, "system", "Process finished with exit code 0\n"); + + render(); + + const links = screen.getAllByRole("link"); + expect(links).toHaveLength(3); + + expect(links[0]?.textContent).toBe("/Users/dev/demo-app/pkg/api/server.go:42"); + expect(links[1]?.textContent).toBe("pkg/store/db.go:15"); + expect(links[2]?.textContent).toBe("http://localhost:8080/api"); + }); + + it("navigates to target file on click with resolved cwd", () => { + const session = adoptSession(1); + session.launch = { + name: "test", + adapter: "delve", + request: "launch", + program: "main.go", + args: [], + cwd: "/repo/demo-app", + env: {}, + extraJson: null, + }; + + appendConsole(1, "stdout", "2026-01-01T12:00:00.000+0000 DEBUG pkg/store/db.go:15 failed\n"); + + render(); + + const link = screen.getByRole("link", { name: "pkg/store/db.go:15" }); + fireEvent.click(link); + + expect(nav.openTarget).toHaveBeenCalledTimes(1); + expect(nav.openTarget).toHaveBeenCalledWith( + expect.objectContaining({ + path: "pkg/store/db.go", + line: 15, + }), + "/repo/demo-app", + ); + }); + + it("opens URL on click", () => { + const session = adoptSession(1); + appendConsole(1, "stdout", "See docs at https://example.com/guide\n"); + + render(); + + const link = screen.getByRole("link", { name: "https://example.com/guide" }); + fireEvent.click(link); + + expect(nav.openUrl).toHaveBeenCalledTimes(1); + expect(nav.openUrl).toHaveBeenCalledWith("https://example.com/guide"); + }); + + it("does not trigger navigation when text is selected", () => { + const session = adoptSession(1); + appendConsole(1, "stdout", " /Users/dev/demo-app/pkg/api/server.go:42:\n"); + + render(); + + // Mock active selection + vi.spyOn(window, "getSelection").mockReturnValue({ + toString: () => "server.go", + } as unknown as Selection); + + const link = screen.getByRole("link", { name: "/Users/dev/demo-app/pkg/api/server.go:42" }); + fireEvent.click(link); + + expect(nav.openTarget).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/debugger/ui/DebugConsole.tsx b/src/features/debugger/ui/DebugConsole.tsx index 21f95a2f3..a988c5559 100644 --- a/src/features/debugger/ui/DebugConsole.tsx +++ b/src/features/debugger/ui/DebugConsole.tsx @@ -1,4 +1,13 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; +import { + memo, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type MouseEvent, +} from "react"; import clsx from "clsx"; import { useVirtualizer } from "@tanstack/react-virtual"; import { copySelectionOnKeyDown, copyText } from "@/ui/selectableText"; @@ -6,16 +15,10 @@ import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui" import { formatBinding, hasPrimaryModifier, hostBinding, registerKeyClaim } from "@/platform/keymap"; import { tx } from "@/platform/i18n"; import { useWorkflowSettings } from "@/platform/workflowSettings"; -import { spanStyle, type Span, type SpanStyle } from "@/ui/ansi"; +import { spanStyle } from "@/ui/ansi"; import ansi from "@/ui/ansi.module.css"; import { ConsoleFindBar } from "@/ui/ConsoleFindBar"; -import { - DEFAULT_FIND_OPTIONS, - findInLines, - type ConsoleMatch, - type FindOptions, - type FindResult, -} from "@/ui/consoleFind"; +import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions, type FindResult } from "@/ui/consoleFind"; import { appendConsole, clearConsole, @@ -24,10 +27,14 @@ import { readsProgramInput, sendProgramInput, useDebug, + type ConsoleLine, type DebugSession, } from "../state"; import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory"; import { useExpressionCompletion } from "./ExpressionCompletion"; +import { findOutputLinks, type OutputLink } from "@/features/run/links"; +import { openTarget, openUrl, runDirectory } from "@/features/run/navigate"; +import { segmentLine, type SegmentMark } from "@/features/run/segments"; import styles from "./DebugToolWindow.module.css"; const EMPTY_FIND: FindResult = { matches: [], overflow: false, invalid: false }; @@ -35,69 +42,64 @@ const EMPTY_FIND: FindResult = { matches: [], overflow: false, invalid: false }; const MARK_COLOR = "var(--bg-search-match)"; const CURRENT_MARK_COLOR = "var(--bg-search-match-current)"; -interface Segment { - text: string; - style: SpanStyle; - /** The piece is inside a find match. */ - mark: boolean; - /** …and inside the current one. */ - current: boolean; -} - -/** Splits a line's styled spans at find-match boundaries (the Run console's `segmentLine`, without links). */ -function findSegments(spans: readonly Span[], marks: readonly ConsoleMatch[], active?: ConsoleMatch): Segment[] { - const out: Segment[] = []; - let pos = 0; - let mi = 0; - for (const span of spans) { - const { text, ...style } = span; - let offset = 0; - while (offset < text.length) { - const abs = pos + offset; - while (mi < marks.length && marks[mi]!.end <= abs) mi++; - const mark = marks[mi]; - if (mark && mark.start <= abs) { - const take = Math.min(mark.end - abs, text.length - offset); - out.push({ text: text.slice(offset, offset + take), style, mark: true, current: mark === active }); - offset += take; - } else if (mark && mark.start < pos + text.length) { - const take = Math.min(mark.start - abs, text.length - offset); - out.push({ text: text.slice(offset, offset + take), style, mark: false, current: false }); - offset += take; - } else { - out.push({ text: text.slice(offset), style, mark: false, current: false }); - offset = text.length; - } - } - pos += text.length; - } - return out; -} - -/** A match keeps the span's colours but takes the search background (as IDEA does). */ -function segmentNode(seg: Segment, key: number) { - if (!seg.mark) +const DebugLine = memo( + function DebugLine({ + line, + marks, + cwd, + }: { + line: ConsoleLine; + marks?: readonly SegmentMark[]; + length: number; + cwd: string; + }) { + const segments = segmentLine(line.spans, line.category === "system" ? [] : findOutputLinks(line.text), marks); + const activate = (e: MouseEvent, link: OutputLink) => { + e.preventDefault(); + if (window.getSelection()?.toString()) return; + if (link.kind === "url") void openUrl(link.url); + else void openTarget(link.target, cwd); + }; + if (segments.length === 0) return <>{"\u200B"}; return ( - - {seg.text} - + <> + {segments.map((seg, i) => { + const style = seg.mark + ? { + ...spanStyle(seg.style), + backgroundColor: seg.current ? CURRENT_MARK_COLOR : MARK_COLOR, + color: "var(--fg-search-match)", + } + : spanStyle(seg.style); + const marked = seg.mark ? { "data-find-match": "", "data-find-current": seg.current ? "" : undefined } : {}; + return seg.link ? ( + activate(e, seg.link!)} + role="link" + tabIndex={-1} + {...marked} + > + {seg.text} + + ) : seg.mark ? ( + + {seg.text} + + ) : ( + + {seg.text} + + ); + })} + ); - return ( - - {seg.text} - - ); -} + }, + (a, b) => a.line === b.line && a.length === b.length && a.cwd === b.cwd && a.marks === b.marks, +); export function DebugServiceConsole({ sessionId }: { sessionId: number }) { const session = useDebug((s) => s.sessions[sessionId]); @@ -160,6 +162,11 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe }); }, [lines, virtualizer, follow, softWrap]); + const cwd = useMemo( + () => runDirectory(session.launch?.cwd ?? session.runConfig?.cwd), + [session.launch?.cwd, session.runConfig?.cwd], + ); + // Find (⌘F): search the whole buffer, not just the rendered rows. const found = useMemo( () => (findOpen ? findInLines(lines ?? [], query, findOptions) : EMPTY_FIND), @@ -168,14 +175,15 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe const activeIndex = found.matches.length ? Math.min(Math.max(activeMatchIndex, 0), found.matches.length - 1) : -1; const current = activeIndex >= 0 ? found.matches[activeIndex] : undefined; const marksByLine = useMemo(() => { - const byLine = new Map(); - found.matches.forEach((match) => { + const byLine = new Map(); + found.matches.forEach((match, i) => { + const mark: SegmentMark = { start: match.start, end: match.end, current: i === activeIndex }; const marks = byLine.get(match.line); - if (marks) marks.push(match); - else byLine.set(match.line, [match]); + if (marks) marks.push(mark); + else byLine.set(match.line, [mark]); }); return byLine; - }, [found]); + }, [found, activeIndex]); // The line at the top of the viewport: typing searches from there, as IDEA does. const topLine = () => { @@ -462,7 +470,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe className={clsx(styles.out, styles[`cat-${l.category}`])} style={{ transform: `translateY(${item.start}px)` }} > - {findSegments(l.spans, marksByLine.get(item.index) ?? [], current).map(segmentNode)} + ); })} diff --git a/src/features/debugger/ui/DebugToolWindow.module.css b/src/features/debugger/ui/DebugToolWindow.module.css index f13d3bbc5..1993357e2 100644 --- a/src/features/debugger/ui/DebugToolWindow.module.css +++ b/src/features/debugger/ui/DebugToolWindow.module.css @@ -273,6 +273,18 @@ border-radius: 2px; } +.link { + color: var(--link); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--link) 45%, transparent); + text-underline-offset: 2px; + cursor: pointer; +} +.link:hover { + color: var(--link-hover); + text-decoration-color: currentColor; +} + /* Soft-Wrap off: long lines scroll horizontally. */ .noWrap { white-space: pre; diff --git a/src/features/run/links.test.ts b/src/features/run/links.test.ts index a62e2f36e..96548c3d7 100644 --- a/src/features/run/links.test.ts +++ b/src/features/run/links.test.ts @@ -66,6 +66,21 @@ describe("findOutputLinks", () => { col: 2, }); expect(files("src/App.tsx(14,3): error TS2322")[0]).toMatchObject({ path: "src/App.tsx", line: 14, col: 3 }); + // Go module cache paths with @version and stack frames + expect(files(" /Users/user/go/pkg/mod/github.com/spf13/cobra@v1.10.2/command.go:1071")[0]).toMatchObject({ + path: "/Users/user/go/pkg/mod/github.com/spf13/cobra@v1.10.2/command.go", + line: 1071, + }); + // Go stack frames with trailing colon + expect(files(" /Users/user/projects/demo/pkg/api/server.go:42:")[0]).toMatchObject({ + path: "/Users/user/projects/demo/pkg/api/server.go", + line: 42, + }); + // Relative paths in loggers (e.g. zap/zerolog/slog) + expect(files("2026-01-01T12:00:00.000+0000\tDEBUG\tpkg/store/db.go:15\tfailed to connect")[0]).toMatchObject({ + path: "pkg/store/db.go", + line: 15, + }); }); it("maven diagnostics [ERROR] path:[line,col]", () => {