diff --git a/web/src/app/(main)/function/traffic/page.tsx b/web/src/app/(main)/function/traffic/page.tsx index 18e5a78..f80910b 100644 --- a/web/src/app/(main)/function/traffic/page.tsx +++ b/web/src/app/(main)/function/traffic/page.tsx @@ -2,6 +2,8 @@ import * as React from "react"; +import { useRouter } from "next/navigation"; + import { ArrowDownWideNarrowIcon, ArrowUpNarrowWideIcon, @@ -19,6 +21,8 @@ import { toast } from "sonner"; import { HttpCodeBlock } from "@/components/http-code-block"; import { LinkTrafficDialog } from "@/components/link-traffic-dialog"; +import { requestFromTraffic } from "@/components/repeater/repeater-http"; +import { seedFromEvidence } from "@/components/repeater/repeater-store"; import { AlertDialog, AlertDialogAction, @@ -112,7 +116,10 @@ const SORT_STORAGE_KEY = "traffic-sort"; const STATUS_BUCKETS = ["2xx", "3xx", "4xx", "5xx"]; export default function TrafficPage() { - const [selectedFlows, setSelectedFlows] = React.useState>(() => new Set()); + const [selectedFlows, setSelectedFlows] = React.useState>(() => new Map()); + const router = useRouter(); + const [importing, setImporting] = React.useState(false); + const importLock = React.useRef(false); const [linking, setLinking] = React.useState(false); const [page, setPage] = React.useState(0); const [size, setSize] = React.useState(50); @@ -322,6 +329,38 @@ export default function TrafficPage() { }; }, [selected]); + async function openRepeater(flows: TrafficExchange[]) { + if (!flows.length || importLock.current) return; + importLock.current = true; + setImporting(true); + let imported = 0; + try { + // Sequential loading limits pressure when a whole page is selected. A bad + // capture does not discard drafts imported successfully before it. + for (const flow of flows) { + try { + const raw = await api.trafficExchange(flow.id); + seedFromEvidence( + requestFromTraffic(raw.req, flow), + { label: `流量 ${flow.id}` }, + { title: `${flow.method} ${flow.host}` }, + ); + imported += 1; + } catch (error) { + toast.error(`${flow.host}:${(error as Error).message}`); + } + } + if (imported) { + toast.success(`已送入 ${imported} 条请求,可编辑后发送`); + setSelected(null); + router.push("/function/repeater"); + } + } finally { + importLock.current = false; + setImporting(false); + } + } + // Toggle direction when re-clicking the active column, else sort the new column // newest/largest-first. const toggleSort = (field: SortField) => @@ -607,8 +646,16 @@ export default function TrafficPage() { + {selectedFlows.size > 0 ? ( - ) : null} @@ -626,9 +673,9 @@ export default function TrafficPage() { checked={exchanges.length > 0 && exchanges.every((e) => selectedFlows.has(e.id))} onCheckedChange={(checked) => setSelectedFlows((previous) => { - const next = new Set(previous); + const next = new Map(previous); for (const e of exchanges) { - if (checked === true) next.add(e.id); + if (checked === true) next.set(e.id, e); else next.delete(e.id); } return next; @@ -681,8 +728,8 @@ export default function TrafficPage() { onClick={(event) => event.stopPropagation()} onCheckedChange={(checked) => setSelectedFlows((previous) => { - const next = new Set(previous); - if (checked === true) next.add(e.id); + const next = new Map(previous); + if (checked === true) next.set(e.id, e); else next.delete(e.id); return next; }) @@ -721,9 +768,9 @@ export default function TrafficPage() { {linking ? ( setLinking(false)} - onBound={() => setSelectedFlows(new Set())} + onBound={() => setSelectedFlows(new Map())} /> ) : null} !open && setSelected(null)}> @@ -740,6 +787,15 @@ export default function TrafficPage() { {selected.host} {selected.url} + diff --git a/web/src/components/finding-traffic-panel.tsx b/web/src/components/finding-traffic-panel.tsx index 80de334..3e6d80a 100644 --- a/web/src/components/finding-traffic-panel.tsx +++ b/web/src/components/finding-traffic-panel.tsx @@ -138,7 +138,7 @@ export function FindingTrafficPanel({ router.push("/function/repeater"); }} > - 打开 Repeater + 打开请求重发 {!readOnly ? ( {!readOnly ? ( <> diff --git a/web/src/components/repeater/repeater-editor.tsx b/web/src/components/repeater/repeater-editor.tsx index b3a0e17..acc7e1e 100644 --- a/web/src/components/repeater/repeater-editor.tsx +++ b/web/src/components/repeater/repeater-editor.tsx @@ -1,5 +1,7 @@ "use client"; +import * as React from "react"; + import { SendIcon } from "lucide-react"; import { toast } from "sonner"; @@ -10,23 +12,40 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; import { api } from "@/lib/api"; -import { buildRawResponse, getHostHeader, parseHeadersText, setHostHeader } from "./repeater-http"; +import { + buildRawResponse, + getHostHeader, + joinRequestText, + parseHeadersText, + setHostHeader, + splitRequestText, +} from "./repeater-http"; import { newId, type RepeaterTab } from "./repeater-state"; import { useRepeaterStore } from "./repeater-store"; +import { RepeaterTextPane } from "./repeater-text-pane"; const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { - const { method, url, headersText, body, useProxy, insecure, response, history } = tab; + const { method, url, headersText, body, useProxy, insecure, response } = tab; const updateTab = useRepeaterStore((state) => state.updateTab); const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined); const setMethod = (method: string) => updateTab(tab.id, { method }); const setURL = (url: string) => updateTab(tab.id, { url }); const setHeadersText = (headersText: string) => updateTab(tab.id, { headersText }); - const setBody = (body: string) => updateTab(tab.id, { body }); + const [request, setRequest] = React.useState(() => ({ headersText, body, text: joinRequestText(headersText, body) })); + // Keep partial edits (including a single trailing newline) intact while syncing + // changes made by the Host control or by restoring a history entry. + if (request.headersText !== headersText || request.body !== body) { + setRequest({ headersText, body, text: joinRequestText(headersText, body) }); + } + function changeRequest(text: string) { + const parts = splitRequestText(text); + setRequest({ ...parts, text }); + updateTab(tab.id, parts); + } const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy }); const setInsecure = (insecure: boolean) => updateTab(tab.id, { insecure }); async function send() { @@ -148,87 +167,27 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { ) : null} -
-
-
标签历史
-
- {history.length === 0 ? ( -

发送后会出现在这里

- ) : ( - history.map((entry) => ( - - )) - )} -
-
-
-
请求
- - - - 请求头 - -