JSX element rendering the IPC viewer panel
export function IpcViewer(): React.ReactElement {
const { ipcEvents, maxIpcEntries, isLogRedactionEnabled } = useDebugState();
const { clearIpcEvents } = useDebugActions();
const [searchTerm, setSearchTerm] = useState("");
const [channelFilter, setChannelFilter] = useState("");
const [directionFilters, setDirectionFilters] = useState<
Record<DirectionFilter, boolean>
>(DEFAULT_DIRECTION_FILTER);
const [isClipboardFallbackOpen, setIsClipboardFallbackOpen] = useState(false);
const [clipboardFallbackText, setClipboardFallbackText] = useState("");
const DEFAULT_WINDOW_SIZE = 100;
const [visibleCount, setVisibleCount] = useState<number>(DEFAULT_WINDOW_SIZE);
// Listen for custom events from PerformanceMonitor to set filter
useEffect(() => {
const handleSetFilter = (event: Event) => {
const customEvent = event as CustomEvent<{ channel?: string }>;
const detail = customEvent.detail;
if (detail?.channel) {
// Set channel filter when custom event is triggered
setChannelFilter(detail.channel);
setVisibleCount(DEFAULT_WINDOW_SIZE);
}
};
globalThis.addEventListener(IPC_SET_FILTER_EVENT, handleSetFilter);
return () => {
globalThis.removeEventListener(IPC_SET_FILTER_EVENT, handleSetFilter);
};
}, []);
const filteredEntries = useMemo(() => {
// Step 1: Apply direction and search filters
const directionAndSearchFiltered = ipcEvents.filter(
(entry) =>
matchesDirection(entry, directionFilters) &&
includesQuery(entry, searchTerm),
);
// Step 2: Apply channel filtering - first try exact/substring match, then fallback to fuzzy
if (!channelFilter.trim()) {
return directionAndSearchFiltered;
}
const filterLower = channelFilter.toLowerCase();
const exactMatches = directionAndSearchFiltered.filter((entry) =>
entry.channel.toLowerCase().includes(filterLower),
);
// If we found matches with substring search, return them
if (exactMatches.length > 0) {
return exactMatches;
}
// Fallback to fuzzy search if no substring matches found
const fuse = buildFuse(directionAndSearchFiltered, ["channel"], {
...FUSE_PRESET_LOOSE,
});
const results = fuse.search(channelFilter);
return results.map((result: { item: IpcLogEntry }) => result.item);
}, [ipcEvents, directionFilters, searchTerm, channelFilter]);
const availableChannels = useMemo(() => {
const set = new Set<string>();
for (const entry of ipcEvents) {
set.add(entry.channel);
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [ipcEvents]);
const totalEntries = ipcEvents.length;
const toggleDirection = (direction: DirectionFilter) => {
setDirectionFilters((prev) => ({
...prev,
[direction]: !prev[direction],
}));
};
const handleClear = () => {
if (!totalEntries) return;
clearIpcEvents();
toast.success("IPC log cleared");
};
const handleCopy = async (entry: IpcLogEntry) => {
try {
const sanitized = isLogRedactionEnabled
? sanitizeForDebug(entry, { redactSensitive: true })
: entry;
const text = JSON.stringify(sanitized, null, 2);
await navigator.clipboard.writeText(text);
toast.success("Entry copied to clipboard");
} catch (error) {
// Fallback 1: use hidden textarea if clipboard API fails
try {
const textarea = document.createElement("textarea");
const sanitized = isLogRedactionEnabled
? sanitizeForDebug(entry, { redactSensitive: true })
: entry;
textarea.value = JSON.stringify(sanitized, null, 2);
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const success = (
document as { execCommand(command: string): boolean }
).execCommand("copy");
textarea.remove();
if (success) {
toast.success("Entry copied to clipboard");
} else {
throw new Error("execCommand failed");
}
} catch (fallbackError) {
// Fallback 2: Show modal with textarea for manual selection
console.error("Failed to copy IPC log entry", error, fallbackError);
try {
const sanitized = isLogRedactionEnabled
? sanitizeForDebug(entry, { redactSensitive: true })
: entry;
setClipboardFallbackText(JSON.stringify(sanitized, null, 2));
setIsClipboardFallbackOpen(true);
} catch (modalError) {
console.error("Failed to set clipboard fallback modal", modalError);
toast.error(
"Unable to copy: clipboard access denied. Try enabling clipboard permissions or use HTTPS.",
);
}
}
}
};
const onCopy = useCallback((entry: IpcLogEntry) => {
void handleCopy(entry);
}, []);
const reversedFiltered = useMemo(() => {
// show newest entries first
return [...filteredEntries].reverse();
}, [filteredEntries]);
const visibleEntries = useMemo(() => {
return reversedFiltered.slice(0, visibleCount);
}, [reversedFiltered, visibleCount]);
const loadMore = () => setVisibleCount((v) => v + DEFAULT_WINDOW_SIZE);
const showAll = () => setVisibleCount(reversedFiltered.length || 0);
const resetWindow = () => setVisibleCount(DEFAULT_WINDOW_SIZE);
return (
<div className="flex h-full flex-col gap-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2 lg:max-w-2xl">
<h2 className="text-lg font-semibold">IPC traffic monitor</h2>
<p className="text-muted-foreground text-sm">
Observe renderer and main process interactions. Use filters to
narrow down channels or payload content. The most recent{" "}
{maxIpcEntries} entries are retained.
</p>
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline" className="border-border/60 bg-muted/40">
{filteredEntries.length} showing
</Badge>
<Badge variant="outline" className="border-border/60 bg-muted/40">
{totalEntries} captured
</Badge>
<Badge variant="outline" className="border-border/60 bg-muted/40">
{availableChannels.length} channel
{availableChannels.length === 1 ? "" : "s"}
</Badge>
</div>
<div className="mt-2 flex flex-row flex-wrap items-center gap-2">
{DIRECTIONS.map((direction) => {
const active = directionFilters[direction];
return (
<Button
key={direction}
type="button"
variant={active ? "default" : "outline"}
className="rounded-full px-3 py-1 text-xs"
onClick={() => toggleDirection(direction)}
>
<span
className={cn(
"flex items-center gap-1",
DIRECTION_META[direction].tone,
)}
>
{DIRECTION_META[direction].label}
</span>
</Button>
);
})}
<Button
variant="outline"
onClick={handleClear}
disabled={!totalEntries}
>
<Trash2 className="mr-2 h-4 w-4" />
Clear
</Button>
<div className="flex min-w-[220px] max-w-xl flex-1 flex-row gap-2">
<div className="relative min-w-0 flex-1">
<Search className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search payload, channel, status"
className="pl-9"
/>
</div>
<div className="relative min-w-0 flex-1">
<Filter className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={channelFilter}
onChange={(event) => setChannelFilter(event.target.value)}
placeholder="Filter by channel"
className="pl-9"
list="debug-ipc-channels"
/>
{availableChannels.length > 0 && (
<datalist id="debug-ipc-channels">
{availableChannels.map((channel) => (
<option key={channel} value={channel} />
))}
</datalist>
)}
</div>
</div>
</div>
</div>
</div>
<Separator />
<ScrollArea type="always" className="h-full max-h-[35vh] w-full">
<div className="space-y-3 pr-4">
{filteredEntries.length === 0 ? (
<div className="border-border/60 bg-muted/20 flex h-40 flex-col items-center justify-center gap-3 rounded-xl border border-dashed text-center">
<p className="font-medium">No IPC entries to display</p>
<p className="text-muted-foreground text-sm">
Adjust filters or wait for new IPC messages to be captured.
</p>
</div>
) : (
<>
<div className="flex items-center justify-end gap-2">
<div className="text-muted-foreground mr-2 text-xs">
Showing {Math.min(visibleCount, reversedFiltered.length)} of{" "}
{reversedFiltered.length}
</div>
{reversedFiltered.length > visibleCount && (
<Button size="sm" variant="outline" onClick={loadMore}>
Load more
</Button>
)}
{reversedFiltered.length > 0 &&
reversedFiltered.length !== visibleCount && (
<Button size="sm" variant="ghost" onClick={showAll}>
Show all
</Button>
)}
{visibleCount !== DEFAULT_WINDOW_SIZE && (
<Button size="sm" variant="outline" onClick={resetWindow}>
Reset
</Button>
)}
</div>
{visibleEntries.map((entry) => (
<IpcEntry
key={entry.id}
entry={entry}
onCopy={onCopy}
searchTerm={searchTerm}
isLogRedactionEnabled={isLogRedactionEnabled}
/>
))}
</>
)}
</div>
</ScrollArea>
{/* Clipboard fallback modal */}
<Dialog
open={isClipboardFallbackOpen}
onOpenChange={setIsClipboardFallbackOpen}
>
<DialogContent className="max-h-96 max-w-2xl">
<DialogHeader>
<DialogTitle>Copy IPC Entry (Clipboard Unavailable)</DialogTitle>
<DialogDescription>
Your system clipboard is not accessible. Select all text below and
copy it manually, or click "Select All" to prepare for
copying.
</DialogDescription>
</DialogHeader>
<Textarea
readOnly
value={clipboardFallbackText}
className="max-h-64 min-h-48 font-mono text-xs"
onFocus={(event) => event.currentTarget.select()}
/>
<DialogFooter className="flex gap-2">
<Button
variant="outline"
onClick={() => {
const textarea = document.querySelector(
"textarea[readonly]",
) as HTMLTextAreaElement;
if (textarea) {
textarea.select();
textarea.focus();
}
}}
>
<Check className="mr-2 h-4 w-4" />
Select All
</Button>
<Button
variant="default"
onClick={() => setIsClipboardFallbackOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
IPC traffic viewer for monitoring renderer ↔ main process messages. Displays, filters, and inspects IPC logs with direction, channel, and search filters.