From 76c76eefa9d944d08fc8cfdce2a9e687d27d7efb Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:37:17 +0800 Subject: [PATCH] feat(repeater): redesign request replay workspace --- web/src/app/(main)/_components/live-clock.tsx | 36 ++++ .../app/(main)/_components/main-content.tsx | 4 +- web/src/components/http-code-block.tsx | 91 +++++++--- .../components/repeater/repeater-editor.tsx | 91 ++++++++-- .../repeater/repeater-navigator.tsx | 7 +- .../repeater/repeater-text-pane.tsx | 68 ++++++- .../repeater/repeater-workspace.tsx | 169 +++++++++++++----- 7 files changed, 377 insertions(+), 89 deletions(-) create mode 100644 web/src/app/(main)/_components/live-clock.tsx diff --git a/web/src/app/(main)/_components/live-clock.tsx b/web/src/app/(main)/_components/live-clock.tsx new file mode 100644 index 0000000..ed19102 --- /dev/null +++ b/web/src/app/(main)/_components/live-clock.tsx @@ -0,0 +1,36 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function formatClock(value: Date) { + const pad = (part: number) => String(part).padStart(2, "0"); + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`; +} + +export function LiveClock({ className }: { className?: string }) { + const [now, setNow] = React.useState(null); + + React.useEffect(() => { + const update = () => setNow(new Date()); + update(); + const timer = window.setInterval(update, 1000); + return () => window.clearInterval(timer); + }, []); + + return ( + + ); +} diff --git a/web/src/app/(main)/_components/main-content.tsx b/web/src/app/(main)/_components/main-content.tsx index 44df7c1..d6951cf 100644 --- a/web/src/app/(main)/_components/main-content.tsx +++ b/web/src/app/(main)/_components/main-content.tsx @@ -10,12 +10,13 @@ import { useCurrentUser } from "@/hooks/use-current-user"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; +import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner"; +import { LiveClock } from "./live-clock"; import { AccountSwitcher } from "./sidebar/account-switcher"; import { LayoutControls } from "./sidebar/layout-controls"; import { SearchDialog } from "./sidebar/search-dialog"; import { ThemeSwitcher } from "./sidebar/theme-switcher"; import { UpdateBadge } from "./update-badge"; -import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner"; // 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。 function isFullBleed(pathname: string) { @@ -77,6 +78,7 @@ function MainContentInner({ children }: { children: ReactNode }) {
+ {version && ( 版本 · {version} )} diff --git a/web/src/components/http-code-block.tsx b/web/src/components/http-code-block.tsx index 0a24de1..9808551 100644 --- a/web/src/components/http-code-block.tsx +++ b/web/src/components/http-code-block.tsx @@ -111,6 +111,70 @@ function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) return body; } +/** + * Render a packet with the same syntax rules everywhere in the UI. + * + * `lineNumbers` is disabled by the repeater editor because it already owns a + * synchronised gutter. Keeping the tokenisation here prevents packet previews + * and editable packet panes from drifting apart visually. + */ +export function HttpPacketSyntax({ + raw, + wrapLines = true, + lineNumbers = true, + emptyFallback = "(空)", + lineClassName = "leading-relaxed", +}: { + raw: string; + wrapLines?: boolean; + lineNumbers?: boolean; + emptyFallback?: string; + lineClassName?: string; +}) { + const value = raw || emptyFallback; + const lines = value.replaceAll("\r\n", "\n").split("\n"); + const separator = lines.indexOf(""); + // LLM records and a few provider diagnostics contain a standalone JSON + // payload instead of an HTTP head. Treat that as a body too, so those + // packet views use the same token colours as captured HTTP traffic. + const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : value; + const bodyFormat = detectBodyFormat(body); + const renderLine = (line: string, index: number) => { + if (separator < 0 && bodyFormat !== "plain") return ; + if (index === 0) return ; + if (separator < 0 || index < separator) return ; + if (index === separator) return null; + return ; + }; + + return ( + <> + {lines.map((line, index) => ( +
+ {lineNumbers ? ( + + {index + 1} + + ) : null} + + {renderLine(line, index)} + +
+ ))} + + ); +} + // Empty grid rows have no text, so native selection copying drops their newlines. // Map the selected DOM boundaries back to the raw packet instead. function selectedPacketText(container: HTMLElement, value: string, lines: string[]): string | null { @@ -150,9 +214,6 @@ export function HttpCodeBlock({ raw }: { raw: string }) { const [copied, setCopied] = React.useState(false); const value = raw || "(空)"; const lines = value.replaceAll("\r\n", "\n").split("\n"); - const separator = lines.indexOf(""); - const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : ""; - const bodyFormat = detectBodyFormat(body); React.useEffect(() => { if (!copied) return; @@ -169,13 +230,6 @@ export function HttpCodeBlock({ raw }: { raw: string }) { toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制"); }; - const renderLine = (line: string, index: number) => { - if (index === 0) return ; - if (separator < 0 || index < separator) return ; - if (index === separator) return null; - return ; - }; - return (
@@ -234,22 +288,7 @@ export function HttpCodeBlock({ raw }: { raw: string }) { selection.addRange(range); }} > - {lines.map((line, index) => ( -
- - {renderLine(line, index)} - -
- ))} +
); diff --git a/web/src/components/repeater/repeater-editor.tsx b/web/src/components/repeater/repeater-editor.tsx index 7f1fc9c..82aa831 100644 --- a/web/src/components/repeater/repeater-editor.tsx +++ b/web/src/components/repeater/repeater-editor.tsx @@ -2,7 +2,7 @@ import * as React from "react"; -import { SendIcon } from "lucide-react"; +import { SendIcon, Settings2 } from "lucide-react"; import { toast } from "sonner"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -32,14 +32,40 @@ import { newId, type RepeaterTab } from "./repeater-state"; import { useRepeaterStore } from "./repeater-store"; import { RepeaterTextPane } from "./repeater-text-pane"; +function prettyResponse(response: NonNullable) { + const raw = buildRawResponse(response); + const separator = raw.indexOf("\n\n"); + if (separator < 0) return raw; + const body = raw.slice(separator + 2); + try { + return `${raw.slice(0, separator)}\n\n${JSON.stringify(JSON.parse(body), null, 2)}`; + } catch { + return raw; + } +} + +function responseStatusClass(status: number) { + if (status >= 500) return "border-rose-300 text-rose-600"; + if (status >= 400) return "border-amber-300 text-amber-600"; + if (status >= 300) return "border-sky-300 text-sky-600"; + return "border-emerald-300 text-emerald-600"; +} + +function responseTextFor(response: RepeaterTab["response"], mode: "raw" | "pretty") { + if (!response) return ""; + return mode === "pretty" ? prettyResponse(response) : buildRawResponse(response); +} + export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { const { useProxy, insecure, response } = tab; const updateTab = useRepeaterStore((state) => state.updateTab); const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined); const [targetOpen, setTargetOpen] = React.useState(false); const [targetDraft, setTargetDraft] = React.useState(""); + const [responseMode, setResponseMode] = React.useState<"raw" | "pretty">("pretty"); const targetOrigin = tab.targetOrigin ?? (tab.rawRequest === undefined ? connectionOrigin(tab.url) : ""); const requestText = buildRawRequest(tab); + const responseText = responseTextFor(response, responseMode); function changeRequest(rawRequest: string) { try { const { httpVersion: _version, ...parts } = parseRawRequest(rawRequest, targetOrigin); @@ -96,11 +122,15 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { } return ( -
-
- - 发送目标:{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"} - +
+
+ 目标 + + {targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"} + -
-
+
发送选项 {response ? ( -
- {response.status ? {response.status_text || response.status} : null} +
+ {response.status ? ( + + {response.status} {response.status_text} + + ) : null} {response.elapsed_ms} ms {response.body_bytes.toLocaleString()} 字节 {response.body_truncated ? 正文已截断 : null}
) : null}
-
+
Raw + } />
{response?.error ? ( @@ -164,8 +205,32 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { + + +
+ } />
diff --git a/web/src/components/repeater/repeater-navigator.tsx b/web/src/components/repeater/repeater-navigator.tsx index 17047ce..dee52f3 100644 --- a/web/src/components/repeater/repeater-navigator.tsx +++ b/web/src/components/repeater/repeater-navigator.tsx @@ -32,9 +32,11 @@ type Rename = { kind: "tab" | "group"; id: string; name: string }; export function RepeaterNavigator({ workspace, onRename, + className, }: { workspace: RepeaterWorkspace; onRename: (value: Rename) => void; + className?: string; }) { const store = useRepeaterStore(); const [filters, setFilters] = React.useState(DEFAULT_FILTERS); @@ -77,7 +79,10 @@ export function RepeaterNavigator({ return (