Порівняти коміти

...
Автор SHA1 Повідомлення Дата
l0ng-ai f3daa598f3 feat(http-client): find in the response body and open the files .http lines name
⌘F in the response pane (or the Find button next to Show Raw) opens the console find bar: Match Case / Words / Regex, n/m, previous/next, with the matches marked in the body.

⌘-click / ⌘B on the file a .http line names opens it: a request body (< file), a handler script (> file), a redirect (>> file) and a saved response (<> file), which resolves against .idea/httpRequests/ where IntelliJ IDEA writes them, then against the request file's folder.
2026-11-14 22:31:53 +08:00
8 змінених файлів з 274 додано та 19 видалено

No files matched your search

+9
Переглянути файл
@@ -207,3 +207,12 @@
.pendingMethod {
font-weight: 600;
}
.match {
border-radius: 2px;
color: var(--fg-search-match);
background: var(--bg-search-match);
}
.matchCurrent {
background: var(--bg-search-match-current);
}
+116 -10
Переглянути файл
@@ -3,7 +3,7 @@
* exchange (right): status line, timing, body (pretty-printed JSON / XML /
* HTML or raw), response headers and the request as sent.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { Fragment, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import clsx from "clsx";
import type { HttpExchange, HttpHistoryEntry } from "@/platform/ipc";
import {
@@ -18,6 +18,9 @@ import {
T,
useSelectionBoundary,
} from "@/ui";
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
import { DEFAULT_FIND_OPTIONS, findInLines, type ConsoleMatch, type FindOptions } from "@/ui/consoleFind";
import { formatBinding, hasPrimaryModifier, hostBinding } from "@/platform/keymap";
import { bodyKind, formatMs, formatSize, prettyBody } from "./format";
import { cancelRunning, clearHistory, refreshHistory, selectEntry, useHttpClient } from "./store";
import styles from "./HttpClient.module.css";
@@ -60,6 +63,49 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
const r = ex.response;
const kind = r?.body != null ? bodyKind(r.contentType, r.body) : "text";
const body = useMemo(() => (r?.body == null ? null : raw ? r.body : prettyBody(kind, r.body)), [r, raw, kind]);
const [findOpen, setFindOpen] = useState(false);
const [query, setQuery] = useState("");
const [findOptions, setFindOptions] = useState<FindOptions>(DEFAULT_FIND_OPTIONS);
const [active, setActive] = useState(0);
const findInput = useRef<HTMLInputElement>(null);
const searchable = findOpen && tab === "body" && body != null;
const lines = useMemo(() => (body ?? "").split("\n").map((text, id) => ({ id, text })), [body]);
const found = useMemo(
() => (searchable ? findInLines(lines, query, findOptions) : { matches: [], overflow: false, invalid: false }),
[searchable, lines, query, findOptions],
);
const activeIndex = found.matches.length ? Math.min(active, found.matches.length - 1) : -1;
useEffect(() => {
pane.current?.querySelector("[data-find-current]")?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [activeIndex, found]);
const openFind = () => {
setFindOpen(true);
// The bar mounts with this render; focus it (and select the query) once it has.
requestAnimationFrame(() => findInput.current?.select());
};
const closeFind = () => {
setFindOpen(false);
pane.current?.focus();
};
const goToMatch = (step: number) => {
const n = found.matches.length;
if (n) setActive((activeIndex + step + n) % n);
};
// ⌘F in the response, as in IDEA's response viewer: the find bar of the Run console.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (
tab === "body" &&
body != null &&
e.key.toLowerCase() === "f" &&
hasPrimaryModifier(e) &&
!e.altKey &&
!e.shiftKey
) {
e.preventDefault();
e.stopPropagation();
openFind();
}
};
return (
<div className={styles.exchange}>
<div className={styles.statusLine}>
@@ -91,18 +137,52 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
{ id: "request", label: "Request" },
]}
trailing={
tab === "body" && kind !== "text" ? (
<IconButton
icon="soft-wrap"
title={raw ? "Show Formatted" : "Show Raw"}
pressed={raw}
onClick={() => setRaw((v) => !v)}
/>
tab === "body" && body != null ? (
<>
<IconButton
icon="search"
title="Find…"
shortcut={formatBinding(hostBinding("Meta+F"))}
pressed={findOpen}
onClick={() => (findOpen ? closeFind() : openFind())}
/>
{kind !== "text" && (
<IconButton
icon="soft-wrap"
title={raw ? "Show Formatted" : "Show Raw"}
pressed={raw}
onClick={() => setRaw((v) => !v)}
/>
)}
</>
) : undefined
}
/>
{searchable && (
<ConsoleFindBar
query={query}
onChange={(q) => {
setQuery(q);
setActive(0);
}}
options={findOptions}
onOptionsChange={(o) => {
setFindOptions(o);
setActive(0);
}}
count={found.matches.length}
active={activeIndex}
overflow={found.overflow}
invalid={found.invalid}
inputRef={findInput}
onNext={() => goToMatch(1)}
onPrevious={() => goToMatch(-1)}
onClose={closeFind}
label="Find in response"
/>
)}
{/* Focusable, so Select All and Copy work on the response like in a read-only editor. */}
<div ref={pane} className={styles.tabBody} tabIndex={0}>
<div ref={pane} className={styles.tabBody} tabIndex={0} onKeyDown={onKeyDown}>
{tab === "body" &&
(r == null ? (
<pre className={styles.pre}>{ex.entry.error}</pre>
@@ -112,7 +192,7 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
</EmptyState>
) : (
<pre className={styles.pre} aria-label={tx("Response body")}>
{body}
{found.matches.length ? highlighted(lines, found.matches, activeIndex) : body}
{r.truncated && (
<span className={styles.truncated}>{`\n… truncated (${formatSize(r.size)} received)`}</span>
)}
@@ -131,6 +211,32 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
);
}
/** The body with its find matches marked, the current one distinctly. */
function highlighted(lines: readonly { text: string }[], matches: readonly ConsoleMatch[], current: number): ReactNode {
const out: ReactNode[] = [];
let m = 0;
lines.forEach(({ text }, line) => {
if (line > 0) out.push("\n");
let at = 0;
for (; m < matches.length && matches[m]!.line === line; m++) {
const { start, end } = matches[m]!;
if (start > at) out.push(text.slice(at, start));
out.push(
<mark
key={m}
className={clsx(styles.match, m === current && styles.matchCurrent)}
data-find-current={m === current ? "" : undefined}
>
{text.slice(start, end)}
</mark>,
);
at = end;
}
if (at < text.length) out.push(text.slice(at));
});
return <Fragment>{out}</Fragment>;
}
function HeaderTable({ headers }: { headers: Array<[string, string]> }) {
return (
<table className={styles.headers}>
+29
Переглянути файл
@@ -0,0 +1,29 @@
import { Text } from "@codemirror/state";
import { describe, expect, it } from "vitest";
import { fileReferenceAt } from "./fileNavigation";
const doc = Text.of([
"POST http://x/api",
"",
"< ./body.json",
"> handler.js",
"> {% client.test('ok', () => {}); %}",
">> out/result.json",
"<> 2026-11-10T135253.200.json",
]);
const at = (line: number, col: number) => fileReferenceAt(doc, doc.line(line).from + col);
describe("fileReferenceAt", () => {
it("finds the file a body, handler, redirect or saved-response line names", () => {
expect(at(3, 4)).toMatchObject({ value: "./body.json", response: false });
expect(at(4, 3)).toMatchObject({ value: "handler.js", response: false });
expect(at(6, 5)).toMatchObject({ value: "out/result.json", response: false });
expect(at(7, 10)).toMatchObject({ value: "2026-11-10T135253.200.json", response: true });
});
it("ignores inline handler scripts, the marker itself and other lines", () => {
expect(at(5, 6)).toBeNull();
expect(at(7, 0)).toBeNull();
expect(at(1, 6)).toBeNull();
});
});
+44
Переглянути файл
@@ -0,0 +1,44 @@
/**
* Navigate › Declaration (⌘-click, ⌘B) on the file a `.http` line names: a request body
* (`< ./body.json`), a response handler script (`> handler.js`), a response redirect
* (`>> out.json`) and a saved response (`<> 2026-11-10T135253.200.json`). Saved responses
* resolve against `.idea/httpRequests/`, where IntelliJ IDEA writes them, then against the
* request file's folder.
*/
import type { Text } from "@codemirror/state";
import { docOf } from "@/editor/lsp/lsp";
import { registerNavigationProvider } from "@/editor/navigationProviders";
import { call, commands } from "@/platform/ipc";
import { HTTP_LANGUAGE } from "./editor";
/** The file named on the line at `offset`, as document offsets; `response` for a `<>` line. */
export function fileReferenceAt(
doc: Text,
offset: number,
): { value: string; from: number; to: number; response: boolean } | null {
const line = doc.lineAt(offset);
const m = /^(\s*(<>|>>!?|<|>)\s+)(\S(?:.*\S)?)\s*$/.exec(line.text);
// `> {% … %}` is an inline handler script, not a file.
if (!m || m[3]!.startsWith("{%")) return null;
const from = line.from + m[1]!.length;
const to = from + m[3]!.length;
return offset >= from && offset <= to ? { value: m[3]!, from, to, response: m[2] === "<>" } : null;
}
export function registerFileNavigation(): () => void {
return registerNavigationProvider(async (view, offset) => {
const doc = docOf(view);
if (!doc || doc.virtual || doc.language !== HTTP_LANGUAGE) return null;
const at = fileReferenceAt(view.state.doc, offset);
if (!at) return null;
const candidates = at.response ? [`/.idea/httpRequests/${at.value}`, at.value] : [at.value];
for (const value of candidates) {
const path = await call(commands.fsResolveReference(doc.path, value)).catch(() => null);
if (path) {
const start = { line: 0, col: 0 };
return { from: at.from, to: at.to, targets: [{ path, range: { start, end: start } }] };
}
}
return { from: at.from, to: at.to, targets: [] };
});
}
+2
Переглянути файл
@@ -17,6 +17,7 @@ import {
registerServiceView,
showServiceView,
} from "@/workbench/contributions";
import { registerFileNavigation } from "./fileNavigation";
import { registerVariableNavigation } from "./variableNavigation";
import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } from "./editor";
import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView";
@@ -134,6 +135,7 @@ export function activate(): () => void {
const disposers = [
registerSpringEndpointProvider(),
registerVariableNavigation(),
registerFileNavigation(),
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) resetHttpClient();
}),
+67
Переглянути файл
@@ -0,0 +1,67 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HttpExchange } from "@/platform/ipc";
vi.mock("./store", async (importOriginal) => ({
...(await importOriginal<typeof import("./store")>()),
refreshHistory: vi.fn(async () => {}),
}));
const { useHttpClient } = await import("./store");
const { HttpClientView } = await import("./HttpClientView");
const exchange: HttpExchange = {
entry: {
id: "e1",
timeMs: 0,
name: null,
method: "GET",
url: "http://x/api",
status: 200,
totalMs: 5,
file: null,
error: null,
},
request: { name: null, method: "GET", url: "http://x/api", headers: [], body: null },
response: {
status: 200,
statusText: "OK",
version: "HTTP/1.1",
headers: [["content-type", "text/plain"]],
contentType: "text/plain",
body: "alpha beta\nbeta gamma\nBeta",
size: 26,
truncated: false,
ttfbMs: 1,
totalMs: 5,
},
};
describe("HTTP response find", () => {
afterEach(() => useHttpClient.setState({ history: [], exchanges: {}, selected: null }));
it("opens a find bar with ⌘F in the response and marks the matches", () => {
Element.prototype.scrollIntoView ??= () => {};
useHttpClient.setState({ history: [exchange.entry], exchanges: { e1: exchange }, selected: "e1" });
render(<HttpClientView />);
const body = screen.getByLabelText("Response body");
fireEvent.keyDown(body, { key: "f", metaKey: true, ctrlKey: true });
const input = screen.getByLabelText("Find in response", { selector: "input" });
act(() => {
fireEvent.change(input, { target: { value: "beta" } });
});
const marks = body.querySelectorAll("mark");
expect([...marks].map((m) => m.textContent)).toEqual(["beta", "beta", "Beta"]);
expect(screen.getByText("1/3")).toBeTruthy();
expect(body.textContent).toBe("alpha beta\nbeta gamma\nBeta");
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("2/3")).toBeTruthy();
expect(
marks[1]!.hasAttribute("data-find-current") ||
body.querySelectorAll("mark")[1]!.hasAttribute("data-find-current"),
).toBe(true);
fireEvent.keyDown(input, { key: "Escape" });
expect(screen.queryByLabelText("Find in response", { selector: "input" })).toBeNull();
expect(body.querySelectorAll("mark")).toHaveLength(0);
});
});
+1
Переглянути файл
@@ -1333,6 +1333,7 @@
"Find in Folder…": "在文件夹中查找…",
"Find in Terminal": "在终端中查找",
"Find in console": "在控制台中查找",
"Find in response": "在响应中查找",
"Find in file": "在文件中查找",
"Find in terminal": "在终端中查找",
"Find results": "查找结果",
+6 -9
Переглянути файл
@@ -25,6 +25,8 @@ export interface ConsoleFindBarProps {
onNext: () => void;
onPrevious: () => void;
onClose: () => void;
/** What is searched, for the field's placeholder and label. */
label?: string;
}
export function ConsoleFindBar({
@@ -40,6 +42,7 @@ export function ConsoleFindBar({
onNext,
onPrevious,
onClose,
label = "Find in console",
}: ConsoleFindBarProps) {
const none = !!query && !invalid && count === 0;
const counter = invalid
@@ -81,20 +84,14 @@ export function ConsoleFindBar({
return (
<div className={styles.container}>
<div
className={styles.find}
role="search"
aria-label={tx("Find in console")}
data-console-find=""
onKeyDown={onKeyDown}
>
<div className={styles.find} role="search" aria-label={tx(label)} data-console-find="" onKeyDown={onKeyDown}>
<SearchField
ref={inputRef}
className={styles.findField}
value={query}
onChange={onChange}
placeholder="Find in console"
aria-label="Find in console"
placeholder={label}
aria-label={label}
invalid={invalid || none}
toggles={[
{