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
6 changes: 1 addition & 5 deletions __tests__/pages/utilities/har-file-viewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,9 @@ describe("HARFileViewer", () => {
await new Promise((resolve) => setTimeout(resolve, 500));

// Should still see the api request
expect(
screen.getByText("https://example.com/api/test")
).toBeInTheDocument();

// Should not see the css request (it should be filtered out)
const rows = screen.queryAllByTestId("table-row");
expect(rows.length).toBe(1);
expect(rows[0]).toHaveTextContent("https://example.com/api/test");
});

test("should clear search query when clear button is clicked", async () => {
Expand Down
67 changes: 67 additions & 0 deletions components/MatchIndicators.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { MatchCategory } from "@/components/utils/har-utils";

interface MatchIndicatorsProps {
categories: MatchCategory[];
className?: string;
}

/**
* Displays colored dots to indicate which parts of an entry matched the search.
* Similar to iOS notification indicators.
*
* Color scheme:
* - Blue: URL match
* - Purple: Headers match
* - Orange: Request payload match
* - Green: Response content match
*/
export default function MatchIndicators({
categories,
className = "",
}: MatchIndicatorsProps) {
if (categories.length === 0) {
return null;
}

const getCategoryColor = (category: MatchCategory): string => {
switch (category) {
case "url":
return "bg-blue-500";
case "headers":
return "bg-purple-500";
case "request":
return "bg-orange-500";
case "response":
return "bg-green-500";
default:
return "bg-gray-500";
}
};

const getCategoryTitle = (category: MatchCategory): string => {
switch (category) {
case "url":
return "Match in URL";
case "headers":
return "Match in headers";
case "request":
return "Match in request payload";
case "response":
return "Match in response content";
default:
return "Match found";
}
};

return (
<div className={`flex items-center gap-1 ${className}`}>
{categories.map((category) => (
<div
key={category}
className={`w-2 h-2 rounded-full ${getCategoryColor(category)}`}
title={getCategoryTitle(category)}
/>
))}
</div>
);
}
99 changes: 99 additions & 0 deletions components/MatchSummaryPills.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
HarEntry,
getMatchCategories,
MatchCategory,
} from "@/components/utils/har-utils";

interface MatchSummaryPillsProps {
entries: HarEntry[];
searchQuery: string;
className?: string;
}

interface CategoryCount {
category: MatchCategory;
count: number;
label: string;
color: string;
}

/**
* Displays pill-shaped badges showing the count of matches by category.
* Helps users understand where their search matches are located.
*/
export default function MatchSummaryPills({
entries,
searchQuery,
className = "",
}: MatchSummaryPillsProps) {
if (!searchQuery) {
return null;
}

// Count matches by category
const categoryCounts: Record<MatchCategory, number> = {
url: 0,
headers: 0,
request: 0,
response: 0,
};

entries.forEach((entry) => {
const matchInfo = getMatchCategories(entry, searchQuery);
matchInfo.categories.forEach((category) => {
categoryCounts[category]++;
});
});

// Build display data
const categoryData: CategoryCount[] = [
{
category: "url",
count: categoryCounts.url,
label: "URLs",
color:
"bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800",
},
{
category: "headers",
count: categoryCounts.headers,
label: "Headers",
color:
"bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-200 dark:border-purple-800",
},
{
category: "request",
count: categoryCounts.request,
label: "Requests",
color:
"bg-orange-500/10 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800",
},
{
category: "response",
count: categoryCounts.response,
label: "Responses",
color:
"bg-green-500/10 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800",
},
];

// Filter out categories with no matches
const activeCategories = categoryData.filter((cat) => cat.count > 0);

if (activeCategories.length === 0) {
return null;
}

return (
<div className={`flex flex-wrap gap-2 ${className}`}>
{activeCategories.map((cat) => (
<div
key={cat.category}
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${cat.color}`}
>
{cat.label}: {cat.count}
</div>
))}
</div>
);
}
56 changes: 56 additions & 0 deletions components/SearchHighlightText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
interface SearchHighlightTextProps {
text: string;
searchQuery: string;
className?: string;
}

/**
* Component to highlight search matches in text, similar to Chrome's Cmd+F functionality.
* Matches are highlighted with a yellow background.
*/
export default function SearchHighlightText({
text,
searchQuery,
className = "",
}: SearchHighlightTextProps) {
if (!searchQuery || !text) {
return <span className={className}>{text}</span>;
}

const parts: JSX.Element[] = [];
const lowerText = text.toLowerCase();
const lowerQuery = searchQuery.toLowerCase();

let lastIndex = 0;
let currentIndex = 0;

while ((currentIndex = lowerText.indexOf(lowerQuery, lastIndex)) !== -1) {
// Add text before match
if (currentIndex > lastIndex) {
parts.push(
<span key={`text-${lastIndex}`}>
{text.slice(lastIndex, currentIndex)}
</span>
);
}

// Add highlighted match
parts.push(
<mark
key={`match-${currentIndex}`}
className="bg-yellow-300 dark:bg-yellow-600 dark:text-black rounded px-0.5"
>
{text.slice(currentIndex, currentIndex + lowerQuery.length)}
</mark>
);

lastIndex = currentIndex + lowerQuery.length;
}

// Add remaining text
if (lastIndex < text.length) {
parts.push(<span key={`text-${lastIndex}`}>{text.slice(lastIndex)}</span>);
}

return <span className={className}>{parts}</span>;
}
73 changes: 73 additions & 0 deletions components/utils/har-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,76 @@ export function tryParseJSON(str: string) {
return str;
}
}

// Search match categories for visual indicators
export type MatchCategory = "url" | "headers" | "request" | "response";

export interface MatchInfo {
categories: MatchCategory[];
hasMatch: boolean;
}

/**
* Determines which categories of content match the search query for a given entry.
* Used to display colored indicators showing where matches were found.
*/
export function getMatchCategories(
entry: HarEntry,
searchQuery: string
): MatchInfo {
if (!searchQuery) {
return { categories: [], hasMatch: false };
}

const query = searchQuery.toLowerCase();
const categories: MatchCategory[] = [];

// Check URL
if (entry.request.url.toLowerCase().includes(query)) {
categories.push("url");
}

// Check request and response headers
const hasHeaderMatch =
entry.request.headers.some(
(header) =>
header.name.toLowerCase().includes(query) ||
header.value.toLowerCase().includes(query)
) ||
entry.response.headers.some(
(header) =>
header.name.toLowerCase().includes(query) ||
header.value.toLowerCase().includes(query)
);

if (hasHeaderMatch) {
categories.push("headers");
}

// Check request payload
if (entry.request.postData?.text) {
if (entry.request.postData.text.toLowerCase().includes(query)) {
categories.push("request");
}
}

// Check response content
if (entry.response.content.text) {
let contentToSearch = entry.response.content.text;
if (isBase64(contentToSearch)) {
try {
contentToSearch = atob(contentToSearch);
} catch (e) {
// If decode fails, search in original
}
}
if (contentToSearch.toLowerCase().includes(query)) {
categories.push("response");
}
}

return {
categories,
hasMatch: categories.length > 0,
};
}
Loading