mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-06 12:13:49 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ebc6ec5b7 | ||
|
|
aff5f2f699 |
No files matched your search
@@ -9,7 +9,6 @@ import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { FindingAgentChatDrawer } from "@/components/finding-agent-chat-drawer";
|
||||
import { FindingRetestPanel } from "@/components/finding-retest-panel";
|
||||
import { FindingTrafficPanel } from "@/components/finding-traffic-panel";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
@@ -161,7 +160,6 @@ function FindingDetailInner() {
|
||||
{finding.inherited && finding.source_task_id && (
|
||||
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
|
||||
)}
|
||||
<FindingAgentChatDrawer finding={finding} />
|
||||
</div>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowUpIcon, Loader2Icon, MessageCircleQuestionIcon, SquareIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api, openSSE } from "@/lib/api";
|
||||
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type { Activity, Finding } from "@/lib/types";
|
||||
|
||||
import {
|
||||
buildFindingAgentContextMessage,
|
||||
findingAgentUnavailableReason,
|
||||
findingDisplayTitle,
|
||||
} from "./finding-agent-chat";
|
||||
|
||||
const MAIN_HISTORY_LIMIT = 200;
|
||||
|
||||
type FindingAgentChatDrawerProps = {
|
||||
finding: Finding;
|
||||
};
|
||||
|
||||
function mainActivity(activity: Activity, segment: number): boolean {
|
||||
if (activity.worker !== "mainagent") return false;
|
||||
return activity.main_seg == null || activity.main_seg === segment;
|
||||
}
|
||||
|
||||
function mergeActivities(current: Activity[], incoming: Activity[], segment: number): Activity[] {
|
||||
const bySeq = new Map<number, Activity>();
|
||||
for (const item of current) bySeq.set(item.seq, item);
|
||||
for (const item of incoming) {
|
||||
if (mainActivity(item, segment)) bySeq.set(item.seq, item);
|
||||
}
|
||||
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
|
||||
function LoadingActivity() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载主 Agent 对话…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyActivity({ available }: { available: boolean }) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{available ? "还没有对话。发送问题后,主 Agent 的回复会显示在这里。" : "暂无可显示的活动。"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingAgentChatDrawer({ finding }: FindingAgentChatDrawerProps) {
|
||||
const taskId = finding.task_id ?? "";
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [activities, setActivities] = React.useState<Activity[]>([]);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [running, setRunning] = React.useState(false);
|
||||
const [stopping, setStopping] = React.useState(false);
|
||||
const sendMode = useChatSendMode();
|
||||
const [taskAvailable, setTaskAvailable] = React.useState<boolean | null>(null);
|
||||
const [unavailableReason, setUnavailableReason] = React.useState("");
|
||||
const eventSourceRef = React.useRef<EventSource | null>(null);
|
||||
const snapshotRef = React.useRef(0);
|
||||
const aliveRef = React.useRef(false);
|
||||
const requestGenerationRef = React.useRef(0);
|
||||
const sentContextRef = React.useRef(false);
|
||||
const currentSegmentRef = React.useRef(0);
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const atBottomRef = React.useRef(true);
|
||||
const findingIdRef = React.useRef(finding.id);
|
||||
findingIdRef.current = finding.id;
|
||||
|
||||
const findingId = finding.id;
|
||||
const inherited = Boolean(finding.inherited);
|
||||
const canAsk = Boolean(taskId && !inherited);
|
||||
|
||||
const closeStream = React.useCallback(() => {
|
||||
eventSourceRef.current?.close();
|
||||
eventSourceRef.current = null;
|
||||
}, []);
|
||||
|
||||
const refreshStatus = React.useCallback(
|
||||
(generation = requestGenerationRef.current) => {
|
||||
if (!taskId || !canAsk) return Promise.resolve();
|
||||
return api
|
||||
.chatStatus(taskId)
|
||||
.then(({ running: nextRunning }) => {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation) setRunning(nextRunning);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
[canAsk, taskId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const generation = ++requestGenerationRef.current;
|
||||
const isCurrent = () =>
|
||||
aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId;
|
||||
|
||||
closeStream();
|
||||
aliveRef.current = open;
|
||||
if (!open) return;
|
||||
|
||||
sentContextRef.current = false;
|
||||
setInput("");
|
||||
setActivities([]);
|
||||
setTaskAvailable(null);
|
||||
setUnavailableReason(findingAgentUnavailableReason({ task_id: taskId, inherited }));
|
||||
setRunning(false);
|
||||
setLoading(false);
|
||||
setSending(false);
|
||||
setStopping(false);
|
||||
snapshotRef.current = 0;
|
||||
currentSegmentRef.current = 0;
|
||||
atBottomRef.current = true;
|
||||
|
||||
if (!canAsk) {
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
requestGenerationRef.current += 1;
|
||||
closeStream();
|
||||
};
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
if (MOCK) {
|
||||
api
|
||||
.activity(taskId)
|
||||
.then(({ items, cursor }) => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(true);
|
||||
setActivities(mergeActivities([], items, currentSegmentRef.current));
|
||||
snapshotRef.current = cursor;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(false);
|
||||
setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。");
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) setLoading(false);
|
||||
});
|
||||
} else {
|
||||
api
|
||||
.task(taskId)
|
||||
.then(() => api.mainSessions(taskId))
|
||||
.then((sessions) => {
|
||||
if (!isCurrent()) return null;
|
||||
const currentSegment = sessions.current ?? 0;
|
||||
currentSegmentRef.current = currentSegment;
|
||||
return api.activityHistory(taskId, `main:${currentSegment}`, 0, MAIN_HISTORY_LIMIT).then((history) => ({
|
||||
currentSegment,
|
||||
history,
|
||||
}));
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result || !isCurrent()) return;
|
||||
const { currentSegment, history } = result;
|
||||
setTaskAvailable(true);
|
||||
setActivities((current) => mergeActivities(current, history.items, currentSegment));
|
||||
snapshotRef.current = history.snapshotCursor;
|
||||
|
||||
closeStream();
|
||||
const stream = openSSE(
|
||||
`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`,
|
||||
);
|
||||
if (!isCurrent()) {
|
||||
stream.close();
|
||||
return;
|
||||
}
|
||||
eventSourceRef.current = stream;
|
||||
stream.onmessage = (event) => {
|
||||
if (!isCurrent()) return;
|
||||
try {
|
||||
const activity = JSON.parse(event.data) as Activity;
|
||||
setActivities((current) => mergeActivities(current, [activity], currentSegment));
|
||||
} catch {
|
||||
// Ignore malformed frames; the next history refresh still repairs the view.
|
||||
}
|
||||
};
|
||||
stream.onerror = () => undefined; // EventSource reconnects automatically.
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(false);
|
||||
setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。");
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
void refreshStatus(generation);
|
||||
const statusTimer = window.setInterval(() => void refreshStatus(generation), 2000);
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
requestGenerationRef.current += 1;
|
||||
window.clearInterval(statusTimer);
|
||||
closeStream();
|
||||
};
|
||||
}, [canAsk, closeStream, findingId, inherited, open, refreshStatus, taskId]);
|
||||
|
||||
const viewport = React.useCallback(
|
||||
() => (contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ?? null,
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const vp = viewport();
|
||||
if (!vp) return;
|
||||
const onScroll = () => {
|
||||
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
|
||||
};
|
||||
vp.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => vp.removeEventListener("scroll", onScroll);
|
||||
}, [open, viewport]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const vp = viewport();
|
||||
if (vp) {
|
||||
vp.scrollTop = vp.scrollHeight;
|
||||
atBottomRef.current = true;
|
||||
}
|
||||
}, [open, viewport]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activity growth intentionally drives live-edge scrolling.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
}, [activities, open, running, viewport]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || typeof ResizeObserver === "undefined") return;
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [open, viewport]);
|
||||
|
||||
const send = React.useCallback(async () => {
|
||||
const message = input.trim();
|
||||
if (!message || sending || running || !taskId || taskAvailable !== true || !canAsk) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setSending(true);
|
||||
const payload = sentContextRef.current
|
||||
? message
|
||||
: `${buildFindingAgentContextMessage(finding)}\n\n用户问题:${message}`;
|
||||
try {
|
||||
const result = await api.chat(payload, taskId);
|
||||
if (!aliveRef.current || requestGenerationRef.current !== generation || findingIdRef.current !== findingId)
|
||||
return;
|
||||
if (MOCK) {
|
||||
const ts = new Date().toISOString();
|
||||
const reply = result.reply?.trim() || "(demo)主 Agent 暂无回复。";
|
||||
setActivities((current) =>
|
||||
mergeActivities(
|
||||
current,
|
||||
[
|
||||
{
|
||||
seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 1,
|
||||
worker: "mainagent",
|
||||
ts,
|
||||
kind: "user",
|
||||
summary: message,
|
||||
main_seg: currentSegmentRef.current,
|
||||
},
|
||||
{
|
||||
seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 2,
|
||||
worker: "mainagent",
|
||||
ts,
|
||||
kind: "text",
|
||||
summary: reply,
|
||||
main_seg: currentSegmentRef.current,
|
||||
},
|
||||
],
|
||||
currentSegmentRef.current,
|
||||
),
|
||||
);
|
||||
}
|
||||
sentContextRef.current = true;
|
||||
setInput("");
|
||||
if (!MOCK) void refreshStatus(generation);
|
||||
} catch (error) {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`);
|
||||
}
|
||||
} finally {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
}, [canAsk, finding, findingId, input, refreshStatus, running, sending, taskAvailable, taskId]);
|
||||
|
||||
const stop = React.useCallback(async () => {
|
||||
if (!taskId || stopping) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setStopping(true);
|
||||
try {
|
||||
await api.stopChat(taskId);
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
await refreshStatus(generation);
|
||||
}
|
||||
} catch (error) {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
toast.error(`停止失败:${(error as Error).message || "请稍后重试"}`);
|
||||
}
|
||||
} finally {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
setStopping(false);
|
||||
}
|
||||
}
|
||||
}, [findingId, refreshStatus, stopping, taskId]);
|
||||
|
||||
const title = findingDisplayTitle(finding);
|
||||
const canSend = canAsk && taskAvailable === true && !running && !sending && Boolean(input.trim());
|
||||
let footerStatus = "Enter 发送,Shift+Enter 换行。";
|
||||
if (sending) footerStatus = "正在发送…";
|
||||
else if (running) footerStatus = "主 Agent 运行中,可停止当前执行。";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)} aria-label="问 Agent">
|
||||
<MessageCircleQuestionIcon />问 Agent
|
||||
</Button>
|
||||
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b pr-12">
|
||||
<SheetTitle className="truncate">问 Agent · {title}</SheetTitle>
|
||||
<SheetDescription className="flex min-w-0 items-center gap-1">
|
||||
所属任务:
|
||||
{taskId ? (
|
||||
<Link className="truncate text-primary hover:underline" href={`/function/tasks/detail?id=${taskId}`}>
|
||||
{finding.task_description ?? `#${taskId}`}
|
||||
</Link>
|
||||
) : (
|
||||
<span>无</span>
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{unavailableReason ? (
|
||||
<Alert variant="destructive" className="m-4 mb-0">
|
||||
<AlertDescription>{unavailableReason}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div ref={contentRef} className="min-h-full p-4">
|
||||
{loading ? <LoadingActivity /> : null}
|
||||
{!loading && activities.length ? (
|
||||
<Transcript
|
||||
activity={activities}
|
||||
live={running}
|
||||
taskId={taskId}
|
||||
chat
|
||||
fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail)}
|
||||
/>
|
||||
) : null}
|
||||
{!loading && !activities.length ? <EmptyActivity available={taskAvailable === true} /> : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="border-t bg-background p-3">
|
||||
<div className="flex w-full items-end gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (!shouldSubmitOnKey(event, sendMode)) return;
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
disabled={!canAsk || taskAvailable !== true || sending || running}
|
||||
placeholder={running ? "主 Agent 正在运行…" : "描述复现现象或继续提问…"}
|
||||
aria-label="向漏洞所属任务的主 Agent 提问"
|
||||
className="max-h-32 min-h-10 resize-none"
|
||||
/>
|
||||
{running ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => void stop()}
|
||||
disabled={stopping}
|
||||
title="停止当前执行"
|
||||
aria-label="停止当前执行"
|
||||
>
|
||||
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => void send()}
|
||||
disabled={!canSend}
|
||||
title="发送消息"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="w-full text-[11px] text-muted-foreground">{footerStatus}</p>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { mentionToken } from "../lib/chat-mentions";
|
||||
import type { Finding } from "../lib/types";
|
||||
|
||||
type FindingContextFields = Finding & {
|
||||
url?: string;
|
||||
endpoint?: string;
|
||||
entry_point?: string;
|
||||
};
|
||||
|
||||
export function canAskFindingAgent(finding: Pick<Finding, "task_id" | "inherited"> | null | undefined): boolean {
|
||||
return Boolean(finding?.task_id && !finding.inherited);
|
||||
}
|
||||
|
||||
export function findingDisplayTitle(finding: Pick<Finding, "id" | "name" | "vulnclass">): string {
|
||||
if (finding.name?.trim()) return finding.name;
|
||||
if (finding.vulnclass?.trim()) return finding.vulnclass;
|
||||
return `漏洞 #${finding.id}`;
|
||||
}
|
||||
|
||||
export function findingAgentUnavailableReason(
|
||||
finding: Pick<Finding, "task_id" | "inherited"> | null | undefined,
|
||||
): string {
|
||||
if (!finding?.task_id) return "无所属任务,无法连接到主 Agent。";
|
||||
if (finding.inherited) return "继承漏洞只读,请到源任务提问。";
|
||||
return "";
|
||||
}
|
||||
|
||||
function truncate(value: string, max: number): string {
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
return compact.length > max ? `${compact.slice(0, max)}…` : compact;
|
||||
}
|
||||
|
||||
/**
|
||||
* The finding mention is included once, in the first turn, so later user turns
|
||||
* stay short while the task's main Agent still has an unambiguous finding anchor.
|
||||
*/
|
||||
export function buildFindingAgentContextMessage(finding: Finding): string {
|
||||
const extended = finding as FindingContextFields;
|
||||
const numericId = Number(finding.id);
|
||||
const mention = mentionToken({
|
||||
kind: "finding",
|
||||
id: Number.isFinite(numericId) ? numericId : 0,
|
||||
label: truncate(findingDisplayTitle(finding), 100),
|
||||
description: truncate(finding.summary || "", 200),
|
||||
});
|
||||
const lines = [
|
||||
"用户正在漏洞详情页复现并提问。",
|
||||
mention,
|
||||
`严重等级:${finding.severity};漏洞类型:${finding.vulnclass || "未分类"}。`,
|
||||
];
|
||||
const summary = truncate(finding.summary || "", 300);
|
||||
if (summary) lines.push(`摘要:${summary}`);
|
||||
const entryPoint = extended.url || extended.entry_point || extended.endpoint;
|
||||
if (entryPoint) lines.push(`入口点:${truncate(entryPoint, 240)}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { createRequire } = require("node:module");
|
||||
const test = require("node:test");
|
||||
|
||||
// Keep the tests runnable on Node 20, which does not load TypeScript natively.
|
||||
const requireFromTest = createRequire(__filename);
|
||||
requireFromTest("ts-node").register({
|
||||
transpileOnly: true,
|
||||
compilerOptions: { module: "CommonJS", moduleResolution: "Node10" },
|
||||
});
|
||||
|
||||
const {
|
||||
buildFindingAgentContextMessage,
|
||||
canAskFindingAgent,
|
||||
findingAgentUnavailableReason,
|
||||
} = requireFromTest("../components/finding-agent-chat.ts");
|
||||
|
||||
const finding = (overrides = {}) => ({
|
||||
id: "42",
|
||||
vulnclass: "SQL 注入",
|
||||
name: "搜索参数注入",
|
||||
severity: "high",
|
||||
status: "confirmed",
|
||||
summary: "搜索参数可控制数据库查询。",
|
||||
evidence: "large evidence omitted",
|
||||
ts: "2026-10-27T00:00:00Z",
|
||||
task_id: "task-7",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("finding without task cannot ask its Agent", () => {
|
||||
assert.equal(canAskFindingAgent(finding({ task_id: undefined })), false);
|
||||
assert.match(findingAgentUnavailableReason(finding({ task_id: undefined })), /无所属任务/);
|
||||
});
|
||||
|
||||
test("task-owned finding can ask and context contains a finding mention", () => {
|
||||
assert.equal(canAskFindingAgent(finding()), true);
|
||||
const context = buildFindingAgentContextMessage(finding());
|
||||
assert.match(context, /用户正在漏洞详情页复现并提问/);
|
||||
assert.match(context, /@\[漏洞#42 搜索参数注入\]/);
|
||||
assert.match(context, /SQL 注入/);
|
||||
});
|
||||
|
||||
test("inherited finding is read-only", () => {
|
||||
assert.equal(canAskFindingAgent(finding({ inherited: true, source_task_id: "source-1" })), false);
|
||||
assert.match(findingAgentUnavailableReason(finding({ inherited: true })), /继承漏洞只读/);
|
||||
});
|
||||
Reference in new issue
Block a user