дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 14:15:39 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
f3daa598f3 |
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: [] };
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1333,6 +1333,7 @@
|
||||
"Find in Folder…": "在文件夹中查找…",
|
||||
"Find in Terminal": "在终端中查找",
|
||||
"Find in console": "在控制台中查找",
|
||||
"Find in response": "在响应中查找",
|
||||
"Find in file": "在文件中查找",
|
||||
"Find in terminal": "在终端中查找",
|
||||
"Find results": "查找结果",
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
Reference in new issue
Заблокувати користувача