дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(debugger): support clickable file and URL links in debug console (#2245)
Co-authored-by: elfadl <[email protected]>
Цей коміт міститься в:
1 джерело
7829bee55c
коміт
fb92a82101
4 files changed
+239
-76
No files matched your search
@@ -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<typeof import("@/features/run/navigate")>("@/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(<DebugConsole session={session} />);
|
||||
|
||||
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(<DebugConsole session={session} />);
|
||||
|
||||
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(<DebugConsole session={session} />);
|
||||
|
||||
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(<DebugConsole session={session} />);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<span key={key} style={spanStyle(seg.style)}>
|
||||
{seg.text}
|
||||
</span>
|
||||
<>
|
||||
{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 ? (
|
||||
<a
|
||||
key={i}
|
||||
href={seg.link.kind === "url" ? seg.link.url : undefined}
|
||||
className={styles.link}
|
||||
style={style}
|
||||
onClick={(e) => activate(e, seg.link!)}
|
||||
role="link"
|
||||
tabIndex={-1}
|
||||
{...marked}
|
||||
>
|
||||
{seg.text}
|
||||
</a>
|
||||
) : seg.mark ? (
|
||||
<mark key={i} className={styles.findMatch} style={style} {...marked}>
|
||||
{seg.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i} style={style}>
|
||||
{seg.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<mark
|
||||
key={key}
|
||||
className={styles.findMatch}
|
||||
style={{
|
||||
...spanStyle(seg.style),
|
||||
backgroundColor: seg.current ? CURRENT_MARK_COLOR : MARK_COLOR,
|
||||
color: "var(--fg-search-match)",
|
||||
}}
|
||||
data-find-match=""
|
||||
data-find-current={seg.current ? "" : undefined}
|
||||
>
|
||||
{seg.text}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
},
|
||||
(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<number, ConsoleMatch[]>();
|
||||
found.matches.forEach((match) => {
|
||||
const byLine = new Map<number, SegmentMark[]>();
|
||||
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)}
|
||||
<DebugLine line={l} marks={marksByLine.get(item.index)} length={l.text.length} cwd={cwd} />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/[email protected]/command.go:1071")[0]).toMatchObject({
|
||||
path: "/Users/user/go/pkg/mod/github.com/spf13/[email protected]/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]", () => {
|
||||
|
||||
Reference in new issue
Заблокувати користувача