mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-24 01:28:35 +02:00
Auto-scroll component for websocket/grpc/sse
This commit is contained in:
@@ -3,7 +3,7 @@ use crate::error::Result;
|
|||||||
use crate::manager::WebsocketManager;
|
use crate::manager::WebsocketManager;
|
||||||
use crate::render::render_request;
|
use crate::render::render_request;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use log::info;
|
use log::{info, warn};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use tauri::http::{HeaderMap, HeaderName};
|
use tauri::http::{HeaderMap, HeaderName};
|
||||||
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
|
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()))?;
|
.ok_or(GenericError("WebSocket Request not found".to_string()))?;
|
||||||
|
|
||||||
let mut ws_manager = ws_manager.lock().await;
|
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(
|
upsert_websocket_event(
|
||||||
&window,
|
&window,
|
||||||
WebsocketEvent {
|
WebsocketEvent {
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ interface Props {
|
|||||||
|
|
||||||
export function EmptyStateText({ children, className }: Props) {
|
export function EmptyStateText({ children, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-full h-full pb-2">
|
||||||
className={classNames(
|
<div
|
||||||
className,
|
className={classNames(
|
||||||
'rounded-lg border border-dashed border-border-subtle',
|
className,
|
||||||
'h-full py-2 text-text-subtlest flex items-center justify-center italic',
|
'rounded-lg border border-dashed border-border-subtle',
|
||||||
)}
|
'h-full py-2 text-text-subtlest flex items-center justify-center italic',
|
||||||
>
|
)}
|
||||||
{children}
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import type { CSSProperties } from 'react';
|
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 { useCopy } from '../hooks/useCopy';
|
||||||
import { useGrpcEvents } from '../hooks/useGrpcEvents';
|
import { useGrpcEvents } from '../hooks/useGrpcEvents';
|
||||||
import { usePinnedGrpcConnection } from '../hooks/usePinnedGrpcConnection';
|
import { usePinnedGrpcConnection } from '../hooks/usePinnedGrpcConnection';
|
||||||
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
||||||
|
import { AutoScroller } from './core/AutoScroller';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon } from './core/Icon';
|
||||||
@@ -80,24 +81,25 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
<div className="overflow-y-auto h-full">
|
{activeConnection.error && (
|
||||||
{activeConnection.error && (
|
<Banner color="danger" className="m-3">
|
||||||
<Banner color="danger" className="m-3">
|
{activeConnection.error}
|
||||||
{activeConnection.error}
|
</Banner>
|
||||||
</Banner>
|
)}
|
||||||
)}
|
<AutoScroller
|
||||||
{...events.map((e) => (
|
data={events}
|
||||||
|
render={(event) => (
|
||||||
<EventRow
|
<EventRow
|
||||||
key={e.id}
|
key={event.id}
|
||||||
event={e}
|
event={event}
|
||||||
isActive={e.id === activeEventId}
|
isActive={event.id === activeEventId}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (e.id === activeEventId) setActiveEventId(null);
|
if (event.id === activeEventId) setActiveEventId(null);
|
||||||
else setActiveEventId(e.id);
|
else setActiveEventId(event.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -195,14 +197,16 @@ function EventRow({
|
|||||||
event: GrpcEvent;
|
event: GrpcEvent;
|
||||||
}) {
|
}) {
|
||||||
const { eventType, status, createdAt, content, error } = event;
|
const { eventType, status, createdAt, content, error } = event;
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-1">
|
<div className="px-1" ref={ref}>
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
|
'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',
|
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
|
||||||
isActive && '!bg-surface-highlight !text-text',
|
isActive && '!bg-surface-active !text-text',
|
||||||
'text-text-subtle hover:text',
|
'text-text-subtle hover:text',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
stateKey={`other.${activeRequest.id}`}
|
stateKey={`other.${activeRequest.id}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyStateText>Empty Body</EmptyStateText>
|
<EmptyStateText>No Body</EmptyStateText>
|
||||||
)}
|
)}
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_DESCRIPTION}>
|
<TabContent value={TAB_DESCRIPTION}>
|
||||||
|
|||||||
@@ -162,9 +162,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
<Icon size="xl" spin icon="refresh" className="text-text-subtlest" />
|
<Icon size="xl" spin icon="refresh" className="text-text-subtlest" />
|
||||||
</EmptyStateText>
|
</EmptyStateText>
|
||||||
) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? (
|
) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? (
|
||||||
<div className="pb-2 h-full">
|
<EmptyStateText>Empty </EmptyStateText>
|
||||||
<EmptyStateText>Empty Body</EmptyStateText>
|
|
||||||
</div>
|
|
||||||
) : contentType?.match(/^text\/event-stream$/i) && viewMode === 'pretty' ? (
|
) : contentType?.match(/^text\/event-stream$/i) && viewMode === 'pretty' ? (
|
||||||
<EventStreamViewer response={activeResponse} />
|
<EventStreamViewer response={activeResponse} />
|
||||||
) : contentType?.match(/^image\/svg/) ? (
|
) : contentType?.match(/^image\/svg/) ? (
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models'
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { hexy } from 'hexy';
|
import { hexy } from 'hexy';
|
||||||
import React, { useMemo, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useCopy } from '../hooks/useCopy';
|
import { useCopy } from '../hooks/useCopy';
|
||||||
import { useFormatText } from '../hooks/useFormatText';
|
import { useFormatText } from '../hooks/useFormatText';
|
||||||
import { usePinnedWebsocketConnection } from '../hooks/usePinnedWebsocketConnection';
|
import { usePinnedWebsocketConnection } from '../hooks/usePinnedWebsocketConnection';
|
||||||
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
||||||
import { useWebsocketEvents } from '../hooks/useWebsocketEvents';
|
import { useWebsocketEvents } from '../hooks/useWebsocketEvents';
|
||||||
import { languageFromContentType } from '../lib/contentType';
|
import { languageFromContentType } from '../lib/contentType';
|
||||||
|
import { AutoScroller } from './core/AutoScroller';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { Editor } from './core/Editor/Editor';
|
import { Editor } from './core/Editor/Editor';
|
||||||
@@ -55,7 +56,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
|
|||||||
}, [activeEvent?.message, hexDump]);
|
}, [activeEvent?.message, hexDump]);
|
||||||
|
|
||||||
const language = languageFromContentType(null, message);
|
const language = languageFromContentType(null, message);
|
||||||
const formattedContent = useFormatText({ language, text: message, pretty: true });
|
const formattedMessage = useFormatText({ language, text: message, pretty: true });
|
||||||
const copy = useCopy();
|
const copy = useCopy();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,32 +77,33 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
|
|||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{events.length} Messages</span>
|
<span>{events.length} Messages</span>
|
||||||
</HStack>
|
</HStack>
|
||||||
<div className="ml-auto">
|
<HStack space={0.5} className="ml-auto">
|
||||||
<RecentWebsocketConnectionsDropdown
|
<RecentWebsocketConnectionsDropdown
|
||||||
connections={connections}
|
connections={connections}
|
||||||
activeConnection={activeConnection}
|
activeConnection={activeConnection}
|
||||||
onPinnedConnectionId={setPinnedConnectionId}
|
onPinnedConnectionId={setPinnedConnectionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
<div className="overflow-y-auto h-full">
|
{activeConnection.error && (
|
||||||
{activeConnection.error && (
|
<Banner color="danger" className="m-3">
|
||||||
<Banner color="danger" className="m-3">
|
{activeConnection.error}
|
||||||
{activeConnection.error}
|
</Banner>
|
||||||
</Banner>
|
)}
|
||||||
)}
|
<AutoScroller
|
||||||
{...events.map((e) => (
|
data={events}
|
||||||
|
render={(event) => (
|
||||||
<EventRow
|
<EventRow
|
||||||
key={e.id}
|
key={event.id}
|
||||||
event={e}
|
event={event}
|
||||||
isActive={e.id === activeEventId}
|
isActive={event.id === activeEventId}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (e.id === activeEventId) setActiveEventId(null);
|
if (event.id === activeEventId) setActiveEventId(null);
|
||||||
else setActiveEventId(e.id);
|
else setActiveEventId(event.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -113,30 +115,32 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
|
|||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]">
|
<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">
|
<div className="font-semibold">
|
||||||
{activeEvent.messageType === 'close'
|
{activeEvent.messageType === 'close'
|
||||||
? 'Connection Closed'
|
? 'Connection Closed'
|
||||||
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
|
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
|
||||||
</div>
|
</div>
|
||||||
<HStack space={1}>
|
{message != '' && (
|
||||||
<Button
|
<HStack space={1}>
|
||||||
variant="border"
|
<Button
|
||||||
size="xs"
|
variant="border"
|
||||||
onClick={() => {
|
size="xs"
|
||||||
if (activeEventId == null) return;
|
onClick={() => {
|
||||||
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
|
if (activeEventId == null) return;
|
||||||
}}
|
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
|
||||||
>
|
}}
|
||||||
{hexDump ? 'Show Message' : 'Show Hexdump'}
|
>
|
||||||
</Button>
|
{hexDump ? 'Show Message' : 'Show Hexdump'}
|
||||||
<IconButton
|
</Button>
|
||||||
title="Copy message"
|
<IconButton
|
||||||
icon="copy"
|
title="Copy message"
|
||||||
size="xs"
|
icon="copy"
|
||||||
onClick={() => copy(message)}
|
size="xs"
|
||||||
/>
|
onClick={() => copy(formattedMessage.data ?? '')}
|
||||||
</HStack>
|
/>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!showLarge && activeEvent.message.length > 1000 * 1000 ? (
|
{!showLarge && activeEvent.message.length > 1000 * 1000 ? (
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
<VStack space={2} className="italic text-text-subtlest">
|
||||||
@@ -164,7 +168,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Editor
|
<Editor
|
||||||
language={language}
|
language={language}
|
||||||
defaultValue={formattedContent.data ?? ''}
|
defaultValue={formattedMessage.data ?? ''}
|
||||||
wrapLines={false}
|
wrapLines={false}
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
stateKey={null}
|
stateKey={null}
|
||||||
@@ -188,17 +192,19 @@ function EventRow({
|
|||||||
event: WebsocketEvent;
|
event: WebsocketEvent;
|
||||||
}) {
|
}) {
|
||||||
const { createdAt, message: messageBytes, isServer, messageType } = event;
|
const { createdAt, message: messageBytes, isServer, messageType } = event;
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const message = messageBytes
|
const message = messageBytes
|
||||||
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
|
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-1">
|
<div className="px-1" ref={ref}>
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
|
'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',
|
'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
|
||||||
isActive && '!bg-surface-highlight !text-text',
|
isActive && '!bg-surface-active !text-text',
|
||||||
'text-text-subtle hover:text',
|
'text-text-subtle hover:text',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -215,9 +221,13 @@ function EventRow({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className={classNames('w-full truncate text-xs')}>
|
<div className={classNames('w-full truncate text-xs')}>
|
||||||
{messageType === 'close'
|
{messageType === 'close' ? (
|
||||||
? 'Connection closed by ' + (isServer ? 'server' : 'client')
|
'Connection closed by ' + (isServer ? 'server' : 'client')
|
||||||
: message.slice(0, 1000)}
|
) : message === '' ? (
|
||||||
|
<em className="italic text-text-subtlest">No content</em>
|
||||||
|
) : (
|
||||||
|
message.slice(0, 1000)
|
||||||
|
)}
|
||||||
{/*{error && <span className="text-warning"> ({error})</span>}*/}
|
{/*{error && <span className="text-warning"> ({error})</span>}*/}
|
||||||
</div>
|
</div>
|
||||||
<div className={classNames('opacity-50 text-xs')}>
|
<div className={classNames('opacity-50 text-xs')}>
|
||||||
|
|||||||
89
src-web/components/core/AutoScroller.tsx
Normal file
89
src-web/components/core/AutoScroller.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ const icons = {
|
|||||||
keyboard: lucide.KeyboardIcon,
|
keyboard: lucide.KeyboardIcon,
|
||||||
left_panel_hidden: lucide.PanelLeftOpenIcon,
|
left_panel_hidden: lucide.PanelLeftOpenIcon,
|
||||||
left_panel_visible: lucide.PanelLeftCloseIcon,
|
left_panel_visible: lucide.PanelLeftCloseIcon,
|
||||||
|
lock: lucide.LockIcon,
|
||||||
magic_wand: lucide.Wand2Icon,
|
magic_wand: lucide.Wand2Icon,
|
||||||
minus: lucide.MinusIcon,
|
minus: lucide.MinusIcon,
|
||||||
minus_circle: lucide.MinusCircleIcon,
|
minus_circle: lucide.MinusCircleIcon,
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export const JsonAttributeTree = ({
|
|||||||
}, [attrValue, attrKeyJsonPath, isExpanded, depth]);
|
}, [attrValue, attrKeyJsonPath, isExpanded, depth]);
|
||||||
|
|
||||||
const labelEl = (
|
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}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -115,7 +115,7 @@ export const JsonAttributeTree = ({
|
|||||||
</button>
|
</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}:
|
{attrKey}:
|
||||||
</span>
|
</span>
|
||||||
{labelEl}
|
{labelEl}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
||||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||||
import type { ServerSentEvent } from '@yaakapp-internal/sse';
|
import type { ServerSentEvent } from '@yaakapp-internal/sse';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { motion } from 'framer-motion';
|
import React, { Fragment, useMemo, useState } from 'react';
|
||||||
import React, { Fragment, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useFormatText } from '../../hooks/useFormatText';
|
import { useFormatText } from '../../hooks/useFormatText';
|
||||||
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
|
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
|
||||||
import { isJSON } from '../../lib/contentType';
|
import { isJSON } from '../../lib/contentType';
|
||||||
|
import { AutoScroller } from '../core/AutoScroller';
|
||||||
import { Button } from '../core/Button';
|
import { Button } from '../core/Button';
|
||||||
import type { EditorProps } from '../core/Editor/Editor';
|
import type { EditorProps } from '../core/Editor/Editor';
|
||||||
import { Editor } from '../core/Editor/Editor';
|
import { Editor } from '../core/Editor/Editor';
|
||||||
@@ -52,7 +51,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
|||||||
defaultRatio={0.4}
|
defaultRatio={0.4}
|
||||||
minHeightPx={20}
|
minHeightPx={20}
|
||||||
firstSlot={() => (
|
firstSlot={() => (
|
||||||
<EventStreamEventsVirtual
|
<EventStreamEvents
|
||||||
events={events.data ?? []}
|
events={events.data ?? []}
|
||||||
activeEventIndex={activeEventIndex}
|
activeEventIndex={activeEventIndex}
|
||||||
setActiveEventIndex={setActiveEventIndex}
|
setActiveEventIndex={setActiveEventIndex}
|
||||||
@@ -66,7 +65,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
|||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
<div className="pl-2 overflow-y-auto">
|
<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
|
<EventLabels
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
event={activeEvent}
|
event={activeEvent}
|
||||||
@@ -113,7 +112,7 @@ function FormattedEditor({ text, language }: { text: string; language: EditorPro
|
|||||||
return <Editor readOnly defaultValue={formatted.data} language={language} stateKey={null} />;
|
return <Editor readOnly defaultValue={formatted.data} language={language} stateKey={null} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventStreamEventsVirtual({
|
function EventStreamEvents({
|
||||||
events,
|
events,
|
||||||
activeEventIndex,
|
activeEventIndex,
|
||||||
setActiveEventIndex,
|
setActiveEventIndex,
|
||||||
@@ -122,53 +121,21 @@ function EventStreamEventsVirtual({
|
|||||||
activeEventIndex: number | null;
|
activeEventIndex: number | null;
|
||||||
setActiveEventIndex: (eventId: number | null) => void;
|
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 (
|
return (
|
||||||
<div ref={parentRef} className="overflow-y-auto">
|
<AutoScroller
|
||||||
<div
|
data={events}
|
||||||
style={{
|
render={(event, i) => (
|
||||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
<EventStreamEvent
|
||||||
width: '100%',
|
event={event}
|
||||||
position: 'relative',
|
isActive={i === activeEventIndex}
|
||||||
}}
|
index={i}
|
||||||
>
|
onClick={() => {
|
||||||
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
|
if (i === activeEventIndex) setActiveEventIndex(null);
|
||||||
const event = events[virtualItem.index]!;
|
else setActiveEventIndex(i);
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,22 +153,20 @@ function EventStreamEvent({
|
|||||||
index: number;
|
index: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<button
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
'w-full grid grid-cols-[auto_auto_minmax(0,3fr)] gap-2 items-center text-left',
|
'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',
|
'-mx-1.5 px-1.5 h-xs font-mono group focus:outline-none focus:text-text rounded',
|
||||||
isActive && '!bg-surface-highlight !text-text',
|
isActive && '!bg-surface-active !text-text',
|
||||||
'text-text-subtle hover:text',
|
'text-text-subtle hover:text',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className={classNames('text-info')} title="Server Message" icon="arrow_big_down_dash" />
|
<Icon className={classNames('text-info')} title="Server Message" icon="arrow_big_down_dash" />
|
||||||
<EventLabels className="text-sm" event={event} isActive={isActive} index={index} />
|
<EventLabels className="text-sm" event={event} isActive={isActive} index={index} />
|
||||||
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
|
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
|
||||||
</motion.button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function initForWorkspace(workspaceId: string, syncDir: string | null) {
|
|||||||
const debouncedSync = debounce(() => {
|
const debouncedSync = debounce(() => {
|
||||||
if (syncDir == null) return;
|
if (syncDir == null) return;
|
||||||
syncWorkspace.mutate({ workspaceId, syncDir });
|
syncWorkspace.mutate({ workspaceId, syncDir });
|
||||||
});
|
}, 1000);
|
||||||
|
|
||||||
// Sync on model upsert
|
// Sync on model upsert
|
||||||
const unsubUpsertedModels = listenToTauriEvent<ModelPayload>('upserted_model', (p) => {
|
const unsubUpsertedModels = listenToTauriEvent<ModelPayload>('upserted_model', (p) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user