Auto-scroll component for websocket/grpc/sse

This commit is contained in:
Gregory Schier
2025-02-03 07:05:14 -08:00
parent be0ef7afce
commit 17dc1991f1
11 changed files with 208 additions and 137 deletions

View File

@@ -3,7 +3,7 @@ use crate::error::Result;
use crate::manager::WebsocketManager;
use crate::render::render_request;
use chrono::Utc;
use log::info;
use log::{info, warn};
use std::str::FromStr;
use tauri::http::{HeaderMap, HeaderName};
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
@@ -143,7 +143,9 @@ pub(crate) async fn close<R: Runtime>(
.ok_or(GenericError("WebSocket Request not found".to_string()))?;
let mut ws_manager = ws_manager.lock().await;
ws_manager.send(&connection.id, Message::Close(None)).await?;
if let Err(e) = ws_manager.send(&connection.id, Message::Close(None)).await {
warn!("Failed to close WebSocket connection: {e:?}");
};
upsert_websocket_event(
&window,
WebsocketEvent {

View File

@@ -9,14 +9,16 @@ interface Props {
export function EmptyStateText({ children, className }: Props) {
return (
<div
className={classNames(
className,
'rounded-lg border border-dashed border-border-subtle',
'h-full py-2 text-text-subtlest flex items-center justify-center italic',
)}
>
{children}
<div className="w-full h-full pb-2">
<div
className={classNames(
className,
'rounded-lg border border-dashed border-border-subtle',
'h-full py-2 text-text-subtlest flex items-center justify-center italic',
)}
>
{children}
</div>
</div>
);
}

View File

@@ -2,11 +2,12 @@ import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import type { CSSProperties } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCopy } from '../hooks/useCopy';
import { useGrpcEvents } from '../hooks/useGrpcEvents';
import { usePinnedGrpcConnection } from '../hooks/usePinnedGrpcConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Icon } from './core/Icon';
@@ -80,24 +81,25 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
/>
</div>
</HStack>
<div className="overflow-y-auto h-full">
{activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)}
{...events.map((e) => (
{activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)}
<AutoScroller
data={events}
render={(event) => (
<EventRow
key={e.id}
event={e}
isActive={e.id === activeEventId}
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (e.id === activeEventId) setActiveEventId(null);
else setActiveEventId(e.id);
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
))}
</div>
)}
/>
</div>
)
}
@@ -195,14 +197,16 @@ function EventRow({
event: GrpcEvent;
}) {
const { eventType, status, createdAt, content, error } = event;
const ref = useRef<HTMLDivElement>(null);
return (
<div className="px-1">
<div className="px-1" ref={ref}>
<button
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
isActive && '!bg-surface-highlight !text-text',
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>

View File

@@ -456,7 +456,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
stateKey={`other.${activeRequest.id}`}
/>
) : (
<EmptyStateText>Empty Body</EmptyStateText>
<EmptyStateText>No Body</EmptyStateText>
)}
</TabContent>
<TabContent value={TAB_DESCRIPTION}>

View File

@@ -162,9 +162,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<Icon size="xl" spin icon="refresh" className="text-text-subtlest" />
</EmptyStateText>
) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? (
<div className="pb-2 h-full">
<EmptyStateText>Empty Body</EmptyStateText>
</div>
<EmptyStateText>Empty </EmptyStateText>
) : contentType?.match(/^text\/event-stream$/i) && viewMode === 'pretty' ? (
<EventStreamViewer response={activeResponse} />
) : contentType?.match(/^image\/svg/) ? (

View File

@@ -2,13 +2,14 @@ import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models'
import classNames from 'classnames';
import { format } from 'date-fns';
import { hexy } from 'hexy';
import React, { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { useCopy } from '../hooks/useCopy';
import { useFormatText } from '../hooks/useFormatText';
import { usePinnedWebsocketConnection } from '../hooks/usePinnedWebsocketConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { useWebsocketEvents } from '../hooks/useWebsocketEvents';
import { languageFromContentType } from '../lib/contentType';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Editor } from './core/Editor/Editor';
@@ -55,7 +56,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
}, [activeEvent?.message, hexDump]);
const language = languageFromContentType(null, message);
const formattedContent = useFormatText({ language, text: message, pretty: true });
const formattedMessage = useFormatText({ language, text: message, pretty: true });
const copy = useCopy();
return (
@@ -76,32 +77,33 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
<span>&bull;</span>
<span>{events.length} Messages</span>
</HStack>
<div className="ml-auto">
<HStack space={0.5} className="ml-auto">
<RecentWebsocketConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedConnectionId}
/>
</div>
</HStack>
</HStack>
<div className="overflow-y-auto h-full">
{activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)}
{...events.map((e) => (
{activeConnection.error && (
<Banner color="danger" className="m-3">
{activeConnection.error}
</Banner>
)}
<AutoScroller
data={events}
render={(event) => (
<EventRow
key={e.id}
event={e}
isActive={e.id === activeEventId}
key={event.id}
event={event}
isActive={event.id === activeEventId}
onClick={() => {
if (e.id === activeEventId) setActiveEventId(null);
else setActiveEventId(e.id);
if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(event.id);
}}
/>
))}
</div>
)}
/>
</div>
)
}
@@ -113,30 +115,32 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
<Separator />
</div>
<div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]">
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="h-xs mb-2 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
{activeEvent.messageType === 'close'
? 'Connection Closed'
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
</div>
<HStack space={1}>
<Button
variant="border"
size="xs"
onClick={() => {
if (activeEventId == null) return;
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
}}
>
{hexDump ? 'Show Message' : 'Show Hexdump'}
</Button>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copy(message)}
/>
</HStack>
{message != '' && (
<HStack space={1}>
<Button
variant="border"
size="xs"
onClick={() => {
if (activeEventId == null) return;
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
}}
>
{hexDump ? 'Show Message' : 'Show Hexdump'}
</Button>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copy(formattedMessage.data ?? '')}
/>
</HStack>
)}
</div>
{!showLarge && activeEvent.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
@@ -164,7 +168,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
) : (
<Editor
language={language}
defaultValue={formattedContent.data ?? ''}
defaultValue={formattedMessage.data ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
@@ -188,17 +192,19 @@ function EventRow({
event: WebsocketEvent;
}) {
const { createdAt, message: messageBytes, isServer, messageType } = event;
const ref = useRef<HTMLDivElement>(null);
const message = messageBytes
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
: '';
return (
<div className="px-1">
<div className="px-1" ref={ref}>
<button
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
isActive && '!bg-surface-highlight !text-text',
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
@@ -215,9 +221,13 @@ function EventRow({
}
/>
<div className={classNames('w-full truncate text-xs')}>
{messageType === 'close'
? 'Connection closed by ' + (isServer ? 'server' : 'client')
: message.slice(0, 1000)}
{messageType === 'close' ? (
'Connection closed by ' + (isServer ? 'server' : 'client')
) : message === '' ? (
<em className="italic text-text-subtlest">No content</em>
) : (
message.slice(0, 1000)
)}
{/*{error && <span className="text-warning"> ({error})</span>}*/}
</div>
<div className={classNames('opacity-50 text-xs')}>

View File

@@ -0,0 +1,89 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import type { ReactElement, UIEvent } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { IconButton } from './IconButton';
interface Props<T> {
data: T[];
render: (item: T, index: number) => ReactElement<HTMLElement>;
}
export function AutoScroller<T>({ data, render }: Props<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState<boolean>(true);
// The virtualizer
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
});
// Scroll to new items
const handleScroll = useCallback(
(e: UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
// Set auto-scroll when container is scrolled
const pixelsFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight);
const newAutoScroll = pixelsFromBottom <= 0;
if (newAutoScroll !== autoScroll) {
setAutoScroll(newAutoScroll);
}
},
[autoScroll],
);
// Scroll to bottom on count change
useLayoutEffect(() => {
if (!autoScroll) return;
const el = containerRef.current;
if (el == null) return;
el.scrollTop = el.scrollHeight;
}, [autoScroll, data.length]);
return (
<div className="h-full w-full relative">
{!autoScroll && (
<div className="absolute bottom-0 right-0 m-2">
<IconButton
title="Lock scroll to bottom"
icon="arrow_down"
size="sm"
iconSize="md"
variant="border"
className={'!bg-surface z-10'}
onClick={() => setAutoScroll((v) => !v)}
/>
</div>
)}
<div ref={containerRef} className="h-full w-full overflow-y-auto" onScroll={handleScroll}>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
// height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{render(data[virtualItem.index]!, virtualItem.index)}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -61,6 +61,7 @@ const icons = {
keyboard: lucide.KeyboardIcon,
left_panel_hidden: lucide.PanelLeftOpenIcon,
left_panel_visible: lucide.PanelLeftCloseIcon,
lock: lucide.LockIcon,
magic_wand: lucide.Wand2Icon,
minus: lucide.MinusIcon,
minus_circle: lucide.MinusCircleIcon,

View File

@@ -84,7 +84,7 @@ export const JsonAttributeTree = ({
}, [attrValue, attrKeyJsonPath, isExpanded, depth]);
const labelEl = (
<span className={classNames(labelClassName, 'select-text group-hover:text-text-subtle')}>
<span className={classNames(labelClassName, 'cursor-text select-text group-hover:text-text-subtle')}>
{label}
</span>
);
@@ -115,7 +115,7 @@ export const JsonAttributeTree = ({
</button>
) : (
<>
<span className="text-primary mr-1.5 pl-4 whitespace-nowrap select-text">
<span className="text-primary mr-1.5 pl-4 whitespace-nowrap cursor-text select-text">
{attrKey}:
</span>
{labelEl}

View File

@@ -1,12 +1,11 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import type { HttpResponse } from '@yaakapp-internal/models';
import type { ServerSentEvent } from '@yaakapp-internal/sse';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import React, { Fragment, useMemo, useRef, useState } from 'react';
import React, { Fragment, useMemo, useState } from 'react';
import { useFormatText } from '../../hooks/useFormatText';
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
import { isJSON } from '../../lib/contentType';
import { AutoScroller } from '../core/AutoScroller';
import { Button } from '../core/Button';
import type { EditorProps } from '../core/Editor/Editor';
import { Editor } from '../core/Editor/Editor';
@@ -52,7 +51,7 @@ function ActualEventStreamViewer({ response }: Props) {
defaultRatio={0.4}
minHeightPx={20}
firstSlot={() => (
<EventStreamEventsVirtual
<EventStreamEvents
events={events.data ?? []}
activeEventIndex={activeEventIndex}
setActiveEventIndex={setActiveEventIndex}
@@ -66,7 +65,7 @@ function ActualEventStreamViewer({ response }: Props) {
<Separator />
</div>
<div className="pl-2 overflow-y-auto">
<HStack space={1.5} className="mb-2 select-text cursor-text font-semibold">
<HStack space={1.5} className="mb-2 font-semibold">
<EventLabels
className="text-sm"
event={activeEvent}
@@ -113,7 +112,7 @@ function FormattedEditor({ text, language }: { text: string; language: EditorPro
return <Editor readOnly defaultValue={formatted.data} language={language} stateKey={null} />;
}
function EventStreamEventsVirtual({
function EventStreamEvents({
events,
activeEventIndex,
setActiveEventIndex,
@@ -122,53 +121,21 @@ function EventStreamEventsVirtual({
activeEventIndex: number | null;
setActiveEventIndex: (eventId: number | null) => void;
}) {
// The scrollable element for your list
const parentRef = useRef<HTMLDivElement>(null);
// The virtualizer
const rowVirtualizer = useVirtualizer({
count: events.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 28, // react-virtual requires a height, so we'll give it one
});
return (
<div ref={parentRef} className="overflow-y-auto">
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
const event = events[virtualItem.index]!;
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<EventStreamEvent
event={event}
isActive={virtualItem.index === activeEventIndex}
index={virtualItem.index}
onClick={() => {
if (virtualItem.index === activeEventIndex) setActiveEventIndex(null);
else setActiveEventIndex(virtualItem.index);
}}
/>
</div>
);
})}
</div>
</div>
<AutoScroller
data={events}
render={(event, i) => (
<EventStreamEvent
event={event}
isActive={i === activeEventIndex}
index={i}
onClick={() => {
if (i === activeEventIndex) setActiveEventIndex(null);
else setActiveEventIndex(i);
}}
/>
)}
/>
);
}
@@ -186,22 +153,20 @@ function EventStreamEvent({
index: number;
}) {
return (
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
<button
onClick={onClick}
className={classNames(
className,
'w-full grid grid-cols-[auto_auto_minmax(0,3fr)] gap-2 items-center text-left',
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
isActive && '!bg-surface-highlight !text-text',
'-mx-1.5 px-1.5 h-xs font-mono group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
<Icon className={classNames('text-info')} title="Server Message" icon="arrow_big_down_dash" />
<EventLabels className="text-sm" event={event} isActive={isActive} index={index} />
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
</motion.button>
</button>
);
}

View File

@@ -25,7 +25,7 @@ function initForWorkspace(workspaceId: string, syncDir: string | null) {
const debouncedSync = debounce(() => {
if (syncDir == null) return;
syncWorkspace.mutate({ workspaceId, syncDir });
});
}, 1000);
// Sync on model upsert
const unsubUpsertedModels = listenToTauriEvent<ModelPayload>('upserted_model', (p) => {