Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions src/components/panels/MocksPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,10 @@ export function MocksPanel() {
const updateRule = useMocksStore((s) => s.updateRule);
const removeRule = useMocksStore((s) => s.removeRule);
const active = rules.filter((r) => r.enabled).length;
// The add form is the whole panel when there are no rules; once there are,
// it folds behind a button so the rules themselves stay at the top.
const [adding, setAdding] = useState(false);
const showForm = adding || rules.length === 0;

return (
<div className="flex flex-col h-full">
Expand All @@ -263,13 +267,33 @@ export function MocksPanel() {
{rules.length} mock{rules.length !== 1 ? "s" : ""}
{rules.length > 0 && ` · ${active} active`}
</span>
{rules.length > 0 && (
<button
type="button"
onClick={() => setAdding(!adding)}
className="flex items-center gap-1 text-micro text-[var(--color-accent)] hover:text-[var(--color-text-primary)]"
>
{adding ? (
"Cancel"
) : (
<>
<Plus size={11} /> New mock
</>
)}
</button>
)}
</div>

<AddMockForm
key={defaultPattern}
defaultPattern={defaultPattern}
onAdd={(r) => addRule(activeWorkspaceId, r)}
/>
{showForm && (
<AddMockForm
key={defaultPattern}
defaultPattern={defaultPattern}
onAdd={(r) => {
addRule(activeWorkspaceId, r);
setAdding(false);
}}
/>
)}

<div className="flex-1 overflow-y-auto">
{rules.length === 0 ? (
Expand Down
20 changes: 13 additions & 7 deletions src/components/panels/NetworkPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { useTabsStore } from "@/stores/tabs";
import { getLiveWorkspaceActiveTab } from "@/lib/workspaceTabs";
import { entryToCurl, entryToFetch } from "@/lib/networkCopy";
import { copyToClipboard } from "@/lib/clipboard";
import { formatBytes, statusColor } from "@/lib/format";
import { hostOf } from "@/lib/url";
import { formatBytes, prettyBody, statusColor } from "@/lib/format";
import { hostOf, splitUrl } from "@/lib/url";
import { useCopy } from "@/hooks/useCopy";
import { setNetworkCapture, webviewReload } from "@/services/browser";
import { useMocksStore, mockRuleFromEntry } from "@/stores/mocks";
Expand All @@ -33,8 +33,10 @@ const METHOD_COLORS: Record<string, string> = {
fixed columns alone exceed that, so a 0-minimum let URL collapse to nothing. The row/header
wrapper below scrolls horizontally once the panel is narrower than this, same as it would
in any other network inspector. */
const GRID_COLS = "2.75rem 2rem 2.25rem minmax(6rem,1fr) 6rem 3.5rem 2.5rem 1.75rem";
const GRID_MIN_WIDTH = "31rem";
// Method/Status/Type are sized for their uppercase header labels, not just the
// row values: at 2rem, "STATUS" ran into "TYPE".
const GRID_COLS = "3.25rem 3.25rem 3rem minmax(7rem,1fr) 6rem 3.5rem 2.5rem 1.75rem";
const GRID_MIN_WIDTH = "34rem";

const TYPE_COLORS: Record<string, string> = {
document: "bg-blue-500/20 text-blue-300",
Expand Down Expand Up @@ -65,10 +67,12 @@ function entryIsApi(e: NetworkLogEntry): boolean {
return e.resourceType === "xhr" || e.resourceType === "fetch";
}

const BODY_PREVIEW_MAX = 500;
// Room for a pretty-printed JSON response; the copy tab has the whole thing.
const BODY_PREVIEW_MAX = 4000;

function bodyPreview(body: string): string {
if (!body) return "";
body = prettyBody(body);
if (body.length > BODY_PREVIEW_MAX) return body.slice(0, BODY_PREVIEW_MAX) + "...";
return body;
}
Expand Down Expand Up @@ -215,7 +219,7 @@ function DetailTabs({ entry, onClose }: { entry: NetworkLogEntry; onClose: () =>
<div>
{entry.body ? (
<>
<pre className="whitespace-pre-wrap break-all text-micro text-[var(--color-text-muted)] bg-[var(--color-hover)] rounded p-1.5 max-h-40 overflow-y-auto">
<pre className="whitespace-pre-wrap break-words text-micro text-[var(--color-text-secondary)] bg-[var(--color-hover)] rounded p-1.5 max-h-64 overflow-y-auto">
{bodyPreview(entry.body)}
</pre>
{entry.bodyTruncated && (
Expand Down Expand Up @@ -283,6 +287,7 @@ function EntryRow({
e.stopPropagation();
copyToClipboard(entryToCurl(entry, true));
};
const { path, host } = splitUrl(entry.url);

return (
<div
Expand Down Expand Up @@ -315,7 +320,8 @@ function EntryRow({
className="truncate text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)]"
title={entry.url}
>
{entry.url}
{path}
{host && <span className="ml-1.5 text-[var(--color-text-disabled)]">{host}</span>}
</span>
<span className="truncate text-[var(--color-text-muted)]" title={entry.referrer || undefined}>
{hostOf(entry.referrer, entry.referrer || "—")}
Expand Down
13 changes: 12 additions & 1 deletion src/lib/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatBytes } from "./format";
import { formatBytes, prettyBody } from "./format";

describe("formatBytes", () => {
it("formats bytes under 1KB as B", () => {
Expand All @@ -21,3 +21,14 @@ describe("formatBytes", () => {
expect(formatBytes(-1)).toBe("—");
});
});

describe("prettyBody", () => {
it("re-indents JSON", () => {
expect(prettyBody('{"a":1,"b":[2]}')).toBe('{\n "a": 1,\n "b": [\n 2\n ]\n}');
});

it("leaves non-JSON and broken JSON alone", () => {
expect(prettyBody("<html></html>")).toBe("<html></html>");
expect(prettyBody('{"a":')).toBe('{"a":');
});
});
15 changes: 15 additions & 0 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,18 @@ export function statusColor(code: number): string {
if (code >= 500) return "text-status-5xx";
return "text-[var(--color-text-disabled)]";
}

/**
* A response body as the network log shows it: JSON re-indented when it
* parses, anything else untouched. Keyed off the body itself rather than the
* Content-Type header, which APIs get wrong often enough (text/plain JSON).
*/
export function prettyBody(body: string): string {
const t = body.trimStart();
if (!t.startsWith("{") && !t.startsWith("[")) return body;
try {
return JSON.stringify(JSON.parse(body), null, 2);
} catch {
return body;
}
}
14 changes: 14 additions & 0 deletions src/lib/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DEFAULT_SEARCH_ENGINE,
SEARCH_ENGINES,
hostOf,
splitUrl,
originOf,
resolveInput,
searchUrl,
Expand Down Expand Up @@ -127,3 +128,16 @@ describe("titleFromUrl", () => {
expect(titleFromUrl("not a url at all")).toBe("not a url at all");
});
});

describe("splitUrl", () => {
it("puts the path and query first, host second", () => {
expect(splitUrl("http://localhost:8000/api/orders?page=2")).toEqual({
path: "/api/orders?page=2",
host: "localhost:8000",
});
});

it("returns an unparseable URL whole", () => {
expect(splitUrl("not a url")).toEqual({ path: "not a url", host: "" });
});
});
14 changes: 14 additions & 0 deletions src/lib/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ export function originOf(url: string, fallback = ""): string {
}
}

/**
* Splits a URL for the network log: path + query first (what tells requests
* apart), host second (repeated on most rows, so it is the part to truncate).
* An unparseable URL comes back whole as `path`.
*/
export function splitUrl(url: string): { path: string; host: string } {
try {
const u = new URL(url);
return { path: u.pathname + u.search, host: u.host };
} catch {
return { path: url, host: "" };
}
}

/** Hostname only (no port), or `fallback` if `url` doesn't parse. */
export function hostOf(url: string, fallback = ""): string {
try {
Expand Down