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
175 changes: 175 additions & 0 deletions components/har-waterfall/HarWaterfall.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import React, { useRef, useState, useCallback, useMemo } from "react";
import { HarEntry, FilterType, getFilterType } from "../utils/har-utils";
import { WaterfallCanvas } from "./WaterfallCanvas";
import { WaterfallTooltip } from "./WaterfallTooltip";
import { WaterfallLegend } from "./WaterfallLegend";
import { WaterfallRequestDetails } from "./WaterfallRequestDetails";
import { WaterfallUrlTooltip } from "./WaterfallUrlTooltip";
import { calculateTimings, WaterfallTiming } from "./waterfall-utils";

interface HarWaterfallProps {
entries: HarEntry[];
activeFilter: FilterType;
className?: string;
}

export const HarWaterfall: React.FC<HarWaterfallProps> = ({
entries,
activeFilter,
className = "",
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [hoveredEntry, setHoveredEntry] = useState<{
entry: HarEntry;
timing: WaterfallTiming;
x: number;
y: number;
} | null>(null);
const [hoveredUrl, setHoveredUrl] = useState<{
url: string;
x: number;
y: number;
} | null>(null);
const [selectedEntry, setSelectedEntry] = useState<{
entry: HarEntry;
timing: WaterfallTiming;
} | null>(null);
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });

// Filter entries based on active filter
const filteredEntries = useMemo(() => {
if (activeFilter === "All") return entries;
return entries.filter((entry) => getFilterType(entry) === activeFilter);
}, [entries, activeFilter]);

// Calculate timings for all entries
const timings = useMemo(() => {
return calculateTimings(filteredEntries);
}, [filteredEntries]);

// Handle mouse move for hover detection
const handleMouseMove = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;

const rect = containerRef.current.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top + scrollOffset.y;

// Find which entry is being hovered (accounting for header)
const rowHeight = 30;
const headerHeight = 40;
const adjustedY = y - headerHeight;
const entryIndex = Math.floor(adjustedY / rowHeight);

if (entryIndex >= 0 && entryIndex < filteredEntries.length) {
setHoveredEntry({
entry: filteredEntries[entryIndex],
timing: timings[entryIndex],
x: event.clientX,
y: event.clientY,
});

// Check if hovering over URL area (left portion)
if (x < 300) {
setHoveredUrl({
url: filteredEntries[entryIndex].request.url,
x: event.clientX,
y: event.clientY,
});
} else {
setHoveredUrl(null);
}
} else {
setHoveredEntry(null);
setHoveredUrl(null);
}
},
[filteredEntries, timings, scrollOffset]
);

const handleMouseLeave = useCallback(() => {
setHoveredEntry(null);
setHoveredUrl(null);
}, []);

// Handle click on request
const handleClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;

const rect = containerRef.current.getBoundingClientRect();
const y = event.clientY - rect.top + scrollOffset.y;

// Find which entry was clicked
const rowHeight = 30;
const headerHeight = 40;
const adjustedY = y - headerHeight;
const entryIndex = Math.floor(adjustedY / rowHeight);

if (entryIndex >= 0 && entryIndex < filteredEntries.length) {
setSelectedEntry({
entry: filteredEntries[entryIndex],
timing: timings[entryIndex],
});
}
},
[filteredEntries, timings, scrollOffset]
);

return (
<div className={`relative ${className}`}>
<WaterfallLegend />

<div
ref={containerRef}
className="relative overflow-auto bg-background border border-border rounded-lg cursor-pointer"
style={{ height: "600px" }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
onScroll={(e) => {
const target = e.target as HTMLDivElement;
setScrollOffset({ x: target.scrollLeft, y: target.scrollTop });
}}
>
<WaterfallCanvas
entries={filteredEntries}
timings={timings}
zoomLevel={1}
width={containerRef.current?.clientWidth || 1200}
height={Math.max(600, filteredEntries.length * 30 + 40)}
scrollOffset={scrollOffset}
hoveredIndex={
hoveredEntry ? filteredEntries.indexOf(hoveredEntry.entry) : -1
}
/>
</div>

{hoveredUrl && (
<WaterfallUrlTooltip
url={hoveredUrl.url}
x={hoveredUrl.x}
y={hoveredUrl.y}
/>
)}

{hoveredEntry && !hoveredUrl && (
<WaterfallTooltip
entry={hoveredEntry.entry}
timing={hoveredEntry.timing}
x={hoveredEntry.x}
y={hoveredEntry.y}
/>
)}

{selectedEntry && (
<WaterfallRequestDetails
entry={selectedEntry.entry}
timing={selectedEntry.timing}
onClose={() => setSelectedEntry(null)}
/>
)}
</div>
);
};
63 changes: 63 additions & 0 deletions components/har-waterfall/TruncatedText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { useState } from "react";
import { Button } from "../ds/ButtonComponent";
import { Copy, Check, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";

interface TruncatedTextProps {
text: string;
maxLength?: number;
className?: string;
showWarning?: boolean;
}

export const TruncatedText: React.FC<TruncatedTextProps> = ({
text,
maxLength = 300,
className = "",
showWarning = true,
}) => {
const [copied, setCopied] = useState(false);
const isTruncated = text.length > maxLength;
const displayText = isTruncated ? text.substring(0, maxLength) + "..." : text;

const handleCopy = async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};

if (!isTruncated) {
return <span className={className}>{text}</span>;
}

return (
<div className="space-y-2">
<div className="flex items-start gap-2 min-w-0">
<span className={cn("break-all flex-1", className)}>{displayText}</span>
<Button
size="sm"
variant="ghost"
onClick={handleCopy}
className="h-6 w-6 p-0 flex-shrink-0"
title="Copy full text"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
{showWarning && (
<div className="flex items-center gap-2 text-xs text-amber-600">
<AlertCircle className="h-3 w-3" />
<span>
This content is very long ({text.length.toLocaleString()}{" "}
characters). Copy and paste into your editor to view the full
content.
</span>
</div>
)}
</div>
);
};
Loading