Compare commits

..
Author SHA1 Message Date
RuoJi6 5ebc6ec5b7 fix(repeater): 窄屏批量操作行可换行可达 2026-10-27 19:33:48 +08:00
RuoJi6 aff5f2f699 feat(repeater): Burp 式顶栏标签,移除左侧导航
将 RepeaterNavigator 改为顶部横向标签栏(RepeaterTopBar),去掉左侧纵栏;
保留搜索/方法过滤、分组与标签增删改、移入分组、历史面板;store 持久化语义不变。
2026-10-27 19:08:25 +08:00
12 changed files with 552 additions and 516 deletions

No files matched your search

@@ -29,9 +29,21 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { HttpPacketSyntax } from "@/components/http-code-block";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { LLMRecordItem, LLMRecordDetail, LLMTask } from "@/lib/types";
@@ -68,12 +80,7 @@ function tryFormatJSON(s: string): string {
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
const disabled = !text;
const copy = async () => {
@@ -88,11 +95,7 @@ function CopyButton({ text }: { text: string }) {
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
} catch {
/* 忽略:不支持则静默 */
}
try { document.execCommand("copy"); } catch { /* 忽略:不支持则静默 */ }
document.body.removeChild(ta);
}
setCopied(true);
@@ -148,7 +151,9 @@ export default function LLMRecordsPage() {
const reqText = showRaw
? detail?.raw_request && tryFormatJSON(detail.raw_request)
: detail?.request_body && tryFormatJSON(detail.request_body);
const respText = showRaw ? detail?.raw_response : detail?.response_body && tryFormatJSON(detail.response_body);
const respText = showRaw
? detail?.raw_response
: detail?.response_body && tryFormatJSON(detail.response_body);
// Per-task delete (task picker + confirm dialog)
const [tasks, setTasks] = React.useState<LLMTask[]>([]);
@@ -162,13 +167,9 @@ export default function LLMRecordsPage() {
let alive = true;
api
.settings()
.then((s) => {
if (alive) setRecEnabled(!!s.llm_record);
})
.then((s) => { if (alive) setRecEnabled(!!s.llm_record); })
.catch(() => {});
return () => {
alive = false;
};
return () => { alive = false; };
}, []);
const toggleRecording = async (on: boolean) => {
@@ -200,13 +201,7 @@ export default function LLMRecordsPage() {
let alive = true;
setLoading(true);
api
.llmRecords({
model: model || undefined,
session: sessionQ || undefined,
task: pickedTask || undefined,
page,
size,
})
.llmRecords({ model: model || undefined, session: sessionQ || undefined, task: pickedTask || undefined, page, size })
.then((r) => {
if (!alive) return;
setRecords(r.records ?? []);
@@ -216,13 +211,9 @@ export default function LLMRecordsPage() {
.finally(() => alive && setLoading(false));
api
.llmTasks()
.then((r) => {
if (alive) setTasks(r.tasks ?? []);
})
.then((r) => { if (alive) setTasks(r.tasks ?? []); })
.catch(() => {});
return () => {
alive = false;
};
return () => { alive = false; };
}, [page, size, sessionQ, model, pickedTask, reloadTick]);
// Delete every LLM record for the picked task, then refetch.
@@ -253,16 +244,10 @@ export default function LLMRecordsPage() {
setDetail(null);
api
.llmRecordDetail(selected.id)
.then((d) => {
if (alive) setDetail(d);
})
.then((d) => { if (alive) setDetail(d); })
.catch(() => {})
.finally(() => {
if (alive) setDetailLoading(false);
});
return () => {
alive = false;
};
.finally(() => { if (alive) setDetailLoading(false); });
return () => { alive = false; };
}, [selected]);
const totalPages = Math.max(1, Math.ceil(total / size));
@@ -291,7 +276,12 @@ export default function LLMRecordsPage() {
className="h-8 pl-8"
/>
</div>
<Input placeholder="Model" className="h-8 w-48" value={model} onChange={(e) => setModel(e.target.value)} />
<Input
placeholder="Model"
className="h-8 w-48"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<Select value={pickedTask} onValueChange={setPickedTask}>
<SelectTrigger size="sm" className="w-56">
<SelectValue placeholder="选择任务…" />
@@ -417,10 +407,15 @@ export default function LLMRecordsPage() {
records.map((rec) => (
<TableRow
key={rec.id}
className={cn("cursor-pointer", selected?.id === rec.id && "bg-accent hover:bg-accent")}
className={cn(
"cursor-pointer",
selected?.id === rec.id && "bg-accent hover:bg-accent",
)}
onClick={() => setSelected(rec)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(rec.ts)}</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(rec.ts)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
{rec.task_id ? `#${rec.task_id}` : "-"}
</TableCell>
@@ -447,13 +442,9 @@ export default function LLMRecordsPage() {
</TableCell>
<TableCell>
{rec.status === "ok" ? (
<Badge variant="secondary" className="text-xs text-emerald-600">
OK
</Badge>
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
) : (
<Badge variant="destructive" className="text-xs">
Error
</Badge>
<Badge variant="destructive" className="text-xs">Error</Badge>
)}
</TableCell>
</TableRow>
@@ -483,18 +474,16 @@ export default function LLMRecordsPage() {
任务 #{selected.task_id}
</Badge>
)}
<span className="text-xs text-muted-foreground">{fmtTime(selected.ts)}</span>
<span className="text-xs text-muted-foreground">
{fmtTime(selected.ts)}
</span>
<span className={cn("text-xs", selected.latency_ms > 30000 && "text-amber-500")}>
{fmtLatency(selected.latency_ms)}
</span>
{selected.status === "ok" ? (
<Badge variant="secondary" className="text-xs text-emerald-600">
OK
</Badge>
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
) : (
<Badge variant="destructive" className="text-xs">
Error
</Badge>
<Badge variant="destructive" className="text-xs">Error</Badge>
)}
{/* 原文视图开关。旧记录没有原文,此时禁用而非静默回退,避免看着像
「原文与解析一致」。 */}
@@ -508,7 +497,12 @@ export default function LLMRecordsPage() {
>
原文
</Button>
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => setSelected(null)}>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
</Button>
</div>
@@ -527,7 +521,7 @@ export default function LLMRecordsPage() {
</div>
) : (
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
<HttpPacketSyntax raw={reqText || ""} lineNumbers={false} emptyFallback="(空)" />
{reqText || "(空)"}
</pre>
)}
</div>
@@ -544,13 +538,11 @@ export default function LLMRecordsPage() {
加载…
</div>
) : (
<pre
className={cn(
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.status !== "ok" && "text-red-600 dark:text-red-400",
)}
>
<HttpPacketSyntax raw={respText || ""} lineNumbers={false} emptyFallback="(空)" />
<pre className={cn(
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.status !== "ok" && "text-red-600 dark:text-red-400",
)}>
{respText || "(空)"}
</pre>
)}
</div>
+28 -19
View File
@@ -43,6 +43,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { SortableHead } from "@/components/ui/sortable-head";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import { useStoredSortPreference } from "@/lib/sort-preference";
@@ -773,7 +774,7 @@ export default function TrafficPage() {
/>
) : null}
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[72rem]! sm:max-w-[72rem]!">
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
{selected && (
<>
<SheetHeader className="border-b px-5 py-4">
@@ -796,24 +797,32 @@ export default function TrafficPage() {
{importing ? "转入中…" : "送入请求重发"}
</Button>
</SheetHeader>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-2 overflow-hidden p-2 sm:grid-cols-2">
{[
["request", "请求 Request", requestWithHost(detail?.req ?? "", selected)],
["response", "响应 Response", detail?.resp ?? ""],
].map(([key, label, raw]) => (
<div key={key} className="flex min-h-0 min-w-0 flex-col gap-1 overflow-hidden">
<span className="px-1 text-[11px] font-medium text-muted-foreground">{label}</span>
{detailLoading ? (
<div className="flex min-h-0 flex-1 items-center justify-center gap-2 rounded-md border bg-muted/10 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<HttpCodeBlock raw={raw} />
)}
</div>
))}
</div>
<Tabs defaultValue="request" className="min-h-0 flex-1 gap-0">
<TabsList className="mx-5 mt-4 grid w-auto grid-cols-2">
<TabsTrigger value="request">请求 Request</TabsTrigger>
<TabsTrigger value="response">响应 Response</TabsTrigger>
</TabsList>
<TabsContent value="request" className="min-h-0 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<HttpCodeBlock raw={requestWithHost(detail?.req ?? "", selected)} />
)}
</TabsContent>
<TabsContent value="response" className="min-h-0 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<HttpCodeBlock raw={detail?.resp ?? ""} />
)}
</TabsContent>
</Tabs>
</>
)}
</SheetContent>
+14 -9
View File
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import { toneClasses } from "@/lib/status";
import type { CredentialAccount, CredentialTraffic, CredentialTrafficDetail, TaskIntelligence } from "@/lib/types";
@@ -343,22 +344,26 @@ export function CredentialTrafficViewer({
{data.reference.side === "request" ? "请求包含匹配凭据" : "响应包含匹配凭据"},不代表认证验证成功。
</p>
<p className="text-sm">绑定时保存的匹配片段</p>
<HttpCodeBlock raw={data.reference.excerpt} />
<pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3 text-xs">
{data.reference.excerpt}
</pre>
{data.unavailable ? (
<Alert>
<AlertDescription>原始流量已删除或不可用,仍可查看上方保存的匹配片段。</AlertDescription>
</Alert>
) : (
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
<div className="min-w-0">
<p className="mb-1 px-1 text-sm font-medium">请求 Request</p>
<Tabs defaultValue={data.reference.side}>
<TabsList>
<TabsTrigger value="request">请求</TabsTrigger>
<TabsTrigger value="response">响应</TabsTrigger>
</TabsList>
<TabsContent value="request">
<HttpCodeBlock raw={data.req ?? ""} />
</div>
<div className="min-w-0">
<p className="mb-1 px-1 text-sm font-medium">响应 Response</p>
</TabsContent>
<TabsContent value="response">
<HttpCodeBlock raw={data.resp ?? ""} />
</div>
</div>
</TabsContent>
</Tabs>
)}
</div>
) : (
+57 -163
View File
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { CheckIcon, CopyIcon, SearchIcon, WrapTextIcon } from "lucide-react";
import { CheckIcon, CopyIcon, WrapTextIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -16,55 +16,15 @@ function statusTone(status: number) {
return "text-muted-foreground";
}
const PACKET_COLORS = {
method: "font-semibold text-violet-600 dark:text-violet-400",
url: "text-sky-600 dark:text-sky-400",
dim: "text-muted-foreground",
headerName: "text-sky-700 dark:text-sky-300",
headerValue: "text-emerald-600 dark:text-emerald-400",
jsonKey: "text-sky-700 dark:text-sky-300",
jsonString: "text-amber-600 dark:text-amber-400",
jsonNumber: "text-fuchsia-600 dark:text-fuchsia-400",
jsonBoolean: "text-rose-600 dark:text-rose-400",
tag: "text-sky-600 dark:text-sky-400",
};
const PacketHighlightContext = React.createContext("");
function HighlightedText({ text, className }: { text: string; className?: string }) {
const query = React.useContext(PacketHighlightContext).trim();
if (!query || !text) return <span className={className}>{text}</span>;
const needle = query.toLowerCase();
const haystack = text.toLowerCase();
const parts: React.ReactNode[] = [];
let cursor = 0;
let key = 0;
for (;;) {
const match = haystack.indexOf(needle, cursor);
if (match < 0) {
parts.push(text.slice(cursor));
break;
}
if (match > cursor) parts.push(text.slice(cursor, match));
parts.push(
<mark key={key++} className="rounded-[2px] bg-yellow-300 text-black">
{text.slice(match, match + needle.length)}
</mark>,
);
cursor = match + needle.length;
}
return <span className={className}>{parts}</span>;
}
function StartLine({ line }: { line: string }) {
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
if (response) {
return (
<>
<HighlightedText text={response[1]} className={PACKET_COLORS.dim} />
<span className="text-muted-foreground">{response[1]}</span>
{response[2]}
<HighlightedText text={response[3]} className={statusTone(Number(response[3]))} />
<HighlightedText text={response[4]} />
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
{response[4]}
</>
);
}
@@ -73,26 +33,26 @@ function StartLine({ line }: { line: string }) {
if (request) {
return (
<>
<HighlightedText text={request[1]} className={PACKET_COLORS.method} />
<span className="font-semibold text-primary">{request[1]}</span>
{request[2]}
<HighlightedText text={request[3]} className={PACKET_COLORS.url} />
<span className="text-chart-2">{request[3]}</span>
{request[4]}
<HighlightedText text={request[5]} className={PACKET_COLORS.dim} />
<span className="text-muted-foreground">{request[5]}</span>
</>
);
}
return <HighlightedText text={line} />;
return line;
}
function HeaderLine({ line }: { line: string }) {
const separator = line.indexOf(":");
if (separator <= 0) return <HighlightedText text={line} />;
if (separator <= 0) return line;
return (
<>
<HighlightedText text={line.slice(0, separator)} className={PACKET_COLORS.headerName} />
<span className={PACKET_COLORS.dim}>:</span>
<HighlightedText text={line.slice(separator + 1)} className={PACKET_COLORS.headerValue} />
<span className="text-primary">{line.slice(0, separator)}</span>
<span className="text-muted-foreground">:</span>
<span className="text-chart-2">{line.slice(separator + 1)}</span>
</>
);
}
@@ -103,14 +63,18 @@ function JsonBody({ body }: { body: string }) {
let cursor = 0;
for (const match of body.matchAll(tokens)) {
const index = match.index ?? 0;
if (index > cursor) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor, index)} />);
let className = PACKET_COLORS.jsonNumber;
if (match[1]) className = match[2] ? PACKET_COLORS.jsonKey : PACKET_COLORS.jsonString;
else if (match[3]) className = PACKET_COLORS.jsonBoolean;
parts.push(<HighlightedText key={`${index}-${match[0].length}`} text={match[0]} className={className} />);
if (index > cursor) parts.push(body.slice(cursor, index));
let className = "text-chart-4";
if (match[1]) className = match[2] ? "text-primary" : "text-chart-2";
else if (match[3]) className = "text-chart-3";
parts.push(
<span key={`${index}-${match[0].length}`} className={className}>
{match[0]}
</span>,
);
cursor = index + match[0].length;
}
if (cursor < body.length) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor)} />);
if (cursor < body.length) parts.push(body.slice(cursor));
return parts;
}
@@ -120,11 +84,15 @@ function MarkupBody({ body }: { body: string }) {
let cursor = 0;
for (const match of body.matchAll(tags)) {
const index = match.index ?? 0;
if (index > cursor) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor, index)} />);
parts.push(<HighlightedText key={`${index}-${match[0].length}`} text={match[0]} className={PACKET_COLORS.tag} />);
if (index > cursor) parts.push(body.slice(cursor, index));
parts.push(
<span key={`${index}-${match[0].length}`} className="text-primary">
{match[0]}
</span>,
);
cursor = index + match[0].length;
}
if (cursor < body.length) parts.push(<HighlightedText key={`plain-${cursor}`} text={body.slice(cursor)} />);
if (cursor < body.length) parts.push(body.slice(cursor));
return parts;
}
@@ -140,82 +108,7 @@ function detectBodyFormat(body: string): BodyFormat {
function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) {
if (format === "json") return <JsonBody body={body} />;
if (format === "markup") return <MarkupBody body={body} />;
return <HighlightedText text={body} />;
}
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 lineItems = React.useMemo(() => {
const occurrences = new Map<string, number>();
return lines.map((line) => {
const occurrence = occurrences.get(line) ?? 0;
occurrences.set(line, occurrence + 1);
return { key: `${line}\u0000${occurrence}`, line };
});
}, [value]);
const separator = lines.indexOf("");
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 <HighlightedBody body={line} format={bodyFormat} />;
if (index === 0) return <StartLine line={line} />;
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
if (index === separator) return null;
return <HighlightedBody body={line} format={bodyFormat} />;
};
return (
<>
{lineItems.map((item, index) => (
<div
key={item.key}
data-line={index + 1}
className={cn("grid min-w-0", lineClassName, lineNumbers && "grid-cols-[2.25rem_minmax(0,1fr)]")}
>
{lineNumbers ? (
<span className="sticky left-0 self-stretch border-r bg-muted/20 px-1 text-right text-[11px] text-muted-foreground/50">
{index + 1}
</span>
) : null}
<code
className={cn(
"min-h-[1lh] min-w-0 [tab-size:4]",
lineNumbers ? "pr-14 pl-2" : "",
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
)}
>
{renderLine(item.line, index)}
</code>
</div>
))}
</>
);
}
function countMatches(value: string, query: string) {
const needle = query.trim().toLowerCase();
if (!needle) return 0;
const haystack = value.toLowerCase();
let count = 0;
let cursor = 0;
while (cursor <= haystack.length - needle.length) {
const index = haystack.indexOf(needle, cursor);
if (index < 0) return count;
count += 1;
cursor = index + needle.length;
}
return body;
}
// Empty grid rows have no text, so native selection copying drops their newlines.
@@ -255,10 +148,11 @@ function selectedPacketText(container: HTMLElement, value: string, lines: string
export function HttpCodeBlock({ raw }: { raw: string }) {
const [wrapLines, setWrapLines] = React.useState(true);
const [copied, setCopied] = React.useState(false);
const [query, setQuery] = React.useState("");
const value = raw || "(空)";
const lines = value.replaceAll("\r\n", "\n").split("\n");
const matches = React.useMemo(() => countMatches(value, query), [value, query]);
const separator = lines.indexOf("");
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
const bodyFormat = detectBodyFormat(body);
React.useEffect(() => {
if (!copied) return;
@@ -275,8 +169,15 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制");
};
const renderLine = (line: string, index: number) => {
if (index === 0) return <StartLine line={line} />;
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
if (index === separator) return null;
return <HighlightedBody body={line} format={bodyFormat} />;
};
return (
<div className="relative mx-3 my-4 flex min-h-0 flex-col overflow-hidden rounded-md border bg-background shadow-xs">
<div className="relative mx-3 my-4 overflow-hidden rounded-md border bg-background shadow-xs">
<div className="absolute top-2 right-2 flex items-center gap-0.5 rounded-md border bg-background/95 p-0.5 shadow-xs">
<Tooltip>
<TooltipTrigger asChild>
@@ -315,7 +216,7 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
aria-multiline="true"
aria-readonly="true"
tabIndex={0}
className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-auto bg-background py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto bg-background py-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onCopy={(event) => {
const text = selectedPacketText(event.currentTarget, value, lines);
if (text === null) return;
@@ -333,29 +234,22 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
selection.addRange(range);
}}
>
<PacketHighlightContext.Provider value={query}>
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
</PacketHighlightContext.Provider>
</div>
<div className="flex items-center gap-2 border-t bg-muted/20 px-2 py-1">
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索报文内容"
aria-label="搜索报文内容"
className="min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/60"
/>
{query.trim() ? (
<span
className={cn(
"shrink-0 text-xs tabular-nums",
matches ? "font-medium text-foreground" : "text-muted-foreground",
)}
{lines.map((line, index) => (
<div
key={`${index}-${line}`}
data-line={index + 1}
className="grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] leading-relaxed before:sticky before:left-0 before:self-stretch before:border-r before:bg-muted/20 before:px-1 before:text-right before:text-muted-foreground/60 before:content-[attr(data-line)]"
>
{matches} 处命中
</span>
) : null}
<code
className={cn(
"min-h-[1lh] min-w-0 pr-16 pl-1.5 [tab-size:4]",
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
)}
>
{renderLine(line, index)}
</code>
</div>
))}
</div>
</div>
);
@@ -154,7 +154,6 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
onChange={changeRequest}
disabled={busy}
placeholder="POST /path HTTP/1.1"
syntaxHighlight
/>
<div className="flex min-h-[300px] min-w-0 flex-col gap-2 xl:min-h-0">
{response?.error ? (
@@ -167,7 +166,6 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
label="响应"
value={response ? buildRawResponse(response) : ""}
placeholder="发送后显示原始响应"
syntaxHighlight
/>
</div>
</div>
@@ -1,9 +1,17 @@
import { buildRawResponse } from "./repeater-http";
import type { RepeaterTab } from "./repeater-state";
import type { RepeaterGroup, 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 filterRepeaterTabs(tabs: RepeaterTab[], filters: RepeaterFilters, groupId = "all"): RepeaterTab[] {
return tabs.filter((tab) => (groupId === "all" || tab.groupId === groupId) && matchesRepeaterFilters(tab, filters));
}
export function groupRepeaterTabs(tabs: RepeaterTab[], groups: RepeaterGroup[]) {
return groups.map((group) => ({ group, tabs: tabs.filter((tab) => tab.groupId === group.id) }));
}
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;
+286 -201
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { ChevronDown, ChevronRight, ListFilter, Pencil, Plus, X } from "lucide-react";
import { ChevronDown, ChevronRight, History, ListFilter, Pencil, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import {
@@ -22,14 +22,14 @@ 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 { DEFAULT_FILTERS, filterRepeaterTabs, groupRepeaterTabs } from "./repeater-filter";
import { RepeaterHistory } from "./repeater-history";
import type { RepeaterWorkspace } from "./repeater-state";
import { DEFAULT_GROUP_ID, type RepeaterWorkspace } from "./repeater-state";
import { useRepeaterStore } from "./repeater-store";
type Rename = { kind: "tab" | "group"; id: string; name: string };
export function RepeaterNavigator({
export function RepeaterTopBar({
workspace,
onRename,
}: {
@@ -39,18 +39,21 @@ export function RepeaterNavigator({
const store = useRepeaterStore();
const [filters, setFilters] = React.useState(DEFAULT_FILTERS);
const [expanded, setExpanded] = React.useState(false);
const [historyOpen, setHistoryOpen] = React.useState(false);
const [groupFilter, setGroupFilter] = React.useState("all");
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],
() => filterRepeaterTabs(workspace.tabs, filters, groupFilter),
[workspace.tabs, filters, groupFilter],
);
const grouped = React.useMemo(() => groupRepeaterTabs(visible, workspace.groups), [visible, workspace.groups]);
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 filtering =
groupFilter !== "all" ||
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 = [
@@ -65,6 +68,17 @@ export function RepeaterNavigator({
...workspace.tabs.map((tab) => tab.method.toUpperCase()),
]),
];
React.useEffect(() => {
if (groupFilter !== "all" && !workspace.groups.some((group) => group.id === groupFilter)) {
setGroupFilter("all");
}
}, [groupFilter, workspace.groups]);
function updateFilters(patch: Partial<typeof filters>) {
setFilters((current) => ({ ...current, ...patch }));
}
function toggle(id: string, checked: boolean) {
setSelected((current) => {
const next = new Set(current);
@@ -77,128 +91,169 @@ export function RepeaterNavigator({
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"
className="flex min-w-0 shrink-0 flex-col overflow-hidden rounded-lg border bg-card"
>
<div className="flex shrink-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 p-3 pb-2">
<Input
aria-label="全局搜索重发请求"
placeholder="搜索全部标题、请求或响应…"
value={filters.query}
onChange={(event) => setFilters({ ...filters, query: event.target.value })}
onChange={(event) => updateFilters({ query: event.target.value })}
className="min-w-48 flex-1"
/>
<div className="flex gap-2">
<Select value={filters.method} onValueChange={(method) => setFilters({ ...filters, method })}>
<SelectTrigger aria-label="筛选请求方法" className="min-w-0 flex-1">
<Select value={filters.method} onValueChange={(method) => updateFilters({ method })}>
<SelectTrigger aria-label="筛选请求方法" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部方法</SelectItem>
{methods.map((method) => (
<SelectItem key={method} value={method}>
{method}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select value={groupFilter} onValueChange={setGroupFilter}>
<SelectTrigger aria-label="筛选分组" className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部分组</SelectItem>
{workspace.groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
variant={expanded ? "secondary" : "outline"}
size="sm"
aria-expanded={expanded}
aria-controls="repeater-extra-filters"
onClick={() => setExpanded((value) => !value)}
>
<ListFilter />
筛选
</Button>
<Button variant="outline" size="sm" onClick={() => store.createGroup()}>
<Plus data-icon="inline-start" />
新建分组
</Button>
</div>
{expanded ? (
<div id="repeater-extra-filters" className="flex flex-wrap items-end gap-2 border-t px-3 py-2">
<Field className="min-w-44 flex-1 gap-1">
<FieldLabel htmlFor="repeater-filter-path" className="text-xs">
请求路径
</FieldLabel>
<Input
id="repeater-filter-path"
placeholder="例如 /api/upload"
value={filters.path}
onChange={(event) => updateFilters({ path: event.target.value })}
/>
</Field>
<Field className="min-w-44 flex-1 gap-1">
<FieldLabel htmlFor="repeater-filter-response" className="text-xs">
响应内容
</FieldLabel>
<Input
id="repeater-filter-response"
placeholder="响应头或正文关键字"
value={filters.response}
onChange={(event) => updateFilters({ response: event.target.value })}
/>
</Field>
<Select value={filters.status} onValueChange={(status) => updateFilters({ status })}>
<SelectTrigger aria-label="筛选响应状态" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部方法</SelectItem>
{methods.map((method) => (
<SelectItem key={method} value={method}>
{method}
<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 flex-wrap items-center gap-2 border-t px-3 py-2 text-muted-foreground text-xs">
<span>
{visible.length} / {workspace.tabs.length} 个请求
</span>
{filtering ? (
<Button
variant={expanded ? "secondary" : "outline"}
variant="ghost"
size="sm"
aria-expanded={expanded}
aria-controls="repeater-extra-filters"
onClick={() => setExpanded(!expanded)}
className="h-6 px-1 text-xs"
onClick={() => {
setFilters(DEFAULT_FILTERS);
setGroupFilter("all");
}}
>
<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>
<span className="ml-auto">当前:{active?.title ?? "无标签"}</span>
<Button
variant={historyOpen ? "secondary" : "ghost"}
size="sm"
className="h-7"
aria-expanded={historyOpen}
onClick={() => setHistoryOpen((value) => !value)}
>
<History data-icon="inline-start" />
历史{active?.history.length ? ` (${active.history.length})` : ""}
</Button>
<Button
size="sm"
className="h-7"
onClick={() => store.createTab({}, { groupId: groupFilter === "all" ? undefined : groupFilter })}
>
<Plus data-icon="inline-start" />
新建标签
</Button>
</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>
<div className="flex flex-wrap items-center gap-2 border-t px-3 py-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="text-muted-foreground text-xs">
已选 {selectedIds.length} 条{hiddenSelected ? `(隐藏 ${hiddenSelected})` : ""}
</span>
{selectedIds.length ? (
<div className="flex gap-2">
<div className="flex min-w-0 basis-full items-center gap-2">
<Select
value=""
onValueChange={(groupId) => {
@@ -207,7 +262,7 @@ export function RepeaterNavigator({
setSelected(new Set());
}}
>
<SelectTrigger aria-label="批量移动到分组" className="min-w-0 flex-1">
<SelectTrigger aria-label="批量移动到分组" className="min-w-0 flex-1 w-40">
<SelectValue placeholder="移动到分组…" />
</SelectTrigger>
<SelectContent>
@@ -223,102 +278,132 @@ export function RepeaterNavigator({
<Button variant="destructive" size="sm" onClick={() => setDeleting(true)}>
删除所选
</Button>
<Button variant="ghost" size="icon-sm" aria-label="清空勾选" onClick={() => setSelected(new Set())}>
<X />
</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>
<section aria-label="重发请求标签栏" className="min-w-0 overflow-x-auto border-t px-3 py-2">
<div className="flex min-w-max items-start gap-3">
{grouped.map(({ group, tabs }) => {
if (filtering && !tabs.length) return null;
const collapsed = group.collapsed && !filtering;
return (
<div
key={group.id}
className="flex min-w-64 max-w-[min(42rem,90vw)] shrink-0 flex-col gap-1 rounded-md bg-muted/40 p-1"
>
<div className="flex items-center gap-1 px-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}`}
disabled={group.id === DEFAULT_GROUP_ID}
onClick={() => {
store.deleteGroup(group.id);
if (groupFilter === group.id) setGroupFilter("all");
}}
>
<Trash2 />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`新建标签 · ${group.name}`}
title="新建标签"
onClick={() => store.createTab({}, { groupId: group.id })}
>
<Plus />
</Button>
</div>
{collapsed ? (
<span className="px-2 pb-1 text-muted-foreground text-xs">已折叠</span>
) : (
<div className="flex min-h-10 items-center gap-1 overflow-x-auto px-1 pb-1">
{tabs.map((tab) => (
<div
key={tab.id}
className={cn(
"flex min-w-40 max-w-64 shrink-0 items-center gap-1 rounded-md border bg-background px-1",
active?.id === tab.id && "border-primary 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 gap-1 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="font-mono text-[10px] text-muted-foreground">{tab.method}</span>
<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>
))}
{!tabs.length ? <span className="px-2 text-muted-foreground text-xs">暂无标签</span> : null}
</div>
)}
</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}
);
})}
{!visible.length ? (
<p className="py-3 text-muted-foreground text-xs">没有匹配的请求,清除筛选后可查看全部。</p>
) : null}
</div>
</section>
<div className="flex max-h-[35%] min-h-20 flex-1 flex-col">
<RepeaterHistory tab={active} />
</div>
{historyOpen ? (
<div className="max-h-44 overflow-y-auto border-t px-3 py-2">
<RepeaterHistory tab={active} />
</div>
) : null}
<AlertDialog open={deleting} onOpenChange={setDeleting}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -33,7 +33,12 @@ const {
parseHeadersText,
setHostHeader,
} = require("./repeater-http.ts");
const { DEFAULT_FILTERS, matchesRepeaterFilters } = require("./repeater-filter.ts");
const {
DEFAULT_FILTERS,
filterRepeaterTabs,
groupRepeaterTabs,
matchesRepeaterFilters,
} = require("./repeater-filter.ts");
const { flush, useRepeaterStore } = require("./repeater-store.ts");
const { toast } = require("sonner");
@@ -473,6 +478,52 @@ test("global replay filters combine title, request, response, method, path and s
);
});
test("top tab helpers filter by group and preserve workspace group order", () => {
let workspace = defaultWorkspace();
workspace.groups.push({ id: "other", name: "验证" });
const first = workspace.tabs[0];
first.title = "默认请求";
workspace = addTab(workspace, { method: "POST", url: "https://example.test/verify" }, { groupId: "other" });
const visible = filterRepeaterTabs(workspace.tabs, { ...DEFAULT_FILTERS, query: "请求" });
assert.deepEqual(
visible.map((tab) => tab.title),
["默认请求"],
);
assert.deepEqual(
filterRepeaterTabs(workspace.tabs, DEFAULT_FILTERS, "other").map((tab) => tab.groupId),
["other"],
);
const grouped = groupRepeaterTabs(workspace.tabs, workspace.groups);
assert.deepEqual(
grouped.map(({ group, tabs }) => [group.id, tabs.length]),
[
["default", 1],
["other", 1],
],
);
});
test("deleting a group keeps its tabs in the default group", (t) => {
const { store } = setupStore(t);
store.createGroup("临时组");
const groupId = useRepeaterStore.getState().workspace.groups[1].id;
const tabId = store.createTab({ title: "临时请求" }, { groupId });
store.toggleGroupCollapsed("default");
store.deleteGroup(groupId);
const workspace = useRepeaterStore.getState().workspace;
assert.equal(
workspace.groups.some((group) => group.id === groupId),
false,
);
assert.equal(workspace.groups.find((group) => group.id === "default").collapsed, false);
assert.equal(workspace.tabs.find((tab) => tab.id === tabId).groupId, "default");
store.deleteGroup("default");
assert.equal(
useRepeaterStore.getState().workspace.groups.some((group) => group.id === "default"),
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;
@@ -47,6 +47,7 @@ type Store = {
recordSend: (id: string, entry: RepeaterHistoryEntry) => void;
createGroup: (name?: string) => void;
renameGroup: (id: string, name: string) => void;
deleteGroup: (id: string) => void;
setTabGroup: (tabId: string, groupId: string) => void;
toggleGroupCollapsed: (id: string) => void;
clearWorkspace: () => void;
@@ -187,6 +188,23 @@ export const useRepeaterStore = create<Store>((set, get) => {
groups: workspace.groups.map((group) => (group.id === id ? { ...group, name: name.trim() } : group)),
});
},
deleteGroup: (id) => {
if (id === DEFAULT_GROUP_ID) return;
const workspace = current();
if (!workspace.groups.some((group) => group.id === id)) return;
const now = new Date().toISOString();
const movesTabs = workspace.tabs.some((tab) => tab.groupId === id);
change({
...workspace,
groups: workspace.groups
.filter((group) => group.id !== id)
.map((group) => (movesTabs && group.id === DEFAULT_GROUP_ID ? { ...group, collapsed: false } : group)),
tabs: workspace.tabs.map((tab) =>
tab.groupId === id ? { ...tab, groupId: DEFAULT_GROUP_ID, updatedAt: now } : tab,
),
});
flush();
},
setTabGroup: (tabId, groupId) => get().setTabsGroup([tabId], groupId),
setTabsGroup: (ids, groupId) => {
const workspace = current();
@@ -3,7 +3,6 @@
import * as React from "react";
import { CopyButton } from "@/components/copy-button";
import { HttpPacketSyntax } from "@/components/http-code-block";
import { Textarea } from "@/components/ui/textarea";
export function RepeaterTextPane({
@@ -13,7 +12,6 @@ export function RepeaterTextPane({
onChange,
disabled,
placeholder,
syntaxHighlight = false,
}: {
id: string;
label: string;
@@ -21,10 +19,8 @@ export function RepeaterTextPane({
onChange?: (value: string) => void;
disabled?: boolean;
placeholder?: string;
syntaxHighlight?: boolean;
}) {
const gutter = React.useRef<HTMLPreElement>(null);
const syntax = React.useRef<HTMLDivElement>(null);
const lines = React.useMemo(() => value.split(/\r\n|\r|\n/).length, [value]);
const numbers = React.useMemo(() => Array.from({ length: lines }, (_, index) => index + 1).join("\n"), [lines]);
return (
@@ -36,7 +32,7 @@ export function RepeaterTextPane({
<span className="text-muted-foreground text-xs tabular-nums">{lines} 行</span>
<CopyButton text={value} label={`复制${label}`} className="ml-auto" />
</div>
<div className="relative flex min-h-0 flex-1 overflow-hidden rounded-lg border border-input focus-within:border-ring">
<div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-input focus-within:border-ring">
<div
aria-hidden="true"
className="w-12 shrink-0 overflow-hidden border-r bg-muted/30 text-right text-muted-foreground"
@@ -51,10 +47,6 @@ export function RepeaterTextPane({
onChange={(event) => onChange?.(event.target.value)}
onScroll={(event) => {
if (gutter.current) gutter.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`;
if (syntax.current) {
syntax.current.scrollTop = event.currentTarget.scrollTop;
syntax.current.scrollLeft = event.currentTarget.scrollLeft;
}
}}
disabled={disabled}
readOnly={!onChange}
@@ -63,28 +55,8 @@ export function RepeaterTextPane({
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className={
"field-sizing-fixed relative z-10 h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs " +
(syntaxHighlight
? "bg-transparent text-transparent caret-foreground placeholder:text-muted-foreground selection:bg-primary/20 selection:text-transparent "
: "")
}
className="field-sizing-fixed h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs"
/>
{syntaxHighlight ? (
<div
ref={syntax}
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 left-12 overflow-hidden px-3 py-2 font-mono text-xs leading-5"
>
<HttpPacketSyntax
raw={value}
lineNumbers={false}
wrapLines={false}
emptyFallback=""
lineClassName="leading-5"
/>
</div>
) : null}
</div>
</div>
);
@@ -30,7 +30,7 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
import { Skeleton } from "@/components/ui/skeleton";
import { RepeaterEditor } from "./repeater-editor";
import { RepeaterNavigator } from "./repeater-navigator";
import { RepeaterTopBar } from "./repeater-navigator";
import { useRepeaterStore } from "./repeater-store";
export function RepeaterWorkspace() {
@@ -84,8 +84,8 @@ export function RepeaterWorkspace() {
</AlertDialog>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
<RepeaterNavigator workspace={workspace} onRename={setRename} />
<div className="flex min-h-0 flex-1 flex-col gap-3">
<RepeaterTopBar workspace={workspace} onRename={setRename} />
<main className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
{active ? (
<>
+23 -19
View File
@@ -13,6 +13,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { FindingTrafficDetail, TrafficDetail } from "@/lib/types";
@@ -113,16 +114,17 @@ export function TrafficEvidenceViewer({
<AlertDescription>{error}</AlertDescription>
</Alert>
) : detail ? (
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
<Tabs defaultValue="request">
<TabsList>
<TabsTrigger value="request">请求 Request</TabsTrigger>
<TabsTrigger value="response">响应 Response</TabsTrigger>
</TabsList>
{(["request", "response"] as const).map((side) => (
<div key={side} className="flex min-w-0 flex-col gap-2">
<TabsContent key={side} value={side}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">{side === "request" ? "请求 Request" : "响应 Response"}</p>
<span className="text-muted-foreground text-xs">
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
</span>
</div>
<span className="text-muted-foreground text-xs">
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
</span>
<Button
variant="outline"
size="sm"
@@ -132,7 +134,7 @@ export function TrafficEvidenceViewer({
.catch((e: Error) => toast.error(e.message))
}
>
下载完整正文
下载完整{side === "request" ? "请求" : "响应"}正文
</Button>
</div>
<HttpCodeBlock
@@ -143,9 +145,9 @@ export function TrafficEvidenceViewer({
加载更多正文
</Button>
) : null}
</div>
</TabsContent>
))}
</div>
</Tabs>
) : (
<Skeleton className="h-56 w-full" />
)}
@@ -191,16 +193,18 @@ export function CapturedTrafficViewer({ id, onClose }: { id: string | null; onCl
<AlertDescription>{error}</AlertDescription>
</Alert>
) : detail ? (
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
<div className="min-w-0">
<p className="mb-1 px-1 text-sm font-medium">请求 Request</p>
<Tabs defaultValue="request">
<TabsList>
<TabsTrigger value="request">请求 Request</TabsTrigger>
<TabsTrigger value="response">响应 Response</TabsTrigger>
</TabsList>
<TabsContent value="request">
<HttpCodeBlock raw={detail.req} />
</div>
<div className="min-w-0">
<p className="mb-1 px-1 text-sm font-medium">响应 Response</p>
</TabsContent>
<TabsContent value="response">
<HttpCodeBlock raw={detail.resp} />
</div>
</div>
</TabsContent>
</Tabs>
) : (
<Skeleton className="h-56 w-full" />
)}