diff --git a/__tests__/pages/utilities/har-file-viewer.test.tsx b/__tests__/pages/utilities/har-file-viewer.test.tsx index 0924875..60e6c6d 100644 --- a/__tests__/pages/utilities/har-file-viewer.test.tsx +++ b/__tests__/pages/utilities/har-file-viewer.test.tsx @@ -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 () => { diff --git a/components/MatchIndicators.tsx b/components/MatchIndicators.tsx new file mode 100644 index 0000000..90682e7 --- /dev/null +++ b/components/MatchIndicators.tsx @@ -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 ( +
+ {categories.map((category) => ( +
+ ))} +
+ ); +} diff --git a/components/MatchSummaryPills.tsx b/components/MatchSummaryPills.tsx new file mode 100644 index 0000000..39d6e0e --- /dev/null +++ b/components/MatchSummaryPills.tsx @@ -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 = { + 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 ( +
+ {activeCategories.map((cat) => ( +
+ {cat.label}: {cat.count} +
+ ))} +
+ ); +} diff --git a/components/SearchHighlightText.tsx b/components/SearchHighlightText.tsx new file mode 100644 index 0000000..16e0ed1 --- /dev/null +++ b/components/SearchHighlightText.tsx @@ -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 {text}; + } + + 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( + + {text.slice(lastIndex, currentIndex)} + + ); + } + + // Add highlighted match + parts.push( + + {text.slice(currentIndex, currentIndex + lowerQuery.length)} + + ); + + lastIndex = currentIndex + lowerQuery.length; + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push({text.slice(lastIndex)}); + } + + return {parts}; +} diff --git a/components/utils/har-utils.ts b/components/utils/har-utils.ts index 48c2336..8763327 100644 --- a/components/utils/har-utils.ts +++ b/components/utils/har-utils.ts @@ -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, + }; +} diff --git a/pages/utilities/har-file-viewer.tsx b/pages/utilities/har-file-viewer.tsx index 93f01fb..7586dfa 100644 --- a/pages/utilities/har-file-viewer.tsx +++ b/pages/utilities/har-file-viewer.tsx @@ -9,6 +9,8 @@ import { HarTableProps, isBase64, tryParseJSON, + getMatchCategories, + MatchCategory, } from "@/components/utils/har-utils"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ds/ButtonComponent"; @@ -37,6 +39,9 @@ import { CommandList, } from "@/components/ds/CommandMenu"; import { Checkbox } from "@/components/ds/CheckboxComponent"; +import SearchHighlightText from "@/components/SearchHighlightText"; +import MatchIndicators from "@/components/MatchIndicators"; +import MatchSummaryPills from "@/components/MatchSummaryPills"; interface MultiSelectComboboxProps { data: { value: string; label: string }[]; @@ -335,15 +340,22 @@ export default function HARFileViewer() { )}
- {/* Results count */} - {(debouncedSearchQuery || - activeFilter !== "All" || - statusFilter.length > 0) && ( + +
+ {/* Match Summary Pills */} + {debouncedSearchQuery && ( + + )} + + {/* Results count */}

Showing {getFilteredCount()} of {harData.log.entries.length}{" "} requests

- )} +
{/* Filters and View Mode */} @@ -595,7 +607,19 @@ const HarTable = ({ - + {searchQuery && ( + + )} + - - + + - {filteredAndSortedEntries.map((entry, index) => ( - - = 400 && tableRowErrorStyles - )} - onClick={() => { - setExpandedRow(expandedRow === index ? null : index); - }} - > - = 400 && tableRowErrorStyles )} + onClick={() => { + setExpandedRow(expandedRow === index ? null : index); + }} > - {entry.request.url} - - - - - - - - - {expandedRow === index && ( - - + )} + + + + + + - )} - - ))} + + {expandedRow === index && ( + + + + )} + + ); + })}
Name + Search + + Name +
Status @@ -608,8 +632,16 @@ const HarTable = ({
TypeStarted at + Type + + Started at + handleSort("size")} @@ -617,7 +649,7 @@ const HarTable = ({ Size {sortField === "size" && (sortOrder === "asc" ? " ▲" : " ▼")} handleSort("time")} > Time {sortField === "time" && (sortOrder === "asc" ? " ▲" : " ▼")} @@ -626,74 +658,112 @@ const HarTable = ({
{ + const matchInfo = searchQuery + ? getMatchCategories(entry, searchQuery) + : { categories: [], hasMatch: false }; + + return ( + +
- {entry.response.status} - - {entry.response.content.mimeType} - - {new Date(entry.startedDateTime).toLocaleTimeString()} - - {(entry.response.content.size / 1024).toFixed(1) + "kB"} - -
-
-
- {entry.time.toFixed(0) + "ms"} -
- + > + + + {searchQuery ? ( + + ) : ( + entry.request.url + )} + + {entry.response.status} + + {entry.response.content.mimeType} + + {new Date(entry.startedDateTime).toLocaleTimeString()} + + {(entry.response.content.size / 1024).toFixed(1) + "kB"} + +
+
+
+ {entry.time.toFixed(0) + "ms"}
+ +
); }; -const ExpandedDetails = ({ entry }: { entry: HarEntry }) => { +const ExpandedDetails = ({ + entry, + searchQuery, + matchInfo, +}: { + entry: HarEntry; + searchQuery: string; + matchInfo: { categories: MatchCategory[]; hasMatch: boolean }; +}) => { const [activeTab, setActiveTab] = useState("headers"); const decodeContent = useCallback((content: string) => { @@ -756,17 +826,32 @@ const ExpandedDetails = ({ entry }: { entry: HarEntry }) => { ); }; - const TabHeader = ({ id, label }: { id: string; label: string }) => { + const TabHeader = ({ + id, + label, + hasMatch, + }: { + id: string; + label: string; + hasMatch?: boolean; + }) => { return ( ); }; @@ -774,10 +859,24 @@ const ExpandedDetails = ({ entry }: { entry: HarEntry }) => { return (
- - {entry.request.postData && } + + {entry.request.postData && ( + + )} {entry.response.content.text && ( - + )}
@@ -788,9 +887,26 @@ const ExpandedDetails = ({ entry }: { entry: HarEntry }) => { {entry.response.headers.map((header, index) => (
- {header.name}:{" "} + {searchQuery ? ( + + ) : ( + header.name + )} + :{" "} + + + {searchQuery ? ( + + ) : ( + header.value + )} - {header.value}
))} @@ -798,9 +914,26 @@ const ExpandedDetails = ({ entry }: { entry: HarEntry }) => { {entry.request.headers.map((header, index) => (
- {header.name}:{" "} + {searchQuery ? ( + + ) : ( + header.name + )} + :{" "} + + + {searchQuery ? ( + + ) : ( + header.value + )} - {header.value}
))}