mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
feat: edit full HTTP requests and manage replay drafts in bulk
This commit is contained in:
1 parent
853a4da57a
commit
dc2e0e84a8
14 files changed
+831
-202
No files matched your search
+30
-1
@@ -32,6 +32,7 @@ var repeaterHopByHop = map[string]struct{}{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type repeaterSendRequest struct {
|
type repeaterSendRequest struct {
|
||||||
|
HTTPVersion string `json:"http_version"`
|
||||||
Method *string `json:"method"`
|
Method *string `json:"method"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
@@ -89,6 +90,13 @@ func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch in.HTTPVersion {
|
||||||
|
case "", "HTTP/1.1", "HTTP/2.0":
|
||||||
|
default:
|
||||||
|
writeErr(w, http.StatusBadRequest, "当前支持 HTTP/1.1 和 HTTP/2.0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
rawURL := strings.TrimSpace(in.URL)
|
rawURL := strings.TrimSpace(in.URL)
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
writeErr(w, http.StatusBadRequest, "url 必填")
|
writeErr(w, http.StatusBadRequest, "url 必填")
|
||||||
@@ -151,9 +159,30 @@ func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Header.Set(name, value)
|
req.Header.Set(name, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transport := s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify)
|
||||||
|
if in.HTTPVersion != "" {
|
||||||
|
tr := transport.(*http.Transport)
|
||||||
|
// Keep TLS ALPN consistent with the explicit protocol, even when an
|
||||||
|
// inherited transport previously advertised h2.
|
||||||
|
if tr.TLSClientConfig == nil {
|
||||||
|
tr.TLSClientConfig = &tls.Config{}
|
||||||
|
} else {
|
||||||
|
tr.TLSClientConfig = tr.TLSClientConfig.Clone()
|
||||||
|
}
|
||||||
|
tr.TLSClientConfig.NextProtos = []string{"http/1.1"}
|
||||||
|
if in.HTTPVersion == "HTTP/2.0" {
|
||||||
|
tr.TLSClientConfig.NextProtos = []string{"h2"}
|
||||||
|
}
|
||||||
|
tr.ForceAttemptHTTP2 = in.HTTPVersion == "HTTP/2.0"
|
||||||
|
tr.Protocols = new(http.Protocols)
|
||||||
|
tr.Protocols.SetHTTP1(in.HTTPVersion == "HTTP/1.1")
|
||||||
|
tr.Protocols.SetHTTP2(in.HTTPVersion == "HTTP/2.0")
|
||||||
|
tr.Protocols.SetUnencryptedHTTP2(in.HTTPVersion == "HTTP/2.0" && strings.EqualFold(u.Scheme, "http"))
|
||||||
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: time.Duration(timeoutMs) * time.Millisecond,
|
Timeout: time.Duration(timeoutMs) * time.Millisecond,
|
||||||
Transport: s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify),
|
Transport: transport,
|
||||||
CheckRedirect: func(next *http.Request, via []*http.Request) error {
|
CheckRedirect: func(next *http.Request, via []*http.Request) error {
|
||||||
if len(via) >= 10 {
|
if len(via) >= 10 {
|
||||||
return errors.New("重定向次数过多")
|
return errors.New("重定向次数过多")
|
||||||
|
|||||||
@@ -402,3 +402,41 @@ func TestRepeaterRedactProxyCredentials(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRepeaterHonorsExplicitHTTPVersion(t *testing.T) {
|
||||||
|
t.Setenv(repeaterAllowLocalEnv, "1")
|
||||||
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, r.Proto)
|
||||||
|
}))
|
||||||
|
srv.EnableHTTP2 = true
|
||||||
|
srv.StartTLS()
|
||||||
|
defer srv.Close()
|
||||||
|
for _, version := range []string{"HTTP/1.1", "HTTP/2.0"} {
|
||||||
|
t.Run(version, func(t *testing.T) {
|
||||||
|
code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{
|
||||||
|
"method": "GET", "url": srv.URL, "http_version": version,
|
||||||
|
"insecure_skip_verify": true, "use_recording_proxy": false,
|
||||||
|
})
|
||||||
|
if code != http.StatusOK || out.Error != "" || out.Body != version {
|
||||||
|
t.Fatalf("version %s: status=%d response=%s", version, code, raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
code, _, _ := repeaterTestSend(t, &Server{}, map[string]any{"method": "GET", "url": srv.URL, "http_version": "HTTP/1.0"})
|
||||||
|
if code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("unsupported version status=%d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepeaterExplicitHTTP2Cleartext(t *testing.T) {
|
||||||
|
t.Setenv(repeaterAllowLocalEnv, "1")
|
||||||
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, r.Proto) }))
|
||||||
|
srv.Config.Protocols = new(http.Protocols)
|
||||||
|
srv.Config.Protocols.SetUnencryptedHTTP2(true)
|
||||||
|
srv.Start()
|
||||||
|
defer srv.Close()
|
||||||
|
code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{"method": "GET", "url": srv.URL, "http_version": "HTTP/2.0", "use_recording_proxy": false})
|
||||||
|
if code != http.StatusOK || out.Error != "" || out.Body != "HTTP/2.0" {
|
||||||
|
t.Fatalf("status=%d response=%s", code, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from "lucide-react";
|
import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { headersFromReqHead } from "@/components/repeater/repeater-http";
|
import { headersFromReqHead, requestFromEvidence } from "@/components/repeater/repeater-http";
|
||||||
import { seedFromEvidence } from "@/components/repeater/repeater-store";
|
import { seedFromEvidence } from "@/components/repeater/repeater-store";
|
||||||
import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer";
|
import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer";
|
||||||
import { TrafficPickerDialog } from "@/components/traffic-picker-dialog";
|
import { TrafficPickerDialog } from "@/components/traffic-picker-dialog";
|
||||||
@@ -107,12 +107,15 @@ export function FindingTrafficPanel({
|
|||||||
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
|
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
|
||||||
else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载");
|
else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载");
|
||||||
seedFromEvidence(
|
seedFromEvidence(
|
||||||
{
|
requestFromEvidence(
|
||||||
method: detail.binding.snapshot.method || "GET",
|
{
|
||||||
url: detail.binding.snapshot.url || "",
|
method: detail.binding.snapshot.method || "GET",
|
||||||
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
|
url: detail.binding.snapshot.url || "",
|
||||||
body: detail.request.binary ? "" : detail.request.content || "",
|
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
|
||||||
},
|
body: detail.request.binary ? "" : detail.request.content || "",
|
||||||
|
},
|
||||||
|
detail.binding.snapshot.req_head,
|
||||||
|
),
|
||||||
{ findingId, bindingId },
|
{ findingId, bindingId },
|
||||||
);
|
);
|
||||||
router.push("/function/repeater");
|
router.push("/function/repeater");
|
||||||
|
|||||||
@@ -9,58 +9,66 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Field, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildRawRequest,
|
||||||
buildRawResponse,
|
buildRawResponse,
|
||||||
getHostHeader,
|
connectionOrigin,
|
||||||
joinRequestText,
|
|
||||||
parseHeadersText,
|
parseHeadersText,
|
||||||
setHostHeader,
|
parseRawRequest,
|
||||||
splitRequestText,
|
|
||||||
} from "./repeater-http";
|
} from "./repeater-http";
|
||||||
import { newId, type RepeaterTab } from "./repeater-state";
|
import { newId, type RepeaterTab } from "./repeater-state";
|
||||||
import { useRepeaterStore } from "./repeater-store";
|
import { useRepeaterStore } from "./repeater-store";
|
||||||
import { RepeaterTextPane } from "./repeater-text-pane";
|
import { RepeaterTextPane } from "./repeater-text-pane";
|
||||||
|
|
||||||
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
||||||
|
|
||||||
export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||||
const { method, url, headersText, body, useProxy, insecure, response } = tab;
|
const { useProxy, insecure, response } = tab;
|
||||||
const updateTab = useRepeaterStore((state) => state.updateTab);
|
const updateTab = useRepeaterStore((state) => state.updateTab);
|
||||||
const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined);
|
const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined);
|
||||||
const setMethod = (method: string) => updateTab(tab.id, { method });
|
const [targetOpen, setTargetOpen] = React.useState(false);
|
||||||
const setURL = (url: string) => updateTab(tab.id, { url });
|
const [targetDraft, setTargetDraft] = React.useState("");
|
||||||
const setHeadersText = (headersText: string) => updateTab(tab.id, { headersText });
|
const targetOrigin = tab.targetOrigin ?? (tab.rawRequest === undefined ? connectionOrigin(tab.url) : "");
|
||||||
const [request, setRequest] = React.useState(() => ({ headersText, body, text: joinRequestText(headersText, body) }));
|
const requestText = buildRawRequest(tab);
|
||||||
// Keep partial edits (including a single trailing newline) intact while syncing
|
function changeRequest(rawRequest: string) {
|
||||||
// changes made by the Host control or by restoring a history entry.
|
try {
|
||||||
if (request.headersText !== headersText || request.body !== body) {
|
const { httpVersion: _version, ...parts } = parseRawRequest(rawRequest, targetOrigin);
|
||||||
setRequest({ headersText, body, text: joinRequestText(headersText, body) });
|
updateTab(tab.id, { ...parts, rawRequest, targetOrigin });
|
||||||
}
|
} catch {
|
||||||
function changeRequest(text: string) {
|
// Preserve incomplete first lines and headers as drafts without rewriting
|
||||||
const parts = splitRequestText(text);
|
// the text or moving the caret. Validate only when the user sends.
|
||||||
setRequest({ ...parts, text });
|
updateTab(tab.id, { rawRequest, targetOrigin });
|
||||||
updateTab(tab.id, parts);
|
}
|
||||||
}
|
}
|
||||||
const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy });
|
const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy });
|
||||||
const setInsecure = (insecure: boolean) => updateTab(tab.id, { insecure });
|
const setInsecure = (insecure: boolean) => updateTab(tab.id, { insecure });
|
||||||
async function send() {
|
async function send() {
|
||||||
const store = useRepeaterStore.getState();
|
const store = useRepeaterStore.getState();
|
||||||
if (store.inflight[tab.id] !== undefined) return;
|
if (store.inflight[tab.id] !== undefined) return;
|
||||||
const target = url.trim();
|
let parsed: ReturnType<typeof parseRawRequest>;
|
||||||
if (!target) {
|
try {
|
||||||
toast.error("请填写 URL");
|
parsed = parseRawRequest(requestText, targetOrigin);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error).message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { method, url: target, headersText, body, httpVersion } = parsed;
|
||||||
const requestGeneration = store.beginSend(tab.id);
|
const requestGeneration = store.beginSend(tab.id);
|
||||||
if (requestGeneration === undefined) return;
|
if (requestGeneration === undefined) return;
|
||||||
try {
|
try {
|
||||||
const result = await api.repeaterSend({
|
const result = await api.repeaterSend({
|
||||||
method,
|
method,
|
||||||
|
http_version: httpVersion,
|
||||||
url: target,
|
url: target,
|
||||||
headers: parseHeadersText(headersText),
|
headers: parseHeadersText(headersText),
|
||||||
body,
|
body,
|
||||||
@@ -73,6 +81,8 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
|||||||
url: target,
|
url: target,
|
||||||
headersText,
|
headersText,
|
||||||
body,
|
body,
|
||||||
|
rawRequest: requestText,
|
||||||
|
targetOrigin,
|
||||||
useProxy,
|
useProxy,
|
||||||
insecure,
|
insecure,
|
||||||
response: result,
|
response: result,
|
||||||
@@ -87,57 +97,26 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||||
<FieldGroup className="flex-row flex-wrap items-end gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Field className="w-28">
|
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs" title={targetOrigin || tab.url}>
|
||||||
<FieldLabel htmlFor="repeater-method" className="mb-1 block text-muted-foreground text-xs">
|
发送目标:{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"}
|
||||||
方法
|
</span>
|
||||||
</FieldLabel>
|
<Button
|
||||||
<Select value={method} onValueChange={setMethod} disabled={busy}>
|
variant="outline"
|
||||||
<SelectTrigger id="repeater-method">
|
size="sm"
|
||||||
<SelectValue />
|
disabled={busy}
|
||||||
</SelectTrigger>
|
onClick={() => {
|
||||||
<SelectContent>
|
setTargetDraft(targetOrigin || connectionOrigin(tab.url));
|
||||||
<SelectGroup>
|
setTargetOpen(true);
|
||||||
{[...new Set([...METHODS, method])].map((item) => (
|
}}
|
||||||
<SelectItem key={item} value={item}>
|
>
|
||||||
{item}
|
修改目标
|
||||||
</SelectItem>
|
</Button>
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field className="min-w-[240px] flex-1">
|
|
||||||
<FieldLabel htmlFor="repeater-url" className="mb-1 block text-muted-foreground text-xs">
|
|
||||||
URL
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
disabled={busy}
|
|
||||||
id="repeater-url"
|
|
||||||
value={url}
|
|
||||||
onChange={(event) => setURL(event.target.value)}
|
|
||||||
placeholder="https://target.example/path"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field className="min-w-[180px] flex-1">
|
|
||||||
<FieldLabel htmlFor="repeater-host" className="mb-1 block text-muted-foreground text-xs">
|
|
||||||
Host 请求头
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="repeater-host"
|
|
||||||
disabled={busy}
|
|
||||||
value={getHostHeader(headersText)}
|
|
||||||
onChange={(event) => setHeadersText(setHostHeader(headersText, event.target.value))}
|
|
||||||
placeholder="可选,留空使用 URL 主机"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Button disabled={busy} onClick={() => void send()}>
|
<Button disabled={busy} onClick={() => void send()}>
|
||||||
<SendIcon data-icon="inline-start" />
|
<SendIcon data-icon="inline-start" />
|
||||||
{busy ? "发送中…" : "发送"}
|
{busy ? "发送中…" : "发送"}
|
||||||
</Button>
|
</Button>
|
||||||
</FieldGroup>
|
</div>
|
||||||
<FieldSet className="flex-row flex-wrap items-center gap-4 text-sm">
|
<FieldSet className="flex-row flex-wrap items-center gap-4 text-sm">
|
||||||
<FieldLegend className="sr-only">发送选项</FieldLegend>
|
<FieldLegend className="sr-only">发送选项</FieldLegend>
|
||||||
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-use-proxy">
|
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-use-proxy">
|
||||||
@@ -171,10 +150,10 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
|||||||
<RepeaterTextPane
|
<RepeaterTextPane
|
||||||
id="repeater-request"
|
id="repeater-request"
|
||||||
label="请求"
|
label="请求"
|
||||||
value={request.text}
|
value={requestText}
|
||||||
onChange={changeRequest}
|
onChange={changeRequest}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
placeholder="Header: value(请求头与正文用空行分隔)"
|
placeholder="POST /path HTTP/1.1"
|
||||||
/>
|
/>
|
||||||
<div className="flex min-h-[300px] min-w-0 flex-col gap-2 xl:min-h-0">
|
<div className="flex min-h-[300px] min-w-0 flex-col gap-2 xl:min-h-0">
|
||||||
{response?.error ? (
|
{response?.error ? (
|
||||||
@@ -190,6 +169,50 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Dialog open={targetOpen} onOpenChange={setTargetOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>修改发送目标</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
设置连接地址和 HTTP/HTTPS。方法、路径和 Host 均在左侧报文中编辑;Host 可以与连接目标不同。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const origin = targetDraft.trim() ? connectionOrigin(targetDraft.trim()) : "";
|
||||||
|
if (targetDraft.trim() && !origin) {
|
||||||
|
toast.error("请填写 http:// 或 https:// 开头的地址");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { httpVersion: _version, ...parts } = parseRawRequest(requestText, origin);
|
||||||
|
updateTab(tab.id, { ...parts, rawRequest: requestText, targetOrigin: origin });
|
||||||
|
} catch {
|
||||||
|
updateTab(tab.id, { rawRequest: requestText, targetOrigin: origin });
|
||||||
|
}
|
||||||
|
setTargetOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="repeater-target">发送目标</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="repeater-target"
|
||||||
|
placeholder="https://example.com:443,留空按 Host 连接"
|
||||||
|
value={targetDraft}
|
||||||
|
onChange={(event) => setTargetDraft(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setTargetOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">保存目标</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { buildRawResponse } from "./repeater-http";
|
||||||
|
import type { RepeaterTab } from "./repeater-state";
|
||||||
|
|
||||||
|
export type RepeaterFilters = { query: string; method: string; path: string; response: string; status: string };
|
||||||
|
export const DEFAULT_FILTERS: RepeaterFilters = { query: "", method: "all", path: "", response: "", status: "all" };
|
||||||
|
|
||||||
|
export function matchesRepeaterFilters(tab: RepeaterTab, filters: RepeaterFilters): boolean {
|
||||||
|
const includes = (text: string, query: string) => text.toLowerCase().includes(query.trim().toLowerCase());
|
||||||
|
if (filters.method !== "all" && tab.method.toUpperCase() !== filters.method) return false;
|
||||||
|
let path = tab.url;
|
||||||
|
try {
|
||||||
|
const url = new URL(tab.url);
|
||||||
|
path = url.pathname + url.search;
|
||||||
|
} catch {
|
||||||
|
/* Incomplete drafts remain searchable. */
|
||||||
|
}
|
||||||
|
if (!includes(path, filters.path)) return false;
|
||||||
|
const rawResponse = tab.response ? `${buildRawResponse(tab.response)}\n${tab.response.error || ""}` : "";
|
||||||
|
if (!includes(rawResponse, filters.response)) return false;
|
||||||
|
if (filters.status === "pending" && tab.response) return false;
|
||||||
|
if (filters.status === "error" && (!tab.response?.error || tab.response.status)) return false;
|
||||||
|
if (/^[1-5]xx$/.test(filters.status) && Math.floor((tab.response?.status || 0) / 100) !== Number(filters.status[0]))
|
||||||
|
return false;
|
||||||
|
return includes(
|
||||||
|
[tab.title, tab.url, tab.rawRequest || "", tab.headersText, tab.body, rawResponse].join("\n"),
|
||||||
|
filters.query,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ export function RepeaterHistory({ tab }: { tab?: RepeaterTab }) {
|
|||||||
url: entry.url,
|
url: entry.url,
|
||||||
headersText: entry.headersText,
|
headersText: entry.headersText,
|
||||||
body: entry.body,
|
body: entry.body,
|
||||||
|
rawRequest: entry.rawRequest,
|
||||||
|
targetOrigin: entry.targetOrigin,
|
||||||
useProxy: entry.useProxy,
|
useProxy: entry.useProxy,
|
||||||
insecure: entry.insecure,
|
insecure: entry.insecure,
|
||||||
response: entry.response,
|
response: entry.response,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export type RepeaterInitial = {
|
|||||||
url?: string;
|
url?: string;
|
||||||
headersText?: string;
|
headersText?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
rawRequest?: string;
|
||||||
|
targetOrigin?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseHeadersText(text: string): Record<string, string> {
|
export function parseHeadersText(text: string): Record<string, string> {
|
||||||
@@ -107,10 +109,74 @@ export function requestFromTraffic(
|
|||||||
} catch {
|
} catch {
|
||||||
/* Legacy URL uses the indexed host. */
|
/* Legacy URL uses the indexed host. */
|
||||||
}
|
}
|
||||||
|
const headersText = getHostHeader(request.headersText)
|
||||||
|
? request.headersText
|
||||||
|
: setHostHeader(request.headersText, host);
|
||||||
|
const rawRequest = `${firstLine[0].trimEnd()}\n${joinRequestText(headersText, request.body)}`;
|
||||||
|
const parsed = parseRawRequest(rawRequest, connectionOrigin(exchange.url));
|
||||||
return {
|
return {
|
||||||
method: exchange.method,
|
method: parsed.method,
|
||||||
url: exchange.url,
|
url: parsed.url,
|
||||||
headersText: getHostHeader(request.headersText) ? request.headersText : setHostHeader(request.headersText, host),
|
headersText: parsed.headersText,
|
||||||
body: request.body,
|
body: parsed.body,
|
||||||
|
rawRequest,
|
||||||
|
targetOrigin: connectionOrigin(exchange.url),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function connectionOrigin(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return /^https?:$/.test(parsed.protocol) ? parsed.origin : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRawRequest(request: RepeaterInitial): string {
|
||||||
|
if (request.rawRequest !== undefined) return request.rawRequest;
|
||||||
|
let target = "/";
|
||||||
|
let host = "";
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url ?? "");
|
||||||
|
target = url.pathname + url.search;
|
||||||
|
host = url.host;
|
||||||
|
} catch {
|
||||||
|
/* New empty draft. */
|
||||||
|
}
|
||||||
|
const headersText = request.headersText?.trim() ? request.headersText : `Host: ${host}`;
|
||||||
|
return `${request.method ?? "GET"} ${target} HTTP/1.1\n${joinRequestText(headersText, request.body ?? "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRawRequest(
|
||||||
|
text: string,
|
||||||
|
targetOrigin = "",
|
||||||
|
): { method: string; url: string; headersText: string; body: string; httpVersion: string } {
|
||||||
|
const newline = text.indexOf("\n");
|
||||||
|
const line = (newline < 0 ? text : text.slice(0, newline)).trim();
|
||||||
|
const match = /^([A-Za-z]+)\s+(\S+)\s+(HTTP\/(?:1\.[01]|2(?:\.0)?))$/.exec(line);
|
||||||
|
if (!match) throw new Error("请求首行应为:POST /path HTTP/1.1");
|
||||||
|
const parts = splitRequestText(newline < 0 ? "" : text.slice(newline + 1));
|
||||||
|
let url: URL;
|
||||||
|
if (/^https?:\/\//i.test(match[2])) url = new URL(match[2]);
|
||||||
|
else {
|
||||||
|
if (!match[2].startsWith("/")) throw new Error("请求路径必须以 / 开头,或填写完整 http/https URL");
|
||||||
|
const origin = connectionOrigin(targetOrigin) || connectionOrigin(`https://${getHostHeader(parts.headersText)}`);
|
||||||
|
if (!origin) throw new Error("请填写 Host 请求头或设置发送目标");
|
||||||
|
url = new URL(origin + match[2]);
|
||||||
|
}
|
||||||
|
if (url.hash) throw new Error("请求路径不能包含 URL 片段,请使用 %23 表示 #");
|
||||||
|
return {
|
||||||
|
...parts,
|
||||||
|
method: match[1].toUpperCase(),
|
||||||
|
url: url.href,
|
||||||
|
httpVersion: match[3] === "HTTP/2" ? "HTTP/2.0" : match[3],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestFromEvidence(initial: RepeaterInitial, requestHead?: string): RepeaterInitial {
|
||||||
|
const firstLine = requestHead?.trimStart().split(/\r?\n/, 1)[0] || "";
|
||||||
|
if (!/^[A-Za-z]+\s+\S+\s+HTTP\/\d(?:\.\d)?$/.test(firstLine)) return initial;
|
||||||
|
const rawRequest = `${firstLine}\n${joinRequestText(initial.headersText ?? "", initial.body ?? "")}`;
|
||||||
|
return { ...initial, rawRequest, targetOrigin: connectionOrigin(initial.url ?? "") };
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { ChevronDown, ChevronRight, ListFilter, Pencil, Plus, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Field, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { DEFAULT_FILTERS, matchesRepeaterFilters } from "./repeater-filter";
|
||||||
|
import { RepeaterHistory } from "./repeater-history";
|
||||||
|
import type { RepeaterWorkspace } from "./repeater-state";
|
||||||
|
import { useRepeaterStore } from "./repeater-store";
|
||||||
|
|
||||||
|
type Rename = { kind: "tab" | "group"; id: string; name: string };
|
||||||
|
|
||||||
|
export function RepeaterNavigator({
|
||||||
|
workspace,
|
||||||
|
onRename,
|
||||||
|
}: {
|
||||||
|
workspace: RepeaterWorkspace;
|
||||||
|
onRename: (value: Rename) => void;
|
||||||
|
}) {
|
||||||
|
const store = useRepeaterStore();
|
||||||
|
const [filters, setFilters] = React.useState(DEFAULT_FILTERS);
|
||||||
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
|
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
|
||||||
|
const [deleting, setDeleting] = React.useState(false);
|
||||||
|
const visible = React.useMemo(
|
||||||
|
() => workspace.tabs.filter((tab) => matchesRepeaterFilters(tab, filters)),
|
||||||
|
[workspace.tabs, filters],
|
||||||
|
);
|
||||||
|
const selectedIds = workspace.tabs.filter((tab) => selected.has(tab.id)).map((tab) => tab.id);
|
||||||
|
const visibleIds = new Set(visible.map((tab) => tab.id));
|
||||||
|
const hiddenSelected = selectedIds.filter((id) => !visibleIds.has(id)).length;
|
||||||
|
const filtering = Object.entries(filters).some(
|
||||||
|
([key, value]) => value !== DEFAULT_FILTERS[key as keyof typeof filters],
|
||||||
|
);
|
||||||
|
const allSelected = visible.length > 0 && visible.every((tab) => selected.has(tab.id));
|
||||||
|
const active = workspace.tabs.find((tab) => tab.id === workspace.activeTabId);
|
||||||
|
const methods = [
|
||||||
|
...new Set([
|
||||||
|
"GET",
|
||||||
|
"POST",
|
||||||
|
"PUT",
|
||||||
|
"PATCH",
|
||||||
|
"DELETE",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS",
|
||||||
|
...workspace.tabs.map((tab) => tab.method.toUpperCase()),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
function toggle(id: string, checked: boolean) {
|
||||||
|
setSelected((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (checked) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="请求重发分组和标签"
|
||||||
|
className="flex h-[28rem] min-h-0 w-full shrink-0 flex-col gap-3 overflow-y-auto rounded-md border p-3 lg:h-[calc(100dvh-14rem)] lg:min-h-[480px] lg:w-80 xl:h-auto xl:min-h-0"
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
aria-label="全局搜索重发请求"
|
||||||
|
placeholder="搜索全部标题、请求或响应…"
|
||||||
|
value={filters.query}
|
||||||
|
onChange={(event) => setFilters({ ...filters, query: event.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={filters.method} onValueChange={(method) => setFilters({ ...filters, method })}>
|
||||||
|
<SelectTrigger aria-label="筛选请求方法" className="min-w-0 flex-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="all">全部方法</SelectItem>
|
||||||
|
{methods.map((method) => (
|
||||||
|
<SelectItem key={method} value={method}>
|
||||||
|
{method}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant={expanded ? "secondary" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls="repeater-extra-filters"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
<ListFilter />
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<div id="repeater-extra-filters" className="flex flex-col gap-2">
|
||||||
|
<Field className="gap-1">
|
||||||
|
<FieldLabel htmlFor="repeater-filter-path" className="text-xs">
|
||||||
|
请求路径
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="repeater-filter-path"
|
||||||
|
placeholder="例如 /api/upload"
|
||||||
|
value={filters.path}
|
||||||
|
onChange={(event) => setFilters({ ...filters, path: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field className="gap-1">
|
||||||
|
<FieldLabel htmlFor="repeater-filter-response" className="text-xs">
|
||||||
|
响应内容
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="repeater-filter-response"
|
||||||
|
placeholder="响应头或正文关键字"
|
||||||
|
value={filters.response}
|
||||||
|
onChange={(event) => setFilters({ ...filters, response: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Select value={filters.status} onValueChange={(status) => setFilters({ ...filters, status })}>
|
||||||
|
<SelectTrigger aria-label="筛选响应状态" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="all">全部响应状态</SelectItem>
|
||||||
|
<SelectItem value="pending">尚未响应</SelectItem>
|
||||||
|
<SelectItem value="error">发送失败</SelectItem>
|
||||||
|
{["1xx", "2xx", "3xx", "4xx", "5xx"].map((status) => (
|
||||||
|
<SelectItem key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center justify-between gap-2 text-muted-foreground text-xs">
|
||||||
|
<span>
|
||||||
|
{visible.length} / {workspace.tabs.length} 个请求
|
||||||
|
</span>
|
||||||
|
{filtering ? (
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 px-1 text-xs" onClick={() => setFilters(DEFAULT_FILTERS)}>
|
||||||
|
清除筛选
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col gap-2 border-y py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="repeater-select-visible"
|
||||||
|
aria-label="全选筛选结果"
|
||||||
|
checked={allSelected || (visible.some((tab) => selected.has(tab.id)) ? "indeterminate" : false)}
|
||||||
|
disabled={!visible.length}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSelected((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
for (const tab of visible) {
|
||||||
|
if (checked === true) next.add(tab.id);
|
||||||
|
else next.delete(tab.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label htmlFor="repeater-select-visible" className="text-xs">
|
||||||
|
全选结果
|
||||||
|
</label>
|
||||||
|
<span className="ml-auto text-muted-foreground text-xs">
|
||||||
|
已选 {selectedIds.length} 条{hiddenSelected ? `(隐藏 ${hiddenSelected})` : ""}
|
||||||
|
</span>
|
||||||
|
{selectedIds.length ? (
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="清空勾选" onClick={() => setSelected(new Set())}>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{selectedIds.length ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
onValueChange={(groupId) => {
|
||||||
|
store.setTabsGroup(selectedIds, groupId);
|
||||||
|
toast.success(`已移动 ${selectedIds.length} 个请求`);
|
||||||
|
setSelected(new Set());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="批量移动到分组" className="min-w-0 flex-1">
|
||||||
|
<SelectValue placeholder="移动到分组…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{workspace.groups.map((group) => (
|
||||||
|
<SelectItem key={group.id} value={group.id}>
|
||||||
|
{group.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => setDeleting(true)}>
|
||||||
|
删除所选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<section className="min-h-24 flex-1 overflow-y-auto" aria-label="重发请求列表">
|
||||||
|
{workspace.groups.map((group) => {
|
||||||
|
const tabs = visible.filter((tab) => tab.groupId === group.id);
|
||||||
|
if (filtering && !tabs.length) return null;
|
||||||
|
const collapsed = group.collapsed && !filtering;
|
||||||
|
return (
|
||||||
|
<div key={group.id} className="flex flex-col gap-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`${collapsed ? "展开" : "折叠"}分组 ${group.name}`}
|
||||||
|
disabled={filtering}
|
||||||
|
onClick={() => store.toggleGroupCollapsed(group.id)}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight /> : <ChevronDown />}
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium text-sm" title={group.name}>
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{tabs.length}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`重命名分组 ${group.name}`}
|
||||||
|
onClick={() => onRename({ kind: "group", id: group.id, name: group.name })}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`新建标签 · ${group.name}`}
|
||||||
|
title="新建标签"
|
||||||
|
onClick={() => store.createTab({}, { groupId: group.id })}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!collapsed
|
||||||
|
? tabs.map((tab) => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 items-center gap-1 rounded-md px-1",
|
||||||
|
active?.id === tab.id && "bg-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
aria-label={`勾选请求 ${tab.title}`}
|
||||||
|
checked={selected.has(tab.id)}
|
||||||
|
onCheckedChange={(checked) => toggle(tab.id, checked === true)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-current={active?.id === tab.id ? "page" : undefined}
|
||||||
|
className="min-w-0 flex-1 justify-start px-1"
|
||||||
|
onClick={() => store.setActiveTab(tab.id)}
|
||||||
|
onDoubleClick={() => onRename({ kind: "tab", id: tab.id, name: tab.title })}
|
||||||
|
title={`${tab.title}\n${tab.url}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{tab.title}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`重命名标签 ${tab.title}`}
|
||||||
|
onClick={() => onRename({ kind: "tab", id: tab.id, name: tab.title })}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`关闭标签 ${tab.title}`}
|
||||||
|
onClick={() => store.closeTab(tab.id)}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!visible.length ? (
|
||||||
|
<p className="p-2 text-muted-foreground text-xs">没有匹配的请求,清除筛选后可查看全部。</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
<div className="flex max-h-[35%] min-h-20 flex-1 flex-col">
|
||||||
|
<RepeaterHistory tab={active} />
|
||||||
|
</div>
|
||||||
|
<AlertDialog open={deleting} onOpenChange={setDeleting}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>删除所选的 {selectedIds.length} 个请求?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
这些请求的草稿和历史响应将从当前浏览器删除
|
||||||
|
{hiddenSelected ? `,其中包含 ${hiddenSelected} 个被筛选隐藏的请求` : ""}
|
||||||
|
。正在发送的请求将不再保存返回结果。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!selectedIds.length}
|
||||||
|
onClick={() => {
|
||||||
|
store.closeTabs(selectedIds);
|
||||||
|
toast.success(`已删除 ${selectedIds.length} 个请求`);
|
||||||
|
setSelected(new Set());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ export type RepeaterHistoryEntry = {
|
|||||||
url: string;
|
url: string;
|
||||||
headersText: string;
|
headersText: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
rawRequest?: string;
|
||||||
|
targetOrigin?: string;
|
||||||
useProxy: boolean;
|
useProxy: boolean;
|
||||||
insecure: boolean;
|
insecure: boolean;
|
||||||
response: RepeaterSendResponse;
|
response: RepeaterSendResponse;
|
||||||
@@ -37,7 +39,10 @@ export type RepeaterWorkspace = {
|
|||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
};
|
};
|
||||||
export type TabSeed = Partial<
|
export type TabSeed = Partial<
|
||||||
Pick<RepeaterTab, "method" | "url" | "headersText" | "body" | "useProxy" | "insecure" | "source">
|
Pick<
|
||||||
|
RepeaterTab,
|
||||||
|
"method" | "url" | "headersText" | "body" | "rawRequest" | "targetOrigin" | "useProxy" | "insecure" | "source"
|
||||||
|
>
|
||||||
>;
|
>;
|
||||||
export type TabOptions = { groupId?: string; title?: string; activate?: boolean };
|
export type TabOptions = { groupId?: string; title?: string; activate?: boolean };
|
||||||
|
|
||||||
@@ -169,6 +174,8 @@ function isRequest(
|
|||||||
return (
|
return (
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
["id", "method", "url", "headersText", "body"].every((key) => typeof value[key] === "string") &&
|
["id", "method", "url", "headersText", "body"].every((key) => typeof value[key] === "string") &&
|
||||||
|
(value.rawRequest === undefined || typeof value.rawRequest === "string") &&
|
||||||
|
(value.targetOrigin === undefined || typeof value.targetOrigin === "string") &&
|
||||||
typeof value.useProxy === "boolean" &&
|
typeof value.useProxy === "boolean" &&
|
||||||
typeof value.insecure === "boolean"
|
typeof value.insecure === "boolean"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ const {
|
|||||||
truncateResponse,
|
truncateResponse,
|
||||||
} = require("./repeater-state.ts");
|
} = require("./repeater-state.ts");
|
||||||
const {
|
const {
|
||||||
|
requestFromEvidence,
|
||||||
|
buildRawRequest,
|
||||||
|
parseRawRequest,
|
||||||
joinRequestText,
|
joinRequestText,
|
||||||
splitRequestText,
|
splitRequestText,
|
||||||
requestFromTraffic,
|
requestFromTraffic,
|
||||||
@@ -30,6 +33,7 @@ const {
|
|||||||
parseHeadersText,
|
parseHeadersText,
|
||||||
setHostHeader,
|
setHostHeader,
|
||||||
} = require("./repeater-http.ts");
|
} = require("./repeater-http.ts");
|
||||||
|
const { DEFAULT_FILTERS, matchesRepeaterFilters } = require("./repeater-filter.ts");
|
||||||
const { flush, useRepeaterStore } = require("./repeater-store.ts");
|
const { flush, useRepeaterStore } = require("./repeater-store.ts");
|
||||||
const { toast } = require("sonner");
|
const { toast } = require("sonner");
|
||||||
|
|
||||||
@@ -413,12 +417,13 @@ test("combined request editing preserves body newlines and splits only the first
|
|||||||
test("traffic import uses captured metadata, preserves Host overrides and the full body", () => {
|
test("traffic import uses captured metadata, preserves Host overrides and the full body", () => {
|
||||||
const flow = { method: "POST", url: "https://example.test:8443/upload?q=1", host: "example.test" };
|
const flow = { method: "POST", url: "https://example.test:8443/upload?q=1", host: "example.test" };
|
||||||
const raw = "POST /upload?q=1 HTTP/1.1\r\nHost: override.test\r\nCookie: a=b\r\n\r\nfirst\r\n\r\nlast\n";
|
const raw = "POST /upload?q=1 HTTP/1.1\r\nHost: override.test\r\nCookie: a=b\r\n\r\nfirst\r\n\r\nlast\n";
|
||||||
assert.deepEqual(requestFromTraffic(raw, flow), {
|
const imported = requestFromTraffic(raw, flow);
|
||||||
method: "POST",
|
assert.equal(imported.method, "POST");
|
||||||
url: flow.url,
|
assert.equal(imported.url, flow.url);
|
||||||
headersText: "Host: override.test\r\nCookie: a=b",
|
assert.equal(imported.headersText, "Host: override.test\r\nCookie: a=b");
|
||||||
body: "first\r\n\r\nlast\n",
|
assert.equal(imported.body, "first\r\n\r\nlast\n");
|
||||||
});
|
assert.match(imported.rawRequest, /^POST \/upload\?q=1 HTTP\/1.1\n/);
|
||||||
|
assert.equal(imported.targetOrigin, "https://example.test:8443");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
requestFromTraffic("POST /upload HTTP/1.1\nContent-Type: text/plain\n\nhello", flow).headersText,
|
requestFromTraffic("POST /upload HTTP/1.1\nContent-Type: text/plain\n\nhello", flow).headersText,
|
||||||
"Host: example.test:8443\nContent-Type: text/plain",
|
"Host: example.test:8443\nContent-Type: text/plain",
|
||||||
@@ -437,3 +442,143 @@ test("traffic import rejects failed loads and blob preview markers rather than s
|
|||||||
/截断预览/,
|
/截断预览/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("global replay filters combine title, request, response, method, path and status", () => {
|
||||||
|
const tab = {
|
||||||
|
...defaultWorkspace().tabs[0],
|
||||||
|
title: "上传测试",
|
||||||
|
method: "POST",
|
||||||
|
url: "https://example.test/api/upload?q=1",
|
||||||
|
headersText: "X-Test: RequestSecret",
|
||||||
|
body: "request-body",
|
||||||
|
response: response("ResponseSecret"),
|
||||||
|
};
|
||||||
|
const match = (patch) => matchesRepeaterFilters(tab, { ...DEFAULT_FILTERS, ...patch });
|
||||||
|
assert.equal(match({ query: "上传" }), true);
|
||||||
|
assert.equal(match({ query: "requestsecret" }), true);
|
||||||
|
assert.equal(match({ query: "responseSECRET" }), true);
|
||||||
|
assert.equal(match({ method: "POST", path: "/api/upload", response: "ResponseSecret", status: "2xx" }), true);
|
||||||
|
assert.equal(match({ method: "GET" }), false);
|
||||||
|
assert.equal(match({ path: "example.test" }), false);
|
||||||
|
assert.equal(match({ status: "4xx" }), false);
|
||||||
|
assert.equal(match({ response: "missing" }), false);
|
||||||
|
assert.equal(match({ status: "pending" }), false);
|
||||||
|
assert.equal(matchesRepeaterFilters({ ...tab, response: null }, { ...DEFAULT_FILTERS, status: "pending" }), true);
|
||||||
|
assert.equal(
|
||||||
|
matchesRepeaterFilters(
|
||||||
|
{ ...tab, response: { ...response(), status: 0, error: "connection refused" } },
|
||||||
|
{ ...DEFAULT_FILTERS, status: "error", response: "refused" },
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bulk move preserves draft, response and inflight requests and persists the whole selection atomically", (t) => {
|
||||||
|
const { store, writes } = setupStore(t);
|
||||||
|
const [first, other] = store.workspace.tabs;
|
||||||
|
store.recordSend(first.id, entry(1, "original response"));
|
||||||
|
store.createGroup("目标组");
|
||||||
|
const groupId = useRepeaterStore.getState().workspace.groups[1].id;
|
||||||
|
store.toggleGroupCollapsed(groupId);
|
||||||
|
const generation = store.beginSend(first.id);
|
||||||
|
const beforeWrites = writes.length;
|
||||||
|
store.setTabsGroup([first.id, other.id], groupId);
|
||||||
|
const state = useRepeaterStore.getState();
|
||||||
|
assert.equal(
|
||||||
|
state.workspace.tabs.every((tab) => tab.groupId === groupId),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(state.workspace.tabs[0].response.body, "original response");
|
||||||
|
assert.equal(state.workspace.activeTabId, other.id);
|
||||||
|
assert.equal(state.workspace.groups[1].collapsed, false);
|
||||||
|
assert.equal(store.isCurrentSend(first.id, generation), true);
|
||||||
|
assert.equal(writes.length, beforeWrites + 1);
|
||||||
|
store.setTabsGroup([first.id], "missing-group");
|
||||||
|
assert.equal(useRepeaterStore.getState().workspace.tabs[0].groupId, groupId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bulk delete invalidates only removed requests and retains an editable tab after deleting everything", (t) => {
|
||||||
|
const { store } = setupStore(t);
|
||||||
|
const [first, other] = store.workspace.tabs;
|
||||||
|
const thirdId = store.createTab({ body: "keep me" });
|
||||||
|
const firstGeneration = store.beginSend(first.id);
|
||||||
|
const otherGeneration = store.beginSend(other.id);
|
||||||
|
const thirdGeneration = store.beginSend(thirdId);
|
||||||
|
store.setActiveTab(first.id);
|
||||||
|
store.closeTabs([first.id, other.id]);
|
||||||
|
store.completeSend(first.id, firstGeneration, entry(1, "late"));
|
||||||
|
store.completeSend(other.id, otherGeneration, entry(2, "late"));
|
||||||
|
let state = useRepeaterStore.getState();
|
||||||
|
assert.deepEqual(
|
||||||
|
state.workspace.tabs.map((tab) => tab.id),
|
||||||
|
[thirdId],
|
||||||
|
);
|
||||||
|
assert.equal(state.workspace.activeTabId, thirdId);
|
||||||
|
assert.equal(state.workspace.tabs[0].body, "keep me");
|
||||||
|
assert.deepEqual(state.inflight, { [thirdId]: thirdGeneration });
|
||||||
|
store.closeTabs([thirdId]);
|
||||||
|
state = useRepeaterStore.getState();
|
||||||
|
assert.equal(state.workspace.tabs.length, 1);
|
||||||
|
assert.notEqual(state.workspace.tabs[0].id, thirdId);
|
||||||
|
assert.equal(state.workspace.tabs[0].body, "");
|
||||||
|
assert.equal(state.workspace.activeTabId, state.workspace.tabs[0].id);
|
||||||
|
assert.deepEqual(state.inflight, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("raw HTTP editing keeps the first line, method, path, protocol, Host and body together", () => {
|
||||||
|
const tab = {
|
||||||
|
...defaultWorkspace().tabs[0],
|
||||||
|
method: "POST",
|
||||||
|
url: "https://example.test:8443/api/upload?q=1",
|
||||||
|
headersText: "Host: virtual.test\nContent-Type: text/plain",
|
||||||
|
body: "first\n\nlast",
|
||||||
|
};
|
||||||
|
const raw = buildRawRequest(tab);
|
||||||
|
assert.equal(raw, "POST /api/upload?q=1 HTTP/1.1\nHost: virtual.test\nContent-Type: text/plain\n\nfirst\n\nlast");
|
||||||
|
const parsed = parseRawRequest(
|
||||||
|
raw.replace("POST /api/upload?q=1 HTTP/1.1", "PATCH /api/edited?q=2 HTTP/2.0"),
|
||||||
|
"https://example.test:8443",
|
||||||
|
);
|
||||||
|
assert.deepEqual(parsed, {
|
||||||
|
method: "PATCH",
|
||||||
|
url: "https://example.test:8443/api/edited?q=2",
|
||||||
|
httpVersion: "HTTP/2.0",
|
||||||
|
headersText: tab.headersText,
|
||||||
|
body: tab.body,
|
||||||
|
});
|
||||||
|
assert.equal(parseRawRequest("GET /new HTTP/1.1\nHost: new.test\n\n").url, "https://new.test/new");
|
||||||
|
assert.equal(parseRawRequest("GET /new HTTP/1.1\nHost: newer.test\n\n").url, "https://newer.test/new");
|
||||||
|
assert.equal(
|
||||||
|
parseRawRequest("GET http://elsewhere.test/path HTTP/1.1\nHost: vhost.test\n\n", "https://old.test").url,
|
||||||
|
"http://elsewhere.test/path",
|
||||||
|
);
|
||||||
|
assert.throws(() => parseRawRequest("POST /missing-version\nHost: test\n\n"), /首行/);
|
||||||
|
assert.equal(buildRawRequest({ ...tab, rawRequest: "unfinished HTTP draft" }), "unfinished HTTP draft");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("raw request drafts and connection origins survive persistence and history restoration", (t) => {
|
||||||
|
const { store } = setupStore(t);
|
||||||
|
const id = store.workspace.tabs[0].id;
|
||||||
|
const rawRequest = "POST /unfinished HTTP/1.";
|
||||||
|
store.updateTab(id, { rawRequest, targetOrigin: "http://example.test:8080" });
|
||||||
|
const loaded = deserializeWorkspace(serializeWorkspace(useRepeaterStore.getState().workspace));
|
||||||
|
assert.equal(loaded.tabs[0].rawRequest, rawRequest);
|
||||||
|
assert.equal(loaded.tabs[0].targetOrigin, "http://example.test:8080");
|
||||||
|
store.recordSend(id, {
|
||||||
|
...entry(1),
|
||||||
|
rawRequest: "POST /old HTTP/1.1\nHost: example.test\n\nbody",
|
||||||
|
targetOrigin: "https://example.test",
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
useRepeaterStore.getState().workspace.tabs[0].history[0].rawRequest.startsWith("POST /old HTTP/1.1"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evidence import preserves captured request line and protocol rather than removing it", () => {
|
||||||
|
const initial = { method: "GET", url: "https://example.test/path", headersText: "Host: example.test", body: "" };
|
||||||
|
const imported = requestFromEvidence(initial, "GET /path HTTP/2.0\r\nHost: example.test\r\n");
|
||||||
|
assert.equal(imported.rawRequest, "GET /path HTTP/2.0\nHost: example.test\n\n");
|
||||||
|
assert.equal(imported.targetOrigin, "https://example.test");
|
||||||
|
assert.equal(buildRawRequest(imported), imported.rawRequest);
|
||||||
|
});
|
||||||
@@ -24,7 +24,10 @@ import {
|
|||||||
} from "./repeater-state";
|
} from "./repeater-state";
|
||||||
|
|
||||||
export type TabPatch = Partial<
|
export type TabPatch = Partial<
|
||||||
Pick<RepeaterTab, "method" | "url" | "headersText" | "body" | "useProxy" | "insecure" | "response">
|
Pick<
|
||||||
|
RepeaterTab,
|
||||||
|
"method" | "url" | "headersText" | "body" | "rawRequest" | "targetOrigin" | "useProxy" | "insecure" | "response"
|
||||||
|
>
|
||||||
>;
|
>;
|
||||||
type Store = {
|
type Store = {
|
||||||
workspace: RepeaterWorkspace | null;
|
workspace: RepeaterWorkspace | null;
|
||||||
@@ -36,6 +39,8 @@ type Store = {
|
|||||||
hydrate: () => void;
|
hydrate: () => void;
|
||||||
createTab: (seed?: TabSeed, opts?: TabOptions) => string;
|
createTab: (seed?: TabSeed, opts?: TabOptions) => string;
|
||||||
closeTab: (id: string) => void;
|
closeTab: (id: string) => void;
|
||||||
|
closeTabs: (ids: string[]) => void;
|
||||||
|
setTabsGroup: (ids: string[], groupId: string) => void;
|
||||||
renameTab: (id: string, title: string) => void;
|
renameTab: (id: string, title: string) => void;
|
||||||
setActiveTab: (id: string) => void;
|
setActiveTab: (id: string) => void;
|
||||||
updateTab: (id: string, patch: TabPatch) => void;
|
updateTab: (id: string, patch: TabPatch) => void;
|
||||||
@@ -123,23 +128,26 @@ export const useRepeaterStore = create<Store>((set, get) => {
|
|||||||
change(workspace);
|
change(workspace);
|
||||||
return workspace.tabs[workspace.tabs.length - 1].id;
|
return workspace.tabs[workspace.tabs.length - 1].id;
|
||||||
},
|
},
|
||||||
closeTab: (id) => {
|
closeTab: (id) => get().closeTabs([id]),
|
||||||
|
closeTabs: (ids) => {
|
||||||
const workspace = current();
|
const workspace = current();
|
||||||
const index = workspace.tabs.findIndex((tab) => tab.id === id);
|
const removed = new Set(ids);
|
||||||
if (index < 0) return;
|
if (!workspace.tabs.some((tab) => removed.has(tab.id))) return;
|
||||||
const generation = get().inflight[id];
|
const inflight = { ...get().inflight };
|
||||||
if (generation !== undefined) get().clearInflight(id, generation);
|
for (const id of removed) delete inflight[id];
|
||||||
const tabs = workspace.tabs.filter((tab) => tab.id !== id);
|
set({ inflight });
|
||||||
|
const tabs = workspace.tabs.filter((tab) => !removed.has(tab.id));
|
||||||
if (!tabs.length) tabs.push(emptyTab());
|
if (!tabs.length) tabs.push(emptyTab());
|
||||||
|
const activeIndex = workspace.tabs.findIndex((tab) => tab.id === workspace.activeTabId);
|
||||||
|
const active =
|
||||||
|
tabs.find((tab) => tab.id === workspace.activeTabId) ??
|
||||||
|
workspace.tabs.slice(activeIndex + 1).find((tab) => !removed.has(tab.id)) ??
|
||||||
|
tabs[tabs.length - 1];
|
||||||
change({
|
change({
|
||||||
...workspace,
|
...workspace,
|
||||||
tabs,
|
tabs,
|
||||||
groups: workspace.groups.map((group) =>
|
activeTabId: active.id,
|
||||||
group.id === tabs[Math.min(index, tabs.length - 1)].groupId && workspace.activeTabId === id
|
groups: workspace.groups.map((group) => (group.id === active.groupId ? { ...group, collapsed: false } : group)),
|
||||||
? { ...group, collapsed: false }
|
|
||||||
: group,
|
|
||||||
),
|
|
||||||
activeTabId: workspace.activeTabId === id ? tabs[Math.min(index, tabs.length - 1)].id : workspace.activeTabId,
|
|
||||||
});
|
});
|
||||||
flush();
|
flush();
|
||||||
},
|
},
|
||||||
@@ -179,11 +187,20 @@ export const useRepeaterStore = create<Store>((set, get) => {
|
|||||||
groups: workspace.groups.map((group) => (group.id === id ? { ...group, name: name.trim() } : group)),
|
groups: workspace.groups.map((group) => (group.id === id ? { ...group, name: name.trim() } : group)),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
setTabGroup: (tabId, groupId) => {
|
setTabGroup: (tabId, groupId) => get().setTabsGroup([tabId], groupId),
|
||||||
|
setTabsGroup: (ids, groupId) => {
|
||||||
const workspace = current();
|
const workspace = current();
|
||||||
if (!workspace.groups.some((group) => group.id === groupId)) return;
|
if (!workspace.groups.some((group) => group.id === groupId)) return;
|
||||||
editTab(tabId, { groupId });
|
const selected = new Set(ids);
|
||||||
if (workspace.activeTabId === tabId) get().setActiveTab(tabId);
|
if (!workspace.tabs.some((tab) => selected.has(tab.id))) return;
|
||||||
|
change({
|
||||||
|
...workspace,
|
||||||
|
tabs: workspace.tabs.map((tab) =>
|
||||||
|
selected.has(tab.id) ? { ...tab, groupId, updatedAt: new Date().toISOString() } : tab,
|
||||||
|
),
|
||||||
|
groups: workspace.groups.map((group) => (group.id === groupId ? { ...group, collapsed: false } : group)),
|
||||||
|
});
|
||||||
|
flush();
|
||||||
},
|
},
|
||||||
toggleGroupCollapsed: (id) => {
|
toggleGroupCollapsed: (id) => {
|
||||||
const workspace = current();
|
const workspace = current();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { ChevronDown, ChevronRight, FolderPlus, Pencil, Plus, X } from "lucide-react";
|
import { FolderPlus } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -28,10 +28,9 @@ import { Field, FieldLabel } from "@/components/ui/field";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import { RepeaterEditor } from "./repeater-editor";
|
import { RepeaterEditor } from "./repeater-editor";
|
||||||
import { RepeaterHistory } from "./repeater-history";
|
import { RepeaterNavigator } from "./repeater-navigator";
|
||||||
import { useRepeaterStore } from "./repeater-store";
|
import { useRepeaterStore } from "./repeater-store";
|
||||||
|
|
||||||
export function RepeaterWorkspace() {
|
export function RepeaterWorkspace() {
|
||||||
@@ -86,89 +85,7 @@ export function RepeaterWorkspace() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
|
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
|
||||||
<nav
|
<RepeaterNavigator workspace={workspace} onRename={setRename} />
|
||||||
aria-label="请求重发分组和标签"
|
|
||||||
className="flex max-h-64 min-h-0 shrink-0 flex-col gap-3 overflow-hidden rounded-md border p-2 lg:max-h-none lg:w-56"
|
|
||||||
>
|
|
||||||
<div className="max-h-32 shrink-0 overflow-y-auto lg:max-h-[45%]">
|
|
||||||
{workspace.groups.map((group) => (
|
|
||||||
<div key={group.id} className="flex flex-col gap-1">
|
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`${group.collapsed ? "展开" : "折叠"}分组 ${group.name}`}
|
|
||||||
onClick={() => store.toggleGroupCollapsed(group.id)}
|
|
||||||
>
|
|
||||||
{group.collapsed ? <ChevronRight /> : <ChevronDown />}
|
|
||||||
</Button>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-medium text-sm" title={group.name}>
|
|
||||||
{group.name}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`重命名分组 ${group.name}`}
|
|
||||||
onClick={() => setRename({ kind: "group", id: group.id, name: group.name })}
|
|
||||||
>
|
|
||||||
<Pencil />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`新建标签 · ${group.name}`}
|
|
||||||
title="新建标签"
|
|
||||||
onClick={() => store.createTab({}, { groupId: group.id })}
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{!group.collapsed
|
|
||||||
? workspace.tabs
|
|
||||||
.filter((tab) => tab.groupId === group.id)
|
|
||||||
.map((tab) => (
|
|
||||||
<div
|
|
||||||
key={tab.id}
|
|
||||||
className={cn(
|
|
||||||
"flex min-w-0 items-center gap-1 rounded-md",
|
|
||||||
active?.id === tab.id && "bg-accent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
aria-current={active?.id === tab.id ? "page" : undefined}
|
|
||||||
className="min-w-0 flex-1 justify-start"
|
|
||||||
onClick={() => store.setActiveTab(tab.id)}
|
|
||||||
onDoubleClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
|
|
||||||
title={`${tab.title}\n${tab.url}`}
|
|
||||||
>
|
|
||||||
<span className="truncate">{tab.title}</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`重命名标签 ${tab.title}`}
|
|
||||||
onClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
|
|
||||||
>
|
|
||||||
<Pencil />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`关闭标签 ${tab.title}`}
|
|
||||||
onClick={() => store.closeTab(tab.id)}
|
|
||||||
>
|
|
||||||
<X />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<RepeaterHistory tab={active} />
|
|
||||||
</nav>
|
|
||||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
<main className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||||
{active ? (
|
{active ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||||
import { headersFromReqHead } from "@/components/repeater/repeater-http";
|
import { headersFromReqHead, requestFromEvidence } from "@/components/repeater/repeater-http";
|
||||||
import { seedFromEvidence } from "@/components/repeater/repeater-store";
|
import { seedFromEvidence } from "@/components/repeater/repeater-store";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -72,12 +72,15 @@ export function TrafficEvidenceViewer({
|
|||||||
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
|
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
|
||||||
else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载");
|
else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载");
|
||||||
seedFromEvidence(
|
seedFromEvidence(
|
||||||
{
|
requestFromEvidence(
|
||||||
method: detail.binding.snapshot.method || "GET",
|
{
|
||||||
url: detail.binding.snapshot.url || "",
|
method: detail.binding.snapshot.method || "GET",
|
||||||
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
|
url: detail.binding.snapshot.url || "",
|
||||||
body: detail.request.binary ? "" : detail.request.content || "",
|
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
|
||||||
},
|
body: detail.request.binary ? "" : detail.request.content || "",
|
||||||
|
},
|
||||||
|
detail.binding.snapshot.req_head,
|
||||||
|
),
|
||||||
{ findingId, bindingId: detail.binding.id },
|
{ findingId, bindingId: detail.binding.id },
|
||||||
);
|
);
|
||||||
router.push("/function/repeater");
|
router.push("/function/repeater");
|
||||||
|
|||||||
@@ -1645,6 +1645,7 @@ export interface FindingTrafficDetail {
|
|||||||
|
|
||||||
export interface RepeaterSendRequest {
|
export interface RepeaterSendRequest {
|
||||||
method: string;
|
method: string;
|
||||||
|
http_version?: string;
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
|||||||
Reference in new issue
Block a user