mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-29 01:23:50 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ebc6ec5b7 | ||
|
|
aff5f2f699 |
No files matched your search
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatClock(value: Date) {
|
||||
const pad = (part: number) => String(part).padStart(2, "0");
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function LiveClock({ className }: { className?: string }) {
|
||||
const [now, setNow] = React.useState<Date | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const update = () => setNow(new Date());
|
||||
update();
|
||||
const timer = window.setInterval(update, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<time
|
||||
role="timer"
|
||||
className={cn(
|
||||
"hidden rounded-md border bg-muted/40 px-2 py-1 font-mono text-[11px] text-muted-foreground tabular-nums sm:inline-flex",
|
||||
className,
|
||||
)}
|
||||
dateTime={now?.toISOString()}
|
||||
aria-label="当前时间"
|
||||
title="当前本地时间"
|
||||
>
|
||||
{now ? formatClock(now) : "---- -- -- --:--:--"}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
@@ -10,13 +10,12 @@ import { useCurrentUser } from "@/hooks/use-current-user";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
|
||||
import { LiveClock } from "./live-clock";
|
||||
import { AccountSwitcher } from "./sidebar/account-switcher";
|
||||
import { LayoutControls } from "./sidebar/layout-controls";
|
||||
import { SearchDialog } from "./sidebar/search-dialog";
|
||||
import { ThemeSwitcher } from "./sidebar/theme-switcher";
|
||||
import { UpdateBadge } from "./update-badge";
|
||||
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
|
||||
|
||||
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
|
||||
function isFullBleed(pathname: string) {
|
||||
@@ -78,7 +77,6 @@ function MainContentInner({ children }: { children: ReactNode }) {
|
||||
<EgressIpNavbar />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<LiveClock />
|
||||
{version && (
|
||||
<span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span>
|
||||
)}
|
||||
|
||||
@@ -111,70 +111,6 @@ function HighlightedBody({ body, format }: { body: string; format: BodyFormat })
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a packet with the same syntax rules everywhere in the UI.
|
||||
*
|
||||
* `lineNumbers` is disabled by the repeater editor because it already owns a
|
||||
* synchronised gutter. Keeping the tokenisation here prevents packet previews
|
||||
* and editable packet panes from drifting apart visually.
|
||||
*/
|
||||
export function HttpPacketSyntax({
|
||||
raw,
|
||||
wrapLines = true,
|
||||
lineNumbers = true,
|
||||
emptyFallback = "(空)",
|
||||
lineClassName = "leading-relaxed",
|
||||
}: {
|
||||
raw: string;
|
||||
wrapLines?: boolean;
|
||||
lineNumbers?: boolean;
|
||||
emptyFallback?: string;
|
||||
lineClassName?: string;
|
||||
}) {
|
||||
const value = raw || emptyFallback;
|
||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||
const separator = lines.indexOf("");
|
||||
// LLM records and a few provider diagnostics contain a standalone JSON
|
||||
// payload instead of an HTTP head. Treat that as a body too, so those
|
||||
// packet views use the same token colours as captured HTTP traffic.
|
||||
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : value;
|
||||
const bodyFormat = detectBodyFormat(body);
|
||||
const renderLine = (line: string, index: number) => {
|
||||
if (separator < 0 && bodyFormat !== "plain") return <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 (
|
||||
<>
|
||||
{lines.map((line, index) => (
|
||||
<div
|
||||
key={`${index}-${line}`}
|
||||
data-line={index + 1}
|
||||
className={cn("grid min-w-0", lineClassName, lineNumbers && "grid-cols-[1.5rem_minmax(0,1fr)]")}
|
||||
>
|
||||
{lineNumbers ? (
|
||||
<span className="sticky left-0 self-stretch border-r bg-muted/20 px-1 text-right text-muted-foreground/60">
|
||||
{index + 1}
|
||||
</span>
|
||||
) : null}
|
||||
<code
|
||||
className={cn(
|
||||
"min-h-[1lh] min-w-0 [tab-size:4]",
|
||||
lineNumbers ? "pr-16 pl-1.5" : "",
|
||||
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
|
||||
)}
|
||||
>
|
||||
{renderLine(line, index)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty grid rows have no text, so native selection copying drops their newlines.
|
||||
// Map the selected DOM boundaries back to the raw packet instead.
|
||||
function selectedPacketText(container: HTMLElement, value: string, lines: string[]): string | null {
|
||||
@@ -214,6 +150,9 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const value = raw || "(空)";
|
||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||
const separator = lines.indexOf("");
|
||||
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
|
||||
const bodyFormat = detectBodyFormat(body);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) return;
|
||||
@@ -230,6 +169,13 @@ 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 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">
|
||||
@@ -288,7 +234,22 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
selection.addRange(range);
|
||||
}}
|
||||
>
|
||||
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
|
||||
{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)]"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { SendIcon, Settings2 } from "lucide-react";
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
@@ -32,40 +32,14 @@ import { newId, type RepeaterTab } from "./repeater-state";
|
||||
import { useRepeaterStore } from "./repeater-store";
|
||||
import { RepeaterTextPane } from "./repeater-text-pane";
|
||||
|
||||
function prettyResponse(response: NonNullable<RepeaterTab["response"]>) {
|
||||
const raw = buildRawResponse(response);
|
||||
const separator = raw.indexOf("\n\n");
|
||||
if (separator < 0) return raw;
|
||||
const body = raw.slice(separator + 2);
|
||||
try {
|
||||
return `${raw.slice(0, separator)}\n\n${JSON.stringify(JSON.parse(body), null, 2)}`;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function responseStatusClass(status: number) {
|
||||
if (status >= 500) return "border-rose-300 text-rose-600";
|
||||
if (status >= 400) return "border-amber-300 text-amber-600";
|
||||
if (status >= 300) return "border-sky-300 text-sky-600";
|
||||
return "border-emerald-300 text-emerald-600";
|
||||
}
|
||||
|
||||
function responseTextFor(response: RepeaterTab["response"], mode: "raw" | "pretty") {
|
||||
if (!response) return "";
|
||||
return mode === "pretty" ? prettyResponse(response) : buildRawResponse(response);
|
||||
}
|
||||
|
||||
export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
const { useProxy, insecure, response } = tab;
|
||||
const updateTab = useRepeaterStore((state) => state.updateTab);
|
||||
const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined);
|
||||
const [targetOpen, setTargetOpen] = React.useState(false);
|
||||
const [targetDraft, setTargetDraft] = React.useState("");
|
||||
const [responseMode, setResponseMode] = React.useState<"raw" | "pretty">("pretty");
|
||||
const targetOrigin = tab.targetOrigin ?? (tab.rawRequest === undefined ? connectionOrigin(tab.url) : "");
|
||||
const requestText = buildRawRequest(tab);
|
||||
const responseText = responseTextFor(response, responseMode);
|
||||
function changeRequest(rawRequest: string) {
|
||||
try {
|
||||
const { httpVersion: _version, ...parts } = parseRawRequest(rawRequest, targetOrigin);
|
||||
@@ -122,15 +96,11 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-1 pb-2">
|
||||
<span className="text-muted-foreground text-xs">目标</span>
|
||||
<code
|
||||
className="min-w-0 max-w-[min(60vw,44rem)] truncate rounded bg-muted px-2 py-1 font-mono text-xs"
|
||||
title={targetOrigin || tab.url}
|
||||
>
|
||||
{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"}
|
||||
</code>
|
||||
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs" title={targetOrigin || tab.url}>
|
||||
发送目标:{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -140,15 +110,14 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
setTargetOpen(true);
|
||||
}}
|
||||
>
|
||||
<Settings2 data-icon="inline-start" />
|
||||
目标设置
|
||||
修改目标
|
||||
</Button>
|
||||
<Button className="ml-auto" disabled={busy} onClick={() => void send()}>
|
||||
<Button disabled={busy} onClick={() => void send()}>
|
||||
<SendIcon data-icon="inline-start" />
|
||||
{busy ? "发送中…" : "发送"}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldSet className="flex-row flex-wrap items-center gap-3 border-b px-1 pb-2 text-xs">
|
||||
<FieldSet className="flex-row flex-wrap items-center gap-4 text-sm">
|
||||
<FieldLegend className="sr-only">发送选项</FieldLegend>
|
||||
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-use-proxy">
|
||||
<Checkbox
|
||||
@@ -169,19 +138,15 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
跳过 TLS 校验
|
||||
</FieldLabel>
|
||||
{response ? (
|
||||
<div className="ml-auto flex items-center gap-2 text-muted-foreground text-xs">
|
||||
{response.status ? (
|
||||
<Badge variant="outline" className={responseStatusClass(response.status)}>
|
||||
{response.status} {response.status_text}
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
{response.status ? <Badge variant="secondary">{response.status_text || response.status}</Badge> : null}
|
||||
<span>{response.elapsed_ms} ms</span>
|
||||
<span>{response.body_bytes.toLocaleString()} 字节</span>
|
||||
{response.body_truncated ? <Badge variant="outline">正文已截断</Badge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</FieldSet>
|
||||
<div className="grid min-h-0 flex-1 auto-rows-[minmax(300px,1fr)] gap-2 overflow-hidden lg:auto-rows-auto lg:grid-cols-2">
|
||||
<div className="grid min-h-0 flex-1 auto-rows-[minmax(300px,1fr)] gap-3 overflow-y-auto xl:auto-rows-auto xl:grid-cols-2">
|
||||
<RepeaterTextPane
|
||||
id="repeater-request"
|
||||
label="请求"
|
||||
@@ -189,12 +154,6 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
onChange={changeRequest}
|
||||
disabled={busy}
|
||||
placeholder="POST /path HTTP/1.1"
|
||||
syntaxHighlight
|
||||
panelClassName="bg-background"
|
||||
showClock
|
||||
toolbar={
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">Raw</span>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-h-[300px] min-w-0 flex-col gap-2 xl:min-h-0">
|
||||
{response?.error ? (
|
||||
@@ -205,32 +164,8 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
|
||||
<RepeaterTextPane
|
||||
id="repeater-response"
|
||||
label="响应"
|
||||
value={responseText}
|
||||
value={response ? buildRawResponse(response) : ""}
|
||||
placeholder="发送后显示原始响应"
|
||||
syntaxHighlight
|
||||
panelClassName="bg-background"
|
||||
toolbar={
|
||||
<div className="ml-auto flex items-center gap-0.5 rounded border bg-muted/50 p-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant={responseMode === "raw" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setResponseMode("raw")}
|
||||
>
|
||||
Raw
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={responseMode === "pretty" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setResponseMode("pretty")}
|
||||
>
|
||||
Pretty
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</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;
|
||||
|
||||
@@ -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,37 +22,38 @@ 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,
|
||||
className,
|
||||
}: {
|
||||
workspace: RepeaterWorkspace;
|
||||
onRename: (value: Rename) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
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 = [
|
||||
@@ -67,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);
|
||||
@@ -79,131 +91,169 @@ export function RepeaterNavigator({
|
||||
return (
|
||||
<nav
|
||||
aria-label="请求重发分组和标签"
|
||||
className={cn(
|
||||
"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,
|
||||
)}
|
||||
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) => {
|
||||
@@ -212,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>
|
||||
@@ -228,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();
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { WrapTextIcon } from "lucide-react";
|
||||
|
||||
import { LiveClock } from "@/app/(main)/_components/live-clock";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { HttpPacketSyntax } from "@/components/http-code-block";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RepeaterTextPane({
|
||||
id,
|
||||
@@ -17,10 +12,6 @@ export function RepeaterTextPane({
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
syntaxHighlight = false,
|
||||
toolbar,
|
||||
panelClassName,
|
||||
showClock = false,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -28,43 +19,20 @@ export function RepeaterTextPane({
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
syntaxHighlight?: boolean;
|
||||
toolbar?: React.ReactNode;
|
||||
panelClassName?: string;
|
||||
showClock?: boolean;
|
||||
}) {
|
||||
const gutter = React.useRef<HTMLPreElement>(null);
|
||||
const syntax = React.useRef<HTMLDivElement>(null);
|
||||
const [wrapLines, setWrapLines] = React.useState(true);
|
||||
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 (
|
||||
<section
|
||||
className={cn(
|
||||
"flex min-h-[280px] min-w-0 flex-1 flex-col gap-2 rounded-md border bg-background p-2 xl:min-h-0",
|
||||
panelClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-[280px] min-w-0 flex-1 flex-col gap-2 xl:min-h-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor={id} className="font-medium text-xs">
|
||||
{label}
|
||||
</label>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">{lines} 行</span>
|
||||
{showClock ? <LiveClock className="ml-auto" /> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center gap-1 rounded border px-1.5 text-[10px] text-muted-foreground hover:bg-muted"
|
||||
onClick={() => setWrapLines((value) => !value)}
|
||||
title={wrapLines ? "切换为不换行" : "切换为自动换行"}
|
||||
aria-label={wrapLines ? "切换为不换行" : "切换为自动换行"}
|
||||
>
|
||||
<WrapTextIcon className="size-3" />
|
||||
{wrapLines ? "换行" : "不换行"}
|
||||
</button>
|
||||
{toolbar}
|
||||
<CopyButton text={value} label={`复制${label}`} className={showClock ? "ml-0" : "ml-auto"} />
|
||||
<CopyButton text={value} label={`复制${label}`} className="ml-auto" />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 flex-1 overflow-hidden rounded 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"
|
||||
@@ -79,41 +47,17 @@ 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}
|
||||
placeholder={placeholder}
|
||||
wrap={wrapLines ? "soft" : "off"}
|
||||
wrap="off"
|
||||
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 selection:bg-primary/20 selection:text-transparent placeholder:text-muted-foreground"
|
||||
: "")
|
||||
}
|
||||
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={wrapLines}
|
||||
emptyFallback=""
|
||||
lineClassName="leading-5"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { Library, Plus, X } from "lucide-react";
|
||||
import { FolderPlus } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -27,39 +27,16 @@ import {
|
||||
import { Field, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { RepeaterEditor } from "./repeater-editor";
|
||||
import { RepeaterNavigator } from "./repeater-navigator";
|
||||
import { RepeaterTopBar } from "./repeater-navigator";
|
||||
import { useRepeaterStore } from "./repeater-store";
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "text-sky-600 dark:text-sky-400",
|
||||
POST: "text-emerald-600 dark:text-emerald-400",
|
||||
PUT: "text-amber-600 dark:text-amber-400",
|
||||
PATCH: "text-amber-600 dark:text-amber-400",
|
||||
DELETE: "text-rose-600 dark:text-rose-400",
|
||||
};
|
||||
|
||||
function methodColor(method: string) {
|
||||
return METHOD_COLORS[method.toUpperCase()] ?? "text-muted-foreground";
|
||||
}
|
||||
|
||||
function statusColor(status: number) {
|
||||
if (status >= 500) return "text-rose-600 dark:text-rose-400";
|
||||
if (status >= 400) return "text-amber-600 dark:text-amber-400";
|
||||
if (status >= 300) return "text-sky-600 dark:text-sky-400";
|
||||
if (status >= 200) return "text-emerald-600 dark:text-emerald-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
export function RepeaterWorkspace() {
|
||||
const store = useRepeaterStore();
|
||||
const { workspace, hydrate } = store;
|
||||
const [rename, setRename] = React.useState<{ kind: "tab" | "group"; id: string; name: string } | null>(null);
|
||||
const [libraryOpen, setLibraryOpen] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
@@ -75,20 +52,16 @@ export function RepeaterWorkspace() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100dvh-7rem)] min-w-0 flex-col gap-2 xl:h-[calc(100dvh-8rem)] xl:min-h-[600px]">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b pb-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-lg tracking-tight">请求重发</h1>
|
||||
<p className="text-muted-foreground text-xs">Repeater · 草稿与历史响应保存在此浏览器</p>
|
||||
<div className="flex min-h-[calc(100dvh-7rem)] min-w-0 flex-col gap-4 xl:h-[calc(100dvh-8rem)] xl:min-h-[600px]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-semibold text-xl">请求重发</h1>
|
||||
<p className="text-muted-foreground text-sm">编辑、发送并比较请求 · 草稿与近期响应保存在此浏览器</p>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setLibraryOpen(true)}>
|
||||
<Library data-icon="inline-start" />
|
||||
请求库
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => store.createTab()}>
|
||||
<Plus data-icon="inline-start" />
|
||||
新建请求
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => store.createGroup()}>
|
||||
<FolderPlus data-icon="inline-start" />
|
||||
新建分组
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -111,98 +84,40 @@ export function RepeaterWorkspace() {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-10 min-w-0 items-center rounded-md border bg-muted/20 p-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
||||
{workspace.tabs.map((tab) => {
|
||||
const selected = tab.id === workspace.activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"group flex min-w-0 max-w-64 shrink-0 items-center rounded-sm border border-transparent",
|
||||
selected && "border-border bg-background shadow-xs",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-w-0 justify-start gap-2 rounded-sm px-2 text-xs"
|
||||
aria-current={selected ? "page" : undefined}
|
||||
onClick={() => store.setActiveTab(tab.id)}
|
||||
onDoubleClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
|
||||
title={`${tab.title}\n${tab.url}`}
|
||||
>
|
||||
<span className={cn("font-mono font-semibold", methodColor(tab.method))}>{tab.method}</span>
|
||||
<span className="truncate">{tab.title}</span>
|
||||
{tab.response?.status ? (
|
||||
<span className={cn("font-mono text-[10px]", statusColor(tab.response.status))}>
|
||||
{tab.response.status}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="mr-0.5 opacity-60 hover:opacity-100"
|
||||
aria-label={`关闭请求 ${tab.title}`}
|
||||
onClick={() => store.closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
<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 ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="min-w-0 flex-1 truncate font-medium text-sm" title={active.title}>
|
||||
{active.title}
|
||||
</h2>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<FieldLabel htmlFor="repeater-group">移入分组</FieldLabel>
|
||||
<Select value={active.groupId} onValueChange={(id) => store.setTabGroup(active.id, id)}>
|
||||
<SelectTrigger id="repeater-group" className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{workspace.groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="新建请求" onClick={() => store.createTab()}>
|
||||
<Plus />
|
||||
</Button>
|
||||
<RepeaterEditor key={active.id} tab={active} />
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => store.createTab()}>新建标签</Button>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col rounded-md border bg-muted/10 p-2">
|
||||
{active ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 px-1">
|
||||
<span className="truncate font-medium text-sm" title={active.title}>
|
||||
{active.title}
|
||||
</span>
|
||||
<Field orientation="horizontal" className="ml-auto w-auto gap-2">
|
||||
<FieldLabel htmlFor="repeater-group" className="text-muted-foreground text-xs">
|
||||
分组
|
||||
</FieldLabel>
|
||||
<Select value={active.groupId} onValueChange={(id) => store.setTabGroup(active.id, id)}>
|
||||
<SelectTrigger id="repeater-group" className="h-7 w-32 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{workspace.groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<RepeaterEditor key={active.id} tab={active} />
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => store.createTab()}>新建请求</Button>
|
||||
)}
|
||||
</main>
|
||||
<Sheet open={libraryOpen} onOpenChange={setLibraryOpen}>
|
||||
<SheetContent side="left" className="w-[min(92vw,380px)] max-w-none p-3">
|
||||
<SheetHeader className="px-1 pb-1">
|
||||
<SheetTitle>请求库</SheetTitle>
|
||||
<SheetDescription>搜索、分组和恢复请求历史。</SheetDescription>
|
||||
</SheetHeader>
|
||||
<RepeaterNavigator
|
||||
workspace={workspace}
|
||||
onRename={setRename}
|
||||
className="h-full min-h-0 w-full rounded-md border-0 p-1 lg:h-full lg:min-h-0 lg:w-full xl:h-full"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Dialog
|
||||
open={rename !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
Reference in new issue
Block a user