mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-31 22:43:11 +02:00
Refactor desktop app into separate client and proxy apps
This commit is contained in:
21
apps/yaak-client/components/core/Alert.tsx
Normal file
21
apps/yaak-client/components/core/Alert.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from './Button';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
|
||||
export interface AlertProps {
|
||||
onHide: () => void;
|
||||
body: ReactNode;
|
||||
}
|
||||
|
||||
export function Alert({ onHide, body }: AlertProps) {
|
||||
return (
|
||||
<VStack space={3} className="pb-4">
|
||||
<div>{body}</div>
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button className="focus" color="primary" onClick={onHide}>
|
||||
Okay
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
118
apps/yaak-client/components/core/AutoScroller.tsx
Normal file
118
apps/yaak-client/components/core/AutoScroller.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
|
||||
import type { ReactElement, ReactNode, 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>;
|
||||
header?: ReactNode;
|
||||
/** Make container focusable for keyboard navigation */
|
||||
focusable?: boolean;
|
||||
/** Callback to expose the virtualizer for keyboard navigation */
|
||||
onVirtualizerReady?: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
|
||||
}
|
||||
|
||||
export function AutoScroller<T>({
|
||||
data,
|
||||
render,
|
||||
header,
|
||||
focusable = false,
|
||||
onVirtualizerReady,
|
||||
}: 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
|
||||
});
|
||||
|
||||
// Expose virtualizer to parent for keyboard navigation
|
||||
useLayoutEffect(() => {
|
||||
onVirtualizerReady?.(rowVirtualizer);
|
||||
}, [rowVirtualizer, onVirtualizerReady]);
|
||||
|
||||
// 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;
|
||||
|
||||
data.length; // Make linter happy. We want to refresh when length changes
|
||||
|
||||
const el = containerRef.current;
|
||||
if (el == null) return;
|
||||
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [autoScroll, data.length]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
{!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>
|
||||
)}
|
||||
{header ?? <span aria-hidden />}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full overflow-y-auto focus:outline-none"
|
||||
onScroll={handleScroll}
|
||||
tabIndex={focusable ? 0 : undefined}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const item = data[virtualItem.index];
|
||||
return (
|
||||
item != null && (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{render(item, virtualItem.index)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/yaak-client/components/core/Banner.tsx
Normal file
28
apps/yaak-client/components/core/Banner.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface BannerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
color?: 'primary' | 'secondary' | 'success' | 'notice' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
export function Banner({ children, className, color }: BannerProps) {
|
||||
return (
|
||||
<div className="w-auto grid grid-rows-1 max-h-full flex-0">
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
color && 'bg-surface',
|
||||
`x-theme-banner--${color}`,
|
||||
'border border-border border-dashed',
|
||||
'px-4 py-2 rounded-lg select-auto cursor-auto',
|
||||
'overflow-auto text-text',
|
||||
'mb-auto', // Don't stretch all the way down if the parent is in grid or flexbox
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
apps/yaak-client/components/core/BulkPairEditor.tsx
Normal file
63
apps/yaak-client/components/core/BulkPairEditor.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import { Editor } from './Editor/LazyEditor';
|
||||
import type { Pair, PairEditorProps, PairWithId } from './PairEditor';
|
||||
|
||||
type Props = PairEditorProps;
|
||||
|
||||
export function BulkPairEditor({
|
||||
pairs,
|
||||
onChange,
|
||||
namePlaceholder,
|
||||
valuePlaceholder,
|
||||
forceUpdateKey,
|
||||
forcedEnvironmentId,
|
||||
stateKey,
|
||||
}: Props) {
|
||||
const pairsText = useMemo(() => {
|
||||
return pairs
|
||||
.filter((p) => !(p.name.trim() === '' && p.value.trim() === ''))
|
||||
.map(pairToLine)
|
||||
.join('\n');
|
||||
}, [pairs]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(text: string) => {
|
||||
const pairs = text
|
||||
.split('\n')
|
||||
.filter((l: string) => l.trim())
|
||||
.map(lineToPair);
|
||||
onChange(pairs);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
stateKey={`bulk_pair.${stateKey}`}
|
||||
forcedEnvironmentId={forcedEnvironmentId}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
placeholder={`${namePlaceholder ?? 'name'}: ${valuePlaceholder ?? 'value'}`}
|
||||
defaultValue={pairsText}
|
||||
language="pairs"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function pairToLine(pair: Pair) {
|
||||
const value = pair.value.replaceAll('\n', '\\n');
|
||||
return `${pair.name}: ${value}`;
|
||||
}
|
||||
|
||||
function lineToPair(line: string): PairWithId {
|
||||
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? [];
|
||||
return {
|
||||
enabled: true,
|
||||
name: (name ?? '').trim(),
|
||||
value: (value ?? '').replaceAll('\\n', '\n').trim(),
|
||||
id: generateId(),
|
||||
};
|
||||
}
|
||||
147
apps/yaak-client/components/core/Button.tsx
Normal file
147
apps/yaak-client/components/core/Button.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Color } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||
import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey';
|
||||
import { Icon } from './Icon';
|
||||
import { LoadingIcon } from './LoadingIcon';
|
||||
|
||||
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onChange'> & {
|
||||
innerClassName?: string;
|
||||
color?: Color | 'custom' | 'default';
|
||||
variant?: 'border' | 'solid';
|
||||
isLoading?: boolean;
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
|
||||
justify?: 'start' | 'center';
|
||||
type?: 'button' | 'submit';
|
||||
forDropdown?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
hotkeyAction?: HotkeyAction;
|
||||
hotkeyLabelOnly?: boolean;
|
||||
hotkeyPriority?: number;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
isLoading,
|
||||
className,
|
||||
innerClassName,
|
||||
children,
|
||||
forDropdown,
|
||||
color = 'default',
|
||||
type = 'button',
|
||||
justify = 'center',
|
||||
size = 'md',
|
||||
variant = 'solid',
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
disabled,
|
||||
hotkeyAction,
|
||||
hotkeyPriority,
|
||||
hotkeyLabelOnly,
|
||||
title,
|
||||
onClick,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref,
|
||||
) {
|
||||
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join('');
|
||||
const fullTitle = hotkeyTrigger ? `${title ?? ''} ${hotkeyTrigger}`.trim() : title;
|
||||
|
||||
if (isLoading) {
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
const classes = classNames(
|
||||
className,
|
||||
'x-theme-button',
|
||||
`x-theme-button--${variant}`,
|
||||
`x-theme-button--${variant}--${color}`,
|
||||
'border', // They all have borders to ensure the same width
|
||||
'max-w-full min-w-0', // Help with truncation
|
||||
'hocus:opacity-100', // Force opacity for certain hover effects
|
||||
'whitespace-nowrap outline-none',
|
||||
'flex-shrink-0 flex items-center',
|
||||
'outline-0',
|
||||
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
|
||||
justify === 'start' && 'justify-start',
|
||||
justify === 'center' && 'justify-center',
|
||||
size === 'md' && 'h-md px-3 rounded-md',
|
||||
size === 'sm' && 'h-sm px-2.5 rounded-md',
|
||||
size === 'xs' && 'h-xs px-2 text-sm rounded-md',
|
||||
size === '2xs' && 'h-2xs px-2 text-xs rounded',
|
||||
|
||||
// Solids
|
||||
variant === 'solid' && 'border-transparent',
|
||||
variant === 'solid' && color === 'custom' && 'focus-visible:outline-2 outline-border-focus',
|
||||
variant === 'solid' &&
|
||||
color !== 'custom' &&
|
||||
'text-text enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle',
|
||||
variant === 'solid' && color !== 'custom' && color !== 'default' && 'bg-surface',
|
||||
|
||||
// Borders
|
||||
variant === 'border' && 'border',
|
||||
variant === 'border' &&
|
||||
color !== 'custom' &&
|
||||
'border-border-subtle text-text-subtle enabled:hocus:border-border ' +
|
||||
'enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler',
|
||||
);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
|
||||
ref,
|
||||
() => buttonRef.current,
|
||||
);
|
||||
|
||||
useHotKey(
|
||||
hotkeyAction ?? null,
|
||||
() => {
|
||||
buttonRef.current?.click();
|
||||
},
|
||||
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
className={classes}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
onDoubleClick={(e) => {
|
||||
// Kind of a hack? This prevents double-clicks from going through buttons. For example, when
|
||||
// double-clicking the workspace header to toggle window maximization
|
||||
e.stopPropagation();
|
||||
}}
|
||||
title={fullTitle}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoadingIcon size={size === 'auto' ? 'md' : size} className="mr-1" />
|
||||
) : leftSlot ? (
|
||||
<div className="mr-2">{leftSlot}</div>
|
||||
) : null}
|
||||
<div
|
||||
className={classNames(
|
||||
'truncate w-full',
|
||||
justify === 'start' ? 'text-left' : 'text-center',
|
||||
innerClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{rightSlot && <div className="ml-1">{rightSlot}</div>}
|
||||
{forDropdown && (
|
||||
<Icon
|
||||
icon="chevron_down"
|
||||
size={size === 'auto' ? 'md' : size}
|
||||
className="ml-1 -mr-1 relative top-[0.1em]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
25
apps/yaak-client/components/core/ButtonInfiniteLoading.tsx
Normal file
25
apps/yaak-client/components/core/ButtonInfiniteLoading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import type { ButtonProps } from './Button';
|
||||
import { Button } from './Button';
|
||||
|
||||
export function ButtonInfiniteLoading({
|
||||
onClick,
|
||||
isLoading,
|
||||
loadingChildren,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps & { loadingChildren?: string }) {
|
||||
const [localIsLoading, setLocalIsLoading] = useState<boolean>(false);
|
||||
return (
|
||||
<Button
|
||||
isLoading={localIsLoading || isLoading}
|
||||
onClick={(e) => {
|
||||
setLocalIsLoading(true);
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{localIsLoading ? (loadingChildren ?? children) : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
70
apps/yaak-client/components/core/Checkbox.tsx
Normal file
70
apps/yaak-client/components/core/Checkbox.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
import { IconTooltip } from './IconTooltip';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
export interface CheckboxProps {
|
||||
checked: boolean | 'indeterminate';
|
||||
title: ReactNode;
|
||||
onChange: (checked: boolean) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
inputWrapperClassName?: string;
|
||||
hideLabel?: boolean;
|
||||
fullWidth?: boolean;
|
||||
help?: ReactNode;
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
className,
|
||||
inputWrapperClassName,
|
||||
disabled,
|
||||
title,
|
||||
hideLabel,
|
||||
fullWidth,
|
||||
help,
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<HStack
|
||||
as="label"
|
||||
alignItems="center"
|
||||
space={2}
|
||||
className={classNames(className, 'text-text mr-auto')}
|
||||
>
|
||||
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex mr-0.5')}>
|
||||
<input
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
'appearance-none w-4 h-4 flex-shrink-0 border border-border',
|
||||
'rounded outline-none ring-0',
|
||||
!disabled && 'hocus:border-border-focus hocus:bg-focus/[5%]',
|
||||
disabled && 'border-dotted',
|
||||
)}
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
onChange(checked === 'indeterminate' ? true : !checked);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Icon
|
||||
size="sm"
|
||||
className={classNames(disabled && 'opacity-disabled')}
|
||||
icon={checked === 'indeterminate' ? 'minus' : checked ? 'check' : 'empty'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!hideLabel && (
|
||||
<div
|
||||
className={classNames('text-sm', fullWidth && 'w-full', disabled && 'opacity-disabled')}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{help && <IconTooltip content={help} />}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
117
apps/yaak-client/components/core/ColorPicker.tsx
Normal file
117
apps/yaak-client/components/core/ColorPicker.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import classNames from 'classnames';
|
||||
import { useState } from 'react';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { useRandomKey } from '../../hooks/useRandomKey';
|
||||
import { Icon } from './Icon';
|
||||
import { PlainInput } from './PlainInput';
|
||||
|
||||
interface Props {
|
||||
onChange: (value: string | null) => void;
|
||||
color: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColorPicker({ onChange, color, className }: Props) {
|
||||
const [updateKey, regenerateKey] = useRandomKey();
|
||||
return (
|
||||
<div className={className}>
|
||||
<HexColorPicker
|
||||
color={color ?? undefined}
|
||||
className="!w-full"
|
||||
onChange={(color) => {
|
||||
onChange(color);
|
||||
regenerateKey(); // To force input to change
|
||||
}}
|
||||
/>
|
||||
<PlainInput
|
||||
hideLabel
|
||||
label="Plain Color"
|
||||
forceUpdateKey={updateKey}
|
||||
defaultValue={color ?? ''}
|
||||
onChange={onChange}
|
||||
validate={(color) => color.match(/#[0-9a-fA-F]{6}$/) !== null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const colors = [
|
||||
null,
|
||||
'danger',
|
||||
'warning',
|
||||
'notice',
|
||||
'success',
|
||||
'primary',
|
||||
'info',
|
||||
'secondary',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
export function ColorPickerWithThemeColors({ onChange, color, className }: Props) {
|
||||
const [updateKey, regenerateKey] = useRandomKey();
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(() => {
|
||||
if (color == null) return null;
|
||||
const c = color?.match(/var\(--([a-z]+)\)/)?.[1];
|
||||
return c ?? 'custom';
|
||||
});
|
||||
return (
|
||||
<div className={classNames(className, 'flex flex-col gap-3')}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
type="button"
|
||||
key={color}
|
||||
onClick={() => {
|
||||
setSelectedColor(color);
|
||||
if (color == null) {
|
||||
onChange(null);
|
||||
} else if (color === 'custom') {
|
||||
onChange('#ffffff');
|
||||
} else {
|
||||
onChange(`var(--${color})`);
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
'flex items-center justify-center',
|
||||
'w-8 h-8 rounded-full transition-all',
|
||||
selectedColor === color && 'scale-[1.15]',
|
||||
selectedColor === color ? 'opacity-100' : 'opacity-60',
|
||||
color === null && 'border border-text-subtle',
|
||||
color === 'primary' && 'bg-primary',
|
||||
color === 'secondary' && 'bg-secondary',
|
||||
color === 'success' && 'bg-success',
|
||||
color === 'notice' && 'bg-notice',
|
||||
color === 'warning' && 'bg-warning',
|
||||
color === 'danger' && 'bg-danger',
|
||||
color === 'info' && 'bg-info',
|
||||
color === 'custom' &&
|
||||
'bg-[conic-gradient(var(--danger),var(--warning),var(--notice),var(--success),var(--info),var(--primary),var(--danger))]',
|
||||
)}
|
||||
>
|
||||
{color == null && <Icon icon="minus" className="text-text-subtle" size="md" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedColor === 'custom' && (
|
||||
<>
|
||||
<HexColorPicker
|
||||
color={color ?? undefined}
|
||||
className="!w-full"
|
||||
onChange={(color) => {
|
||||
onChange(color);
|
||||
regenerateKey(); // To force input to change
|
||||
}}
|
||||
/>
|
||||
<PlainInput
|
||||
hideLabel
|
||||
label="Plain Color"
|
||||
forceUpdateKey={updateKey}
|
||||
defaultValue={color ?? ''}
|
||||
onChange={onChange}
|
||||
validate={(color) => color.match(/#[0-9a-fA-F]{6}$/) !== null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
apps/yaak-client/components/core/Confirm.tsx
Normal file
74
apps/yaak-client/components/core/Confirm.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Color } from '@yaakapp-internal/plugins';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { CopyIconButton } from '../CopyIconButton';
|
||||
import { Button } from './Button';
|
||||
import { PlainInput } from './PlainInput';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
export interface ConfirmProps {
|
||||
onHide: () => void;
|
||||
onResult: (result: boolean) => void;
|
||||
confirmText?: string;
|
||||
requireTyping?: string;
|
||||
color?: Color;
|
||||
}
|
||||
|
||||
export function Confirm({
|
||||
onHide,
|
||||
onResult,
|
||||
confirmText,
|
||||
requireTyping,
|
||||
color = 'primary',
|
||||
}: ConfirmProps) {
|
||||
const [confirm, setConfirm] = useState<string>('');
|
||||
const handleHide = () => {
|
||||
onResult(false);
|
||||
onHide();
|
||||
};
|
||||
|
||||
const didConfirm = !requireTyping || confirm === requireTyping;
|
||||
|
||||
const handleSuccess = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (didConfirm) {
|
||||
onResult(true);
|
||||
onHide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col" onSubmit={handleSuccess}>
|
||||
{requireTyping && (
|
||||
<PlainInput
|
||||
autoFocus
|
||||
onChange={setConfirm}
|
||||
placeholder={requireTyping}
|
||||
labelRightSlot={
|
||||
<CopyIconButton
|
||||
tabIndex={-1}
|
||||
text={requireTyping}
|
||||
title="Copy name"
|
||||
className="text-text-subtlest"
|
||||
iconSize="sm"
|
||||
size="2xs"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<>
|
||||
Type <strong>{requireTyping}</strong> to confirm
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
|
||||
<Button type="submit" color={color} disabled={!didConfirm}>
|
||||
{confirmText ?? 'Confirm'}
|
||||
</Button>
|
||||
<Button onClick={handleHide} variant="border">
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
48
apps/yaak-client/components/core/CountBadge.tsx
Normal file
48
apps/yaak-client/components/core/CountBadge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Color } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
count: number | true;
|
||||
count2?: number | true;
|
||||
className?: string;
|
||||
color?: Color;
|
||||
showZero?: boolean;
|
||||
}
|
||||
|
||||
export function CountBadge({ count, count2, className, color, showZero }: Props) {
|
||||
if (count === 0 && !showZero) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
className,
|
||||
'flex items-center',
|
||||
'opacity-70 border text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
|
||||
color == null && 'border-border-subtle',
|
||||
color === 'primary' && 'text-primary',
|
||||
color === 'secondary' && 'text-secondary',
|
||||
color === 'success' && 'text-success',
|
||||
color === 'notice' && 'text-notice',
|
||||
color === 'warning' && 'text-warning',
|
||||
color === 'danger' && 'text-danger',
|
||||
)}
|
||||
>
|
||||
{count === true ? (
|
||||
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
|
||||
) : (
|
||||
count
|
||||
)}
|
||||
{count2 != null && (
|
||||
<>
|
||||
/
|
||||
{count2 === true ? (
|
||||
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
|
||||
) : (
|
||||
count2
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
apps/yaak-client/components/core/DetailsBanner.tsx
Normal file
60
apps/yaak-client/components/core/DetailsBanner.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import classNames from 'classnames';
|
||||
import { atom, useAtom } from 'jotai';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { atomWithKVStorage } from '../../lib/atoms/atomWithKVStorage';
|
||||
import type { BannerProps } from './Banner';
|
||||
import { Banner } from './Banner';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDetailsElement> {
|
||||
summary: ReactNode;
|
||||
color?: BannerProps['color'];
|
||||
defaultOpen?: boolean;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function DetailsBanner({
|
||||
className,
|
||||
color,
|
||||
summary,
|
||||
children,
|
||||
defaultOpen,
|
||||
storageKey,
|
||||
...extraProps
|
||||
}: Props) {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to recompute the atom when storageKey changes
|
||||
const openAtom = useMemo(
|
||||
() =>
|
||||
storageKey
|
||||
? atomWithKVStorage<boolean>(['details_banner', storageKey], defaultOpen ?? false)
|
||||
: atom(defaultOpen ?? false),
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(openAtom);
|
||||
|
||||
const handleToggle = (e: React.SyntheticEvent<HTMLDetailsElement>) => {
|
||||
if (storageKey) {
|
||||
setIsOpen(e.currentTarget.open);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Banner color={color} className={className}>
|
||||
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
|
||||
<summary className="!cursor-default !select-none list-none flex items-center gap-3 focus:outline-none opacity-70">
|
||||
<div
|
||||
className={classNames(
|
||||
'transition-transform',
|
||||
'group-open:rotate-90',
|
||||
'w-0 h-0 border-t-[0.3em] border-b-[0.3em] border-l-[0.5em] border-r-0',
|
||||
'border-t-transparent border-b-transparent border-l-text-subtle',
|
||||
)}
|
||||
/>
|
||||
{summary}
|
||||
</summary>
|
||||
<div className="mt-1.5 pb-2">{children}</div>
|
||||
</details>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
130
apps/yaak-client/components/core/Dialog.tsx
Normal file
130
apps/yaak-client/components/core/Dialog.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import classNames from 'classnames';
|
||||
import * as m from 'motion/react-m';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Overlay } from '../Overlay';
|
||||
import { Heading } from './Heading';
|
||||
import { IconButton } from './IconButton';
|
||||
import { DialogSize } from '@yaakapp-internal/plugins';
|
||||
|
||||
export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
disableBackdropClose?: boolean;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
size?: DialogSize;
|
||||
hideX?: boolean;
|
||||
noPadding?: boolean;
|
||||
noScroll?: boolean;
|
||||
vAlign?: 'top' | 'center';
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
children,
|
||||
className,
|
||||
size = 'full',
|
||||
open,
|
||||
onClose,
|
||||
disableBackdropClose,
|
||||
title,
|
||||
description,
|
||||
hideX,
|
||||
noPadding,
|
||||
noScroll,
|
||||
vAlign = 'center',
|
||||
}: DialogProps) {
|
||||
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||
const descriptionId = useMemo(
|
||||
() => (description ? Math.random().toString(36).slice(2) : undefined),
|
||||
[description],
|
||||
);
|
||||
|
||||
return (
|
||||
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
|
||||
<div
|
||||
role="dialog"
|
||||
className={classNames(
|
||||
'py-4 x-theme-dialog absolute inset-0 pointer-events-none',
|
||||
'h-full flex flex-col items-center justify-center',
|
||||
vAlign === 'top' && 'justify-start',
|
||||
vAlign === 'center' && 'justify-center',
|
||||
)}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(e) => {
|
||||
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
|
||||
// dialogs and can be intercepted by children if needed.
|
||||
if (e.key === 'Escape') {
|
||||
onClose?.();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<m.div
|
||||
initial={{ top: 5, scale: 0.97 }}
|
||||
animate={{ top: 0, scale: 1 }}
|
||||
className={classNames(
|
||||
className,
|
||||
'grid grid-rows-[auto_auto_minmax(0,1fr)]',
|
||||
'grid-cols-1', // must be here for inline code blocks to correctly break words
|
||||
'relative bg-surface pointer-events-auto',
|
||||
'rounded-lg',
|
||||
'border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]',
|
||||
'min-h-[10rem]',
|
||||
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]',
|
||||
size === 'sm' && 'w-[30rem]',
|
||||
size === 'md' && 'w-[50rem]',
|
||||
size === 'lg' && 'w-[70rem]',
|
||||
size === 'full' && 'w-[100vw] h-[100vh]',
|
||||
size === 'dynamic' && 'min-w-[20rem] max-w-[100vw]',
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<Heading className="px-6 mt-4 mb-2" level={1} id={titleId}>
|
||||
{title}
|
||||
</Heading>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
{description ? (
|
||||
<div className="min-h-0 px-6 text-text-subtle mb-3" id={descriptionId}>
|
||||
{description}
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'h-full w-full grid grid-cols-[minmax(0,1fr)] grid-rows-1',
|
||||
!noPadding && 'px-6 py-2',
|
||||
!noScroll && 'overflow-y-auto overflow-x-hidden',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!hideX && (
|
||||
<div className="ml-auto absolute right-1 top-1">
|
||||
<IconButton
|
||||
className="opacity-70 hover:opacity-100"
|
||||
onClick={onClose}
|
||||
title="Close dialog (Esc)"
|
||||
aria-label="Close"
|
||||
size="sm"
|
||||
icon="x"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
58
apps/yaak-client/components/core/DismissibleBanner.tsx
Normal file
58
apps/yaak-client/components/core/DismissibleBanner.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Color } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import { useKeyValue } from '../../hooks/useKeyValue';
|
||||
import type { BannerProps } from './Banner';
|
||||
import { Banner } from './Banner';
|
||||
import { Button } from './Button';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
export function DismissibleBanner({
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
actions,
|
||||
...props
|
||||
}: BannerProps & {
|
||||
id: string;
|
||||
actions?: { label: string; onClick: () => void; color?: Color }[];
|
||||
}) {
|
||||
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
|
||||
namespace: 'global',
|
||||
key: ['dismiss-banner', id],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<Banner
|
||||
className={classNames(className, 'relative grid grid-cols-[1fr_auto] gap-3')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<HStack space={1.5}>
|
||||
{actions?.map((a) => (
|
||||
<Button
|
||||
key={a.label}
|
||||
variant="border"
|
||||
color={a.color ?? props.color}
|
||||
size="xs"
|
||||
onClick={a.onClick}
|
||||
title={a.label}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="border"
|
||||
color={props.color}
|
||||
size="xs"
|
||||
onClick={() => setDismissed((d) => !d)}
|
||||
title="Dismiss message"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
978
apps/yaak-client/components/core/Dropdown.tsx
Normal file
978
apps/yaak-client/components/core/Dropdown.tsx
Normal file
@@ -0,0 +1,978 @@
|
||||
import classNames from 'classnames';
|
||||
import { atom } from 'jotai';
|
||||
import * as m from 'motion/react-m';
|
||||
import type {
|
||||
CSSProperties,
|
||||
HTMLAttributes,
|
||||
MouseEvent,
|
||||
ReactElement,
|
||||
FocusEvent as ReactFocusEvent,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useKey, useWindowSize } from 'react-use';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||
import { useHotKey } from '../../hooks/useHotKey';
|
||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import { getNodeText } from '../../lib/getNodeText';
|
||||
import { jotaiStore } from '../../lib/jotai';
|
||||
import { ErrorBoundary } from '../ErrorBoundary';
|
||||
import { Overlay } from '../Overlay';
|
||||
import { Button } from './Button';
|
||||
import { Hotkey } from './Hotkey';
|
||||
import { Icon, type IconProps } from './Icon';
|
||||
import { LoadingIcon } from './LoadingIcon';
|
||||
import { Separator } from './Separator';
|
||||
import { HStack, VStack } from './Stacks';
|
||||
|
||||
export type DropdownItemSeparator = {
|
||||
type: 'separator';
|
||||
label?: ReactNode;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export type DropdownItemContent = {
|
||||
type: 'content';
|
||||
label?: ReactNode;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export type DropdownItemDefault = {
|
||||
type?: 'default';
|
||||
label: ReactNode;
|
||||
hotKeyAction?: HotkeyAction;
|
||||
hotKeyLabelOnly?: boolean;
|
||||
color?: 'default' | 'primary' | 'danger' | 'info' | 'warning' | 'notice' | 'success';
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
waitForOnSelect?: boolean;
|
||||
keepOpenOnSelect?: boolean;
|
||||
onSelect?: () => void | Promise<void>;
|
||||
submenu?: DropdownItem[];
|
||||
/** If true, submenu opens on click instead of hover */
|
||||
submenuOpenOnClick?: boolean;
|
||||
icon?: IconProps['icon'];
|
||||
};
|
||||
|
||||
export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent;
|
||||
|
||||
export interface DropdownProps {
|
||||
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
|
||||
items: DropdownItem[];
|
||||
fullWidth?: boolean;
|
||||
hotKeyAction?: HotkeyAction;
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
export interface DropdownRef {
|
||||
isOpen: boolean;
|
||||
open: (index?: number) => void;
|
||||
toggle: () => void;
|
||||
close?: () => void;
|
||||
next?: (incrBy?: number) => void;
|
||||
prev?: (incrBy?: number) => void;
|
||||
select?: () => void;
|
||||
}
|
||||
|
||||
// Every dropdown gets a unique ID and we use this global atom to ensure
|
||||
// only one dropdown can be open at a time.
|
||||
// TODO: Also make ContextMenu use this
|
||||
const openAtom = atom<string | null>(null);
|
||||
|
||||
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
|
||||
{ children, items, hotKeyAction, fullWidth, onOpen }: DropdownProps,
|
||||
ref,
|
||||
) {
|
||||
const id = useRef(generateId());
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
return jotaiStore.sub(openAtom, () => {
|
||||
const globalOpenId = jotaiStore.get(openAtom);
|
||||
const newIsOpen = globalOpenId === id.current;
|
||||
if (newIsOpen !== isOpen) {
|
||||
setIsOpen(newIsOpen);
|
||||
}
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
// const [isOpen, _setIsOpen] = useState<boolean>(false);
|
||||
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
|
||||
|
||||
const handleSetIsOpen = useCallback(
|
||||
(o: SetStateAction<boolean>) => {
|
||||
jotaiStore.set(openAtom, (prevId) => {
|
||||
const prevIsOpen = prevId === id.current;
|
||||
const newIsOpen = typeof o === 'function' ? o(prevIsOpen) : o;
|
||||
// Persist background color of button until we close the dropdown
|
||||
if (newIsOpen) {
|
||||
onOpen?.();
|
||||
if (buttonRef.current) {
|
||||
buttonRef.current.style.backgroundColor = window
|
||||
.getComputedStyle(buttonRef.current)
|
||||
.getPropertyValue('background-color');
|
||||
}
|
||||
}
|
||||
return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
|
||||
});
|
||||
},
|
||||
[onOpen],
|
||||
);
|
||||
|
||||
// Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method
|
||||
// we have of detecting the dropdown closed, to do cleanup.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Clear persisted BG
|
||||
if (buttonRef.current) buttonRef.current.style.backgroundColor = '';
|
||||
// Set to different value when opened and closed to force it to update. This is to force
|
||||
// <Menu/> to reset its selected-index state, which it does when this prop changes
|
||||
setDefaultSelectedIndex(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Pull into variable so linter forces us to add it as a hook dep to useImperativeHandle. If we don't,
|
||||
// the ref will not update when menuRef updates, causing stale callback state to be used.
|
||||
const menuRefCurrent = menuRef.current;
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
...menuRefCurrent,
|
||||
isOpen: isOpen,
|
||||
toggle() {
|
||||
if (!isOpen) this.open();
|
||||
else this.close();
|
||||
},
|
||||
open(index?: number) {
|
||||
handleSetIsOpen(true);
|
||||
setDefaultSelectedIndex(index ?? -1);
|
||||
},
|
||||
close() {
|
||||
handleSetIsOpen(false);
|
||||
},
|
||||
}),
|
||||
[isOpen, handleSetIsOpen, menuRefCurrent],
|
||||
);
|
||||
|
||||
useHotKey(hotKeyAction ?? null, () => {
|
||||
setDefaultSelectedIndex(0);
|
||||
handleSetIsOpen(true);
|
||||
});
|
||||
|
||||
const child = useMemo(() => {
|
||||
const existingChild = Children.only(children);
|
||||
const originalOnClick = existingChild.props?.onClick;
|
||||
const props: HTMLAttributes<HTMLButtonElement> & { ref: RefObject<HTMLButtonElement | null> } =
|
||||
{
|
||||
...existingChild.props,
|
||||
ref: buttonRef,
|
||||
'aria-haspopup': 'true',
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => {
|
||||
// Call original onClick first if it exists
|
||||
originalOnClick?.(e);
|
||||
|
||||
// Only toggle dropdown if event wasn't prevented
|
||||
if (!e.defaultPrevented) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSetIsOpen((o) => !o); // Toggle dropdown
|
||||
}
|
||||
},
|
||||
};
|
||||
return cloneElement(existingChild, props);
|
||||
}, [children, handleSetIsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
buttonRef.current?.setAttribute('aria-expanded', isOpen.toString());
|
||||
}, [isOpen]);
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
const triggerRect = useMemo(() => {
|
||||
if (!windowSize) return null; // No-op to TS happy with this dep
|
||||
if (!isOpen) return null;
|
||||
return buttonRef.current?.getBoundingClientRect();
|
||||
}, [isOpen, windowSize]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{child}
|
||||
<ErrorBoundary name={'Dropdown Menu'}>
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
showTriangle
|
||||
triggerRef={buttonRef}
|
||||
fullWidth={fullWidth}
|
||||
defaultSelectedIndex={defaultSelectedIndex}
|
||||
items={items}
|
||||
triggerShape={triggerRect ?? null}
|
||||
onClose={() => handleSetIsOpen(false)}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export interface ContextMenuProps {
|
||||
triggerPosition: { x: number; y: number } | null;
|
||||
className?: string;
|
||||
items: DropdownProps['items'];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
|
||||
{ triggerPosition, className, items, onClose },
|
||||
ref,
|
||||
) {
|
||||
const triggerShape = useMemo(
|
||||
() => ({
|
||||
top: triggerPosition?.y ?? 0,
|
||||
bottom: triggerPosition?.y ?? 0,
|
||||
left: triggerPosition?.x ?? 0,
|
||||
right: triggerPosition?.x ?? 0,
|
||||
}),
|
||||
[triggerPosition],
|
||||
);
|
||||
|
||||
if (triggerPosition == null) return null;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
isOpen={true} // Always open because we return null if not
|
||||
className={className}
|
||||
defaultSelectedIndex={null}
|
||||
ref={ref}
|
||||
items={items}
|
||||
onClose={onClose}
|
||||
triggerShape={triggerShape}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface MenuProps {
|
||||
className?: string;
|
||||
defaultSelectedIndex: number | null;
|
||||
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
|
||||
onClose: () => void;
|
||||
onCloseAll?: () => void;
|
||||
showTriangle?: boolean;
|
||||
fullWidth?: boolean;
|
||||
isOpen: boolean;
|
||||
items: DropdownItem[];
|
||||
triggerRef?: RefObject<HTMLButtonElement | null>;
|
||||
isSubmenu?: boolean;
|
||||
}
|
||||
|
||||
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'>, MenuProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
isOpen,
|
||||
items,
|
||||
fullWidth,
|
||||
onClose,
|
||||
onCloseAll,
|
||||
triggerShape,
|
||||
defaultSelectedIndex,
|
||||
showTriangle,
|
||||
triggerRef,
|
||||
isSubmenu,
|
||||
}: MenuProps,
|
||||
ref,
|
||||
) => {
|
||||
const [selectedIndex, setSelectedIndex] = useStateWithDeps<number | null>(
|
||||
defaultSelectedIndex ?? -1,
|
||||
[defaultSelectedIndex],
|
||||
);
|
||||
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
|
||||
// Clear filter when menu opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFilter('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<{
|
||||
item: DropdownItemDefault;
|
||||
parent: HTMLButtonElement;
|
||||
viaKeyboard?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const mousePosition = useRef({ x: 0, y: 0 });
|
||||
const submenuTimeoutRef = useRef<number | null>(null);
|
||||
const submenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// HACK: Use a ref to track selectedIndex so our closure functions (eg. select()) can
|
||||
// have access to the latest value.
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
useEffect(() => {
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
}, [selectedIndex]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
setActiveSubmenu(null);
|
||||
}, [onClose]);
|
||||
|
||||
// Close the entire menu hierarchy (used when selecting an item)
|
||||
const handleCloseAll = useCallback(() => {
|
||||
if (onCloseAll) {
|
||||
onCloseAll();
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}, [onCloseAll, handleClose]);
|
||||
|
||||
// Handle type-ahead filtering (only for the deepest open menu)
|
||||
const handleMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
// Skip if this menu has a submenu open - let the submenu handle typing
|
||||
if (activeSubmenu) return;
|
||||
|
||||
const isCharacter = e.key.length === 1;
|
||||
const isSpecial = e.ctrlKey || e.metaKey || e.altKey;
|
||||
if (isCharacter && !isSpecial) {
|
||||
e.preventDefault();
|
||||
setFilter((f) => f + e.key);
|
||||
setSelectedIndex(0);
|
||||
} else if (e.key === 'Backspace' && !isSpecial) {
|
||||
e.preventDefault();
|
||||
setFilter((f) => f.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
useKey(
|
||||
'Escape',
|
||||
() => {
|
||||
if (!isOpen) return;
|
||||
if (activeSubmenu) setActiveSubmenu(null);
|
||||
else if (filter !== '') setFilter('');
|
||||
else handleClose();
|
||||
},
|
||||
{},
|
||||
[isOpen, filter, setFilter, handleClose, activeSubmenu],
|
||||
);
|
||||
|
||||
const handlePrev = useCallback(
|
||||
(incrBy = 1) => {
|
||||
setSelectedIndex((currIndex) => {
|
||||
let nextIndex = (currIndex ?? 0) - incrBy;
|
||||
const maxTries = items.length;
|
||||
for (let i = 0; i < maxTries; i++) {
|
||||
if (items[nextIndex]?.hidden || items[nextIndex]?.type === 'separator') {
|
||||
nextIndex--;
|
||||
} else if (nextIndex < 0) {
|
||||
nextIndex = items.length - 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
},
|
||||
[items, setSelectedIndex],
|
||||
);
|
||||
|
||||
const handleNext = useCallback(
|
||||
(incrBy = 1) => {
|
||||
setSelectedIndex((currIndex) => {
|
||||
let nextIndex = (currIndex ?? -1) + incrBy;
|
||||
const maxTries = items.length;
|
||||
for (let i = 0; i < maxTries; i++) {
|
||||
if (items[nextIndex]?.hidden || items[nextIndex]?.type === 'separator') {
|
||||
nextIndex++;
|
||||
} else if (nextIndex >= items.length) {
|
||||
nextIndex = 0;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
},
|
||||
[items, setSelectedIndex],
|
||||
);
|
||||
|
||||
// Ensure selection is on a valid item (not hidden/separator/content)
|
||||
useEffect(() => {
|
||||
const item = items[selectedIndex ?? -1];
|
||||
if (item?.hidden || item?.type === 'separator' || item?.type === 'content') {
|
||||
handleNext();
|
||||
}
|
||||
}, [selectedIndex, items, handleNext]);
|
||||
|
||||
useKey(
|
||||
'ArrowUp',
|
||||
(e) => {
|
||||
if (!isOpen || activeSubmenu) return;
|
||||
e.preventDefault();
|
||||
handlePrev();
|
||||
},
|
||||
{},
|
||||
[isOpen, activeSubmenu],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowDown',
|
||||
(e) => {
|
||||
if (!isOpen || activeSubmenu) return;
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
},
|
||||
{},
|
||||
[isOpen, activeSubmenu],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowLeft',
|
||||
(e) => {
|
||||
if (!isOpen) return;
|
||||
// Only handle if this menu doesn't have an open submenu
|
||||
// (let the deepest submenu handle the key first)
|
||||
if (activeSubmenu) return;
|
||||
// If this is a submenu, ArrowLeft closes it and returns to parent
|
||||
if (isSubmenu) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{},
|
||||
[isOpen, isSubmenu, activeSubmenu, onClose],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: DropdownItem, parentEl?: HTMLButtonElement) => {
|
||||
// Handle click-to-open submenu
|
||||
if ('submenu' in item && item.submenu && item.submenuOpenOnClick && parentEl) {
|
||||
setActiveSubmenu({ item, parent: parentEl });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('onSelect' in item) || !item.onSelect) return;
|
||||
setSelectedIndex(null);
|
||||
|
||||
const promise = item.onSelect();
|
||||
if (item.waitForOnSelect) {
|
||||
try {
|
||||
await promise;
|
||||
} catch {
|
||||
// Nothing
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.keepOpenOnSelect) handleCloseAll();
|
||||
},
|
||||
[handleCloseAll, setSelectedIndex],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
close: handleClose,
|
||||
prev: handlePrev,
|
||||
next: handleNext,
|
||||
select: async () => {
|
||||
const item = items[selectedIndexRef.current ?? -1] ?? null;
|
||||
if (!item) return;
|
||||
await handleSelect(item);
|
||||
},
|
||||
};
|
||||
}, [handleClose, handleNext, handlePrev, handleSelect, items]);
|
||||
|
||||
const styles = useMemo<{
|
||||
container: CSSProperties;
|
||||
menu: CSSProperties;
|
||||
triangle: CSSProperties;
|
||||
upsideDown: boolean;
|
||||
}>(() => {
|
||||
if (triggerShape == null) return { container: {}, triangle: {}, menu: {}, upsideDown: false };
|
||||
|
||||
if (isSubmenu) {
|
||||
const parentRect = triggerShape;
|
||||
const docRect = document.documentElement.getBoundingClientRect();
|
||||
const spaceRight = docRect.width - parentRect.right;
|
||||
const spaceBelow = docRect.height - parentRect.top;
|
||||
const spaceAbove = parentRect.bottom;
|
||||
const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right
|
||||
// Estimate submenu height (items * ~28px + padding), flip if not enough space below
|
||||
const estimatedHeight = items.length * 28 + 20;
|
||||
const openUpward = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
|
||||
|
||||
return {
|
||||
upsideDown: openUpward,
|
||||
container: {
|
||||
top: openUpward ? undefined : parentRect.top,
|
||||
bottom: openUpward ? docRect.height - parentRect.bottom : undefined,
|
||||
left: openLeft ? undefined : parentRect.right,
|
||||
right: openLeft ? docRect.width - parentRect.left : undefined,
|
||||
},
|
||||
menu: {
|
||||
maxHeight: `${(openUpward ? spaceAbove : spaceBelow) - 20}px`,
|
||||
},
|
||||
triangle: {}, // No triangle for submenus
|
||||
};
|
||||
}
|
||||
|
||||
const menuMarginY = 5;
|
||||
const docRect = document.documentElement.getBoundingClientRect();
|
||||
const width = triggerShape.right - triggerShape.left;
|
||||
const heightAbove = triggerShape.top;
|
||||
const heightBelow = docRect.height - triggerShape.bottom;
|
||||
const horizontalSpaceRemaining = docRect.width - triggerShape.left;
|
||||
const top = triggerShape.bottom;
|
||||
const onRight = horizontalSpaceRemaining < 300;
|
||||
const upsideDown = heightBelow < heightAbove && heightBelow < items.length * 25 + 20 + 200;
|
||||
const triggerWidth = triggerShape.right - triggerShape.left;
|
||||
return {
|
||||
upsideDown,
|
||||
container: {
|
||||
top: !upsideDown ? top + menuMarginY : undefined,
|
||||
bottom: upsideDown
|
||||
? docRect.height - top - (triggerShape.top - triggerShape.bottom) + menuMarginY
|
||||
: undefined,
|
||||
right: onRight ? docRect.width - triggerShape.right : undefined,
|
||||
left: !onRight ? triggerShape.left : undefined,
|
||||
minWidth: fullWidth ? triggerWidth : undefined,
|
||||
maxWidth: '40rem',
|
||||
},
|
||||
triangle: {
|
||||
width: '0.4rem',
|
||||
height: '0.4rem',
|
||||
...(onRight
|
||||
? { right: width / 2, marginRight: '-0.2rem' }
|
||||
: { left: width / 2, marginLeft: '-0.2rem' }),
|
||||
...(upsideDown
|
||||
? { bottom: '-0.2rem', rotate: '225deg' }
|
||||
: { top: '-0.2rem', rotate: '45deg' }),
|
||||
},
|
||||
menu: {
|
||||
maxHeight: `${(upsideDown ? heightAbove : heightBelow) - 15}px`,
|
||||
},
|
||||
};
|
||||
}, [fullWidth, items.length, triggerShape, isSubmenu]);
|
||||
|
||||
const filteredItems = useMemo(
|
||||
() => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())),
|
||||
[items, filter],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(i: DropdownItem) => {
|
||||
const index = filteredItems.indexOf(i) ?? null;
|
||||
setSelectedIndex(index);
|
||||
},
|
||||
[filteredItems, setSelectedIndex],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowRight',
|
||||
(e) => {
|
||||
if (!isOpen || activeSubmenu) return;
|
||||
const item = filteredItems[selectedIndex ?? -1];
|
||||
if (item?.type !== 'separator' && item?.type !== 'content' && item?.submenu) {
|
||||
e.preventDefault();
|
||||
const parent = document.activeElement as HTMLButtonElement;
|
||||
if (parent) {
|
||||
setActiveSubmenu({ item, parent, viaKeyboard: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
{},
|
||||
[isOpen, activeSubmenu, filteredItems, selectedIndex],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'Enter',
|
||||
(e) => {
|
||||
if (!isOpen || activeSubmenu) return;
|
||||
const item = filteredItems[selectedIndex ?? -1];
|
||||
if (!item || item.type === 'separator' || item.type === 'content') return;
|
||||
e.preventDefault();
|
||||
if (item.submenu) {
|
||||
const parent = document.activeElement as HTMLButtonElement;
|
||||
if (parent) {
|
||||
setActiveSubmenu({ item, parent, viaKeyboard: true });
|
||||
}
|
||||
} else if (item.onSelect) {
|
||||
handleSelect(item);
|
||||
}
|
||||
},
|
||||
{},
|
||||
[isOpen, activeSubmenu, filteredItems, selectedIndex, handleSelect],
|
||||
);
|
||||
|
||||
const handleItemHover = useCallback(
|
||||
(item: DropdownItemDefault, parent: HTMLButtonElement) => {
|
||||
if (submenuTimeoutRef.current) {
|
||||
clearTimeout(submenuTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (item.submenu && !item.submenuOpenOnClick) {
|
||||
setActiveSubmenu({ item, parent });
|
||||
} else if (activeSubmenu) {
|
||||
submenuTimeoutRef.current = window.setTimeout(() => {
|
||||
const submenuEl = submenuRef.current;
|
||||
if (!submenuEl || !activeSubmenu) {
|
||||
setActiveSubmenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { parent } = activeSubmenu;
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
const submenuRect = submenuEl.getBoundingClientRect();
|
||||
const mouse = mousePosition.current;
|
||||
|
||||
if (
|
||||
mouse.x >= submenuRect.left &&
|
||||
mouse.x <= submenuRect.right &&
|
||||
mouse.y >= submenuRect.top &&
|
||||
mouse.y <= submenuRect.bottom
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tolerance = 5;
|
||||
const p1 = { x: parentRect.right, y: parentRect.top - tolerance };
|
||||
const p2 = { x: parentRect.right, y: parentRect.bottom + tolerance };
|
||||
const p3 = { x: submenuRect.left, y: submenuRect.top - tolerance };
|
||||
const p4 = { x: submenuRect.left, y: submenuRect.bottom + tolerance };
|
||||
|
||||
const inTriangle =
|
||||
isPointInTriangle(mouse, p1, p2, p4) || isPointInTriangle(mouse, p1, p3, p4);
|
||||
|
||||
if (!inTriangle) {
|
||||
setActiveSubmenu(null);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[activeSubmenu],
|
||||
);
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
useClickOutside(menuRef, handleClose, triggerRef);
|
||||
|
||||
// Keep focus on menu container when filtering leaves no items
|
||||
useEffect(() => {
|
||||
if (filteredItems.length === 0 && filter && menuRef.current) {
|
||||
menuRef.current.focus();
|
||||
}
|
||||
}, [filteredItems.length, filter]);
|
||||
|
||||
const submenuTriggerShape = useMemo(() => {
|
||||
if (!activeSubmenu) return null;
|
||||
const rect = activeSubmenu.parent.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
};
|
||||
}, [activeSubmenu]);
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent) => {
|
||||
mousePosition.current = { x: event.clientX, y: event.clientY };
|
||||
};
|
||||
|
||||
const menuContent = (
|
||||
<m.div
|
||||
ref={menuRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onContextMenu={(e) => {
|
||||
// Prevent showing any ancestor context menus
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
initial={{ opacity: 0, y: (styles.upsideDown ? 1 : -1) * 5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
style={styles.container}
|
||||
className={classNames(
|
||||
className,
|
||||
'x-theme-menu',
|
||||
'outline-none my-1 pointer-events-auto z-40',
|
||||
'fixed',
|
||||
)}
|
||||
>
|
||||
{showTriangle && !isSubmenu && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={styles.triangle}
|
||||
className="bg-surface absolute border-border-subtle border-t border-l"
|
||||
/>
|
||||
)}
|
||||
<VStack
|
||||
style={styles.menu}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-auto bg-surface rounded-md shadow-lg py-1.5 border',
|
||||
'border-border-subtle overflow-y-auto overflow-x-hidden mx-0.5',
|
||||
)}
|
||||
>
|
||||
{filter && (
|
||||
<HStack
|
||||
space={2}
|
||||
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
|
||||
>
|
||||
<Icon icon="search" size="xs" />
|
||||
<div className="text">{filter}</div>
|
||||
</HStack>
|
||||
)}
|
||||
{filteredItems.length === 0 && (
|
||||
<span className="text-text-subtlest text-center px-2 py-1">No matches</span>
|
||||
)}
|
||||
{filteredItems.map((item, i) => {
|
||||
if (item.hidden) {
|
||||
return null;
|
||||
}
|
||||
if (item.type === 'separator') {
|
||||
return (
|
||||
<Separator
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else available
|
||||
key={i}
|
||||
className={classNames('my-1.5', item.label ? 'ml-2' : null)}
|
||||
>
|
||||
{item.label}
|
||||
</Separator>
|
||||
);
|
||||
}
|
||||
if (item.type === 'content') {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Needs to be clickable but want to support nested buttons
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine
|
||||
<div key={i} className={classNames('my-1 mx-2 max-w-xs')} onClick={onClose}>
|
||||
{item.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isParentOfActiveSubmenu = activeSubmenu?.item === item;
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
isParentOfActiveSubmenu={isParentOfActiveSubmenu}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
onHover={handleItemHover}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: It's fine
|
||||
key={i}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
{activeSubmenu && (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Container div that cancels hover timeout
|
||||
<div
|
||||
ref={submenuRef}
|
||||
onMouseEnter={() => {
|
||||
if (submenuTimeoutRef.current) {
|
||||
clearTimeout(submenuTimeoutRef.current);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
isSubmenu
|
||||
isOpen
|
||||
items={activeSubmenu.item.submenu ?? []}
|
||||
defaultSelectedIndex={activeSubmenu.viaKeyboard ? 0 : null}
|
||||
onClose={() => setActiveSubmenu(null)}
|
||||
onCloseAll={handleCloseAll}
|
||||
triggerShape={submenuTriggerShape}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
);
|
||||
|
||||
// Hotkeys must be rendered even when menu is closed (so they work globally)
|
||||
const hotKeyElements = items.map(
|
||||
(item, i) =>
|
||||
item.type !== 'separator' &&
|
||||
item.type !== 'content' &&
|
||||
!item.hotKeyLabelOnly &&
|
||||
item.hotKeyAction && (
|
||||
<MenuItemHotKey
|
||||
key={`${item.hotKeyAction}::${i}`}
|
||||
onSelect={handleSelect}
|
||||
item={item}
|
||||
action={item.hotKeyAction}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return <>{hotKeyElements}</>;
|
||||
}
|
||||
|
||||
if (isSubmenu) {
|
||||
return menuContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hotKeyElements}
|
||||
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
|
||||
{menuContent}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface MenuItemProps {
|
||||
className?: string;
|
||||
item: DropdownItemDefault;
|
||||
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
|
||||
onFocus: (item: DropdownItemDefault) => void;
|
||||
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||
focused: boolean;
|
||||
isParentOfActiveSubmenu?: boolean;
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
className,
|
||||
focused,
|
||||
onFocus,
|
||||
onHover,
|
||||
item,
|
||||
onSelect,
|
||||
isParentOfActiveSubmenu,
|
||||
...props
|
||||
}: MenuItemProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const handleClick = useCallback(async () => {
|
||||
if (item.waitForOnSelect) setIsLoading(true);
|
||||
await onSelect?.(item, buttonRef.current ?? undefined);
|
||||
if (item.waitForOnSelect) setIsLoading(false);
|
||||
}, [item, onSelect]);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: ReactFocusEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation(); // Don't trigger focus on any parents
|
||||
return onFocus?.(item);
|
||||
},
|
||||
[item, onFocus],
|
||||
);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const initRef = useCallback(
|
||||
(el: HTMLButtonElement | null) => {
|
||||
buttonRef.current = el;
|
||||
if (el === null) return;
|
||||
if (focused) {
|
||||
setTimeout(() => el.focus(), 0);
|
||||
}
|
||||
},
|
||||
[focused],
|
||||
);
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
onHover(item, e.currentTarget);
|
||||
e.currentTarget.focus();
|
||||
};
|
||||
|
||||
const rightSlot = item.submenu ? (
|
||||
<Icon icon="chevron_right" color="secondary" />
|
||||
) : (
|
||||
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={initRef}
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={(e) => e.currentTarget.blur()}
|
||||
disabled={item.disabled}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
justify="start"
|
||||
leftSlot={
|
||||
(isLoading || item.leftSlot || item.icon) && (
|
||||
<div className={classNames('pr-2 flex justify-start [&_svg]:opacity-70')}>
|
||||
{isLoading ? <LoadingIcon /> : item.icon ? <Icon icon={item.icon} /> : item.leftSlot}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
|
||||
innerClassName="!text-left"
|
||||
color="custom"
|
||||
className={classNames(
|
||||
className,
|
||||
'h-xs', // More compact
|
||||
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
|
||||
'focus:bg-surface-highlight focus:text rounded focus:outline-none focus-visible:outline-1',
|
||||
isParentOfActiveSubmenu && 'bg-surface-highlight text rounded',
|
||||
item.color === 'danger' && '!text-danger',
|
||||
item.color === 'primary' && '!text-primary',
|
||||
item.color === 'success' && '!text-success',
|
||||
item.color === 'warning' && '!text-warning',
|
||||
item.color === 'notice' && '!text-notice',
|
||||
item.color === 'info' && '!text-info',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className={classNames('truncate min-w-[5rem]')}>{item.label}</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemHotKeyProps {
|
||||
action: HotkeyAction | undefined;
|
||||
onSelect: MenuItemProps['onSelect'];
|
||||
item: MenuItemProps['item'];
|
||||
}
|
||||
|
||||
function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) {
|
||||
useHotKey(action ?? null, () => onSelect(item));
|
||||
return null;
|
||||
}
|
||||
|
||||
function sign(
|
||||
p1: { x: number; y: number },
|
||||
p2: { x: number; y: number },
|
||||
p3: { x: number; y: number },
|
||||
) {
|
||||
return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
|
||||
}
|
||||
|
||||
function isPointInTriangle(
|
||||
pt: { x: number; y: number },
|
||||
v1: { x: number; y: number },
|
||||
v2: { x: number; y: number },
|
||||
v3: { x: number; y: number },
|
||||
) {
|
||||
const d1 = sign(pt, v1, v2);
|
||||
const d2 = sign(pt, v2, v3);
|
||||
const d3 = sign(pt, v3, v1);
|
||||
|
||||
const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
|
||||
const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
|
||||
|
||||
return !(has_neg && has_pos);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type DecorationSet, MatchDecorator, type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
|
||||
*/
|
||||
export class BetterMatchDecorator extends MatchDecorator {
|
||||
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
|
||||
if (!update.startState.selection.eq(update.state.selection)) {
|
||||
return super.createDeco(update.view);
|
||||
}
|
||||
return super.updateDeco(update, deco);
|
||||
}
|
||||
}
|
||||
39
apps/yaak-client/components/core/Editor/DiffViewer.css
Normal file
39
apps/yaak-client/components/core/Editor/DiffViewer.css
Normal file
@@ -0,0 +1,39 @@
|
||||
.cm-wrapper.cm-multiline .cm-mergeView {
|
||||
@apply h-full w-full overflow-auto pr-0.5;
|
||||
|
||||
.cm-mergeViewEditors {
|
||||
@apply w-full min-h-full;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditor {
|
||||
@apply w-full min-h-full relative;
|
||||
|
||||
.cm-collapsedLines {
|
||||
@apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply pl-1.5;
|
||||
}
|
||||
.cm-changedLine {
|
||||
/* Round top corners only if previous line is not a changed line */
|
||||
&:not(.cm-changedLine + &) {
|
||||
@apply rounded-t;
|
||||
}
|
||||
/* Round bottom corners only if next line is not a changed line */
|
||||
&:not(:has(+ .cm-changedLine)) {
|
||||
@apply rounded-b;
|
||||
}
|
||||
}
|
||||
|
||||
/* Let content grow and disable individual scrolling for sync */
|
||||
.cm-editor {
|
||||
@apply h-auto relative !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply overflow-visible !important;
|
||||
}
|
||||
}
|
||||
64
apps/yaak-client/components/core/Editor/DiffViewer.tsx
Normal file
64
apps/yaak-client/components/core/Editor/DiffViewer.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { syntaxHighlighting } from '@codemirror/language';
|
||||
import { MergeView } from '@codemirror/merge';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import './DiffViewer.css';
|
||||
import { readonlyExtensions, syntaxHighlightStyle } from './extensions';
|
||||
|
||||
interface Props {
|
||||
/** Original/previous version (left side) */
|
||||
original: string;
|
||||
/** Modified/current version (right side) */
|
||||
modified: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DiffViewer({ original, modified, className }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<MergeView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Clean up previous instance
|
||||
viewRef.current?.destroy();
|
||||
|
||||
const sharedExtensions = [
|
||||
yaml(),
|
||||
syntaxHighlighting(syntaxHighlightStyle),
|
||||
...readonlyExtensions,
|
||||
EditorView.lineWrapping,
|
||||
];
|
||||
|
||||
viewRef.current = new MergeView({
|
||||
a: {
|
||||
doc: original,
|
||||
extensions: sharedExtensions,
|
||||
},
|
||||
b: {
|
||||
doc: modified,
|
||||
extensions: sharedExtensions,
|
||||
},
|
||||
parent: containerRef.current,
|
||||
collapseUnchanged: { margin: 2, minSize: 3 },
|
||||
highlightChanges: false,
|
||||
gutter: true,
|
||||
orientation: 'a-b',
|
||||
revertControls: undefined,
|
||||
});
|
||||
|
||||
return () => {
|
||||
viewRef.current?.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [original, modified]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classNames('cm-wrapper cm-multiline h-full w-full', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
488
apps/yaak-client/components/core/Editor/Editor.css
Normal file
488
apps/yaak-client/components/core/Editor/Editor.css
Normal file
@@ -0,0 +1,488 @@
|
||||
.cm-wrapper {
|
||||
@apply h-full overflow-hidden;
|
||||
|
||||
.cm-editor {
|
||||
@apply w-full block text-base;
|
||||
|
||||
/* Regular cursor */
|
||||
|
||||
.cm-cursor {
|
||||
@apply border-text !important;
|
||||
/* Widen the cursor a bit */
|
||||
@apply border-l-[2px];
|
||||
}
|
||||
|
||||
/* Vim-mode cursor */
|
||||
|
||||
.cm-fat-cursor {
|
||||
@apply outline-0 bg-text !important;
|
||||
@apply text-surface !important;
|
||||
}
|
||||
|
||||
/* Matching bracket */
|
||||
|
||||
.cm-matchingBracket {
|
||||
@apply bg-transparent border-b border-b-text-subtle;
|
||||
}
|
||||
|
||||
&:not(.cm-focused) {
|
||||
.cm-cursor,
|
||||
.cm-fat-cursor {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
@apply py-0;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply w-full;
|
||||
/* Important! Ensure it spans the entire width */
|
||||
@apply w-full text-text px-0;
|
||||
|
||||
/* So the search highlight border is not cut off by editor view */
|
||||
@apply pl-[1px];
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
@apply text-placeholder;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
/* Inherit line-height from outside */
|
||||
line-height: inherit;
|
||||
|
||||
* {
|
||||
@apply cursor-text;
|
||||
@apply caret-transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
@apply bg-selection !important;
|
||||
}
|
||||
|
||||
/* Style gutters */
|
||||
|
||||
.cm-gutters {
|
||||
@apply border-0 text-text-subtlest bg-surface pr-1.5;
|
||||
/* Not sure why, but there's a tiny gap left of the gutter that you can see text
|
||||
through. Move left slightly to fix that. */
|
||||
@apply -left-[1px];
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-gutter-lint {
|
||||
@apply w-auto !important;
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply px-0;
|
||||
}
|
||||
|
||||
.cm-lint-marker {
|
||||
@apply cursor-default opacity-80 hover:opacity-100 transition-opacity;
|
||||
@apply rounded-full w-[0.9em] h-[0.9em];
|
||||
|
||||
content: "";
|
||||
|
||||
&.cm-lint-marker-error {
|
||||
@apply bg-danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-tag {
|
||||
/* Colors */
|
||||
@apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;
|
||||
@apply hover:border-border hover:text-text hover:bg-surface-highlight;
|
||||
|
||||
@apply inline border px-1 mx-[0.5px] rounded dark:shadow;
|
||||
|
||||
-webkit-text-security: none;
|
||||
|
||||
* {
|
||||
@apply cursor-default;
|
||||
}
|
||||
|
||||
.fn {
|
||||
@apply inline-block;
|
||||
.fn-inner {
|
||||
@apply text-text-subtle max-w-[40em] italic inline-flex items-end whitespace-pre text-[0.9em];
|
||||
}
|
||||
.fn-arg-name {
|
||||
/* Nothing yet */
|
||||
@apply opacity-60;
|
||||
}
|
||||
.fn-arg-value {
|
||||
@apply inline-block truncate;
|
||||
}
|
||||
.fn-bracket {
|
||||
@apply text-text-subtle opacity-30;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hyperlink-widget {
|
||||
& > * {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
&:hover > * {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
-webkit-text-security: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-singleline {
|
||||
.cm-editor {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-xs;
|
||||
|
||||
/* Hide scrollbars */
|
||||
|
||||
&::-webkit-scrollbar-corner,
|
||||
&::-webkit-scrollbar {
|
||||
@apply hidden !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-multiline {
|
||||
&.cm-full-height {
|
||||
@apply relative;
|
||||
|
||||
.cm-editor {
|
||||
@apply inset-0 absolute;
|
||||
position: absolute !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-editor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Style search matches */
|
||||
.cm-searchMatch {
|
||||
@apply bg-transparent !important;
|
||||
@apply rounded-[2px] outline outline-1;
|
||||
|
||||
&.cm-searchMatch-selected {
|
||||
@apply outline-text;
|
||||
@apply bg-text !important;
|
||||
|
||||
&,
|
||||
* {
|
||||
@apply text-surface font-semibold !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Obscure text for password fields */
|
||||
.cm-wrapper.cm-obscure-text .cm-line {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
/* Obscure text for password fields */
|
||||
.cm-wrapper.cm-obscure-text .cm-line {
|
||||
-webkit-text-security: disc;
|
||||
|
||||
.cm-placeholder {
|
||||
-webkit-text-security: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor .cm-gutterElement {
|
||||
@apply flex items-center;
|
||||
transition: color var(--transition-duration);
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon {
|
||||
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded;
|
||||
@apply cursor-default !important;
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon::after {
|
||||
@apply block w-1.5 h-1.5 p-0.5 border-transparent border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
|
||||
}
|
||||
|
||||
/* Rotate the fold gutter chevron when open */
|
||||
.cm-editor .fold-gutter-icon[data-open]::after {
|
||||
@apply rotate-[-45deg];
|
||||
}
|
||||
|
||||
/* Adjust fold gutter icon position after rotation */
|
||||
.cm-editor .fold-gutter-icon:not([data-open])::after {
|
||||
@apply relative -left-[0.1em] top-[0.1em] rotate-[-135deg];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon:hover {
|
||||
@apply text-text bg-surface-highlight;
|
||||
}
|
||||
|
||||
.cm-editor .cm-foldPlaceholder {
|
||||
@apply px-2 border border-border-subtle bg-surface-highlight;
|
||||
@apply hover:text-text hover:border-border-subtle text-text;
|
||||
@apply cursor-default !important;
|
||||
}
|
||||
|
||||
.cm-editor .cm-activeLineGutter {
|
||||
@apply bg-transparent text-text-subtle;
|
||||
}
|
||||
|
||||
/* Cursor and mouse cursor for readonly mode */
|
||||
.cm-wrapper.cm-readonly {
|
||||
&.cm-singleline * {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-singleline .cm-editor {
|
||||
.cm-content {
|
||||
@apply h-full flex items-center;
|
||||
|
||||
/* Break characters on line wrapping mode, useful for URL field.
|
||||
* We can make this dynamic if we need it to be configurable later
|
||||
*/
|
||||
|
||||
&.cm-lineWrapping {
|
||||
@apply break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip-lint {
|
||||
@apply font-mono text-editor rounded overflow-hidden bg-surface-highlight border border-border shadow !important;
|
||||
|
||||
.cm-diagnostic-error {
|
||||
@apply border-l-danger px-4 py-2;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-lintPoint {
|
||||
&.cm-lintPoint-error {
|
||||
&::after {
|
||||
@apply border-b-danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-hover {
|
||||
@apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm;
|
||||
@apply p-1.5;
|
||||
|
||||
/* Style the tooltip for popping up "open in browser" and other stuff */
|
||||
|
||||
a,
|
||||
button {
|
||||
@apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply cursor-default !important;
|
||||
|
||||
&::after {
|
||||
@apply text-text bg-text h-3 w-3 ml-1;
|
||||
content: "";
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='black' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E");
|
||||
-webkit-mask-size: contain;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* NOTE: Extra selector required to override default styles */
|
||||
.cm-tooltip.cm-tooltip-autocomplete,
|
||||
.cm-tooltip.cm-completionInfo {
|
||||
@apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto;
|
||||
|
||||
& * {
|
||||
@apply font-mono text-editor !important;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply opacity-80 italic;
|
||||
|
||||
&::after {
|
||||
content: "a" !important; /* Default (eg. for GraphQL) */
|
||||
}
|
||||
|
||||
&.cm-completionIcon-function::after {
|
||||
content: "f" !important;
|
||||
@apply text-info;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-variable::after {
|
||||
content: "x" !important;
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-namespace::after {
|
||||
content: "n" !important;
|
||||
@apply text-warning;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-constant::after {
|
||||
content: "c" !important;
|
||||
@apply text-notice;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-class::after {
|
||||
content: "o" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-enum::after {
|
||||
content: "e" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-interface::after {
|
||||
content: "i" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-keyword::after {
|
||||
content: "k" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-method::after {
|
||||
content: "m" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-property::after {
|
||||
content: "a" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-text::after {
|
||||
content: "t" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-type::after {
|
||||
content: "t" !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-completionInfo {
|
||||
@apply mx-0.5 -mt-0.5 font-sans;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply transition-none;
|
||||
}
|
||||
|
||||
&.cm-tooltip-autocomplete {
|
||||
@apply font-mono;
|
||||
|
||||
& > ul {
|
||||
@apply p-1 max-h-[40vh];
|
||||
}
|
||||
|
||||
& > ul > li {
|
||||
@apply cursor-default px-2 h-[2em] rounded-sm text-text flex items-center;
|
||||
}
|
||||
|
||||
& > ul > li[aria-selected] {
|
||||
@apply bg-surface-highlight text-text;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply text-sm flex items-center pb-0.5 flex-shrink-0;
|
||||
}
|
||||
|
||||
.cm-completionLabel {
|
||||
@apply text-text;
|
||||
}
|
||||
|
||||
.cm-completionDetail {
|
||||
@apply ml-auto pl-6 text-text-subtle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor .cm-panels {
|
||||
@apply bg-surface-highlight backdrop-blur-sm p-1 mb-1 text-text z-20 rounded-md;
|
||||
|
||||
input,
|
||||
button {
|
||||
@apply rounded-sm outline-none;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply border-border-subtle bg-surface-highlight text-text hover:border-info;
|
||||
@apply appearance-none bg-none cursor-default;
|
||||
}
|
||||
|
||||
button[name="close"] {
|
||||
@apply text-text-subtle hocus:text-text px-2 -mr-1.5 !important;
|
||||
}
|
||||
|
||||
input {
|
||||
@apply bg-surface border-border-subtle focus:border-border-focus;
|
||||
@apply border outline-none;
|
||||
}
|
||||
|
||||
input.cm-textfield {
|
||||
@apply cursor-text;
|
||||
}
|
||||
|
||||
.cm-search label {
|
||||
@apply inline-flex items-center h-6 px-1.5 rounded-sm border border-border-subtle cursor-default text-text-subtle text-xs;
|
||||
|
||||
input[type="checkbox"] {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
&:has(:checked) {
|
||||
@apply text-primary border-border;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the "All" button */
|
||||
|
||||
button[name="select"] {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
/* Replace next/prev button text with chevron icons */
|
||||
|
||||
.cm-search button[name="next"],
|
||||
.cm-search button[name="prev"] {
|
||||
@apply text-[0px] w-7 h-6 inline-flex items-center justify-center border border-border-subtle mr-1;
|
||||
}
|
||||
|
||||
.cm-search button[name="prev"]::after,
|
||||
.cm-search button[name="next"]::after {
|
||||
@apply block w-3.5 h-3.5 bg-text;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.cm-search button[name="prev"]::after {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.cm-search button[name="next"]::after {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.cm-search-match-count {
|
||||
@apply text-text-subtle text-xs font-mono whitespace-nowrap px-1.5 py-0.5 self-center;
|
||||
}
|
||||
}
|
||||
702
apps/yaak-client/components/core/Editor/Editor.tsx
Normal file
702
apps/yaak-client/components/core/Editor/Editor.tsx
Normal file
@@ -0,0 +1,702 @@
|
||||
import { startCompletion } from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, historyField, indentWithTab } from '@codemirror/commands';
|
||||
import { foldState, forceParsing } from '@codemirror/language';
|
||||
import type { EditorStateConfig, Extension } from '@codemirror/state';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { EditorView, keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
|
||||
import { emacs } from '@replit/codemirror-emacs';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
|
||||
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
||||
import type { EditorKeymap } from '@yaakapp-internal/models';
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import type { GraphQLSchema } from 'graphql';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { md5 } from 'js-md5';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment';
|
||||
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
|
||||
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
|
||||
import { eventMatchesHotkey } from '../../../hooks/useHotKey';
|
||||
import { useRequestEditor } from '../../../hooks/useRequestEditor';
|
||||
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
|
||||
import { editEnvironment } from '../../../lib/editEnvironment';
|
||||
import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
|
||||
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
|
||||
import { IconButton } from '../IconButton';
|
||||
import { HStack } from '../Stacks';
|
||||
import './Editor.css';
|
||||
import {
|
||||
baseExtensions,
|
||||
getLanguageExtension,
|
||||
multiLineExtensions,
|
||||
readonlyExtensions,
|
||||
} from './extensions';
|
||||
import type { GenericCompletionConfig } from './genericCompletion';
|
||||
import { singleLineExtensions } from './singleLine';
|
||||
|
||||
// VSCode's Tab actions mess with the single-line editor tab actions, so remove it.
|
||||
const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== 'Tab');
|
||||
|
||||
const keymapExtensions: Record<EditorKeymap, Extension> = {
|
||||
vim: vim(),
|
||||
emacs: emacs(),
|
||||
vscode: keymap.of(vsCodeWithoutTab),
|
||||
default: [],
|
||||
};
|
||||
|
||||
export interface EditorProps {
|
||||
actions?: ReactNode;
|
||||
autoFocus?: boolean;
|
||||
autoSelect?: boolean;
|
||||
autocomplete?: GenericCompletionConfig;
|
||||
autocompleteFunctions?: boolean;
|
||||
autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean);
|
||||
className?: string;
|
||||
defaultValue?: string | null;
|
||||
disableTabIndent?: boolean;
|
||||
disabled?: boolean;
|
||||
extraExtensions?: Extension[] | Extension;
|
||||
forcedEnvironmentId?: string;
|
||||
forceUpdateKey?: string | number;
|
||||
format?: (v: string) => Promise<string>;
|
||||
heightMode?: 'auto' | 'full';
|
||||
hideGutter?: boolean;
|
||||
id?: string;
|
||||
language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null;
|
||||
graphQLSchema?: GraphQLSchema | null;
|
||||
onBlur?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
onPaste?: (value: string) => void;
|
||||
onPasteOverwrite?: (e: ClipboardEvent, value: string) => void;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
singleLine?: boolean;
|
||||
containerOnly?: boolean;
|
||||
stateKey: string | null;
|
||||
tooltipContainer?: HTMLElement;
|
||||
type?: 'text' | 'password';
|
||||
wrapLines?: boolean;
|
||||
setRef?: (view: EditorView | null) => void;
|
||||
}
|
||||
|
||||
const stateFields = { history: historyField, folds: foldState };
|
||||
|
||||
const emptyVariables: WrappedEnvironmentVariable[] = [];
|
||||
const emptyExtension: Extension = [];
|
||||
|
||||
export function Editor(props: EditorProps) {
|
||||
return <EditorInner key={props.stateKey} {...props} />;
|
||||
}
|
||||
|
||||
function EditorInner({
|
||||
actions,
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
autocomplete,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
className,
|
||||
defaultValue,
|
||||
disableTabIndent,
|
||||
disabled,
|
||||
extraExtensions,
|
||||
forcedEnvironmentId,
|
||||
forceUpdateKey,
|
||||
format,
|
||||
heightMode,
|
||||
hideGutter,
|
||||
graphQLSchema,
|
||||
language,
|
||||
onBlur,
|
||||
onChange,
|
||||
onFocus,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onPasteOverwrite,
|
||||
placeholder,
|
||||
readOnly,
|
||||
singleLine,
|
||||
containerOnly,
|
||||
stateKey,
|
||||
type,
|
||||
wrapLines,
|
||||
setRef,
|
||||
}: EditorProps) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
|
||||
const allEnvironmentVariables = useEnvironmentVariables(forcedEnvironmentId ?? null);
|
||||
const useTemplating = !!(autocompleteFunctions || autocompleteVariables || autocomplete);
|
||||
const environmentVariables = useMemo(() => {
|
||||
if (!autocompleteVariables) return emptyVariables;
|
||||
return typeof autocompleteVariables === 'function'
|
||||
? allEnvironmentVariables.filter(autocompleteVariables)
|
||||
: allEnvironmentVariables;
|
||||
}, [allEnvironmentVariables, autocompleteVariables]);
|
||||
|
||||
if (settings && wrapLines === undefined) {
|
||||
wrapLines = settings.editorSoftWrap;
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
readOnly = true;
|
||||
}
|
||||
|
||||
if (
|
||||
singleLine ||
|
||||
language == null ||
|
||||
language === 'text' ||
|
||||
language === 'url' ||
|
||||
language === 'pairs'
|
||||
) {
|
||||
disableTabIndent = true;
|
||||
}
|
||||
|
||||
if (format == null && !readOnly) {
|
||||
format =
|
||||
language === 'json'
|
||||
? tryFormatJson
|
||||
: language === 'xml' || language === 'html'
|
||||
? tryFormatXml
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handleChange = useRef<EditorProps['onChange']>(onChange);
|
||||
useEffect(() => {
|
||||
handleChange.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handlePaste = useRef<EditorProps['onPaste']>(onPaste);
|
||||
useEffect(() => {
|
||||
handlePaste.current = onPaste;
|
||||
}, [onPaste]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handlePasteOverwrite = useRef<EditorProps['onPasteOverwrite']>(onPasteOverwrite);
|
||||
useEffect(() => {
|
||||
handlePasteOverwrite.current = onPasteOverwrite;
|
||||
}, [onPasteOverwrite]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handleFocus = useRef<EditorProps['onFocus']>(onFocus);
|
||||
useEffect(() => {
|
||||
handleFocus.current = onFocus;
|
||||
}, [onFocus]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handleBlur = useRef<EditorProps['onBlur']>(onBlur);
|
||||
useEffect(() => {
|
||||
handleBlur.current = onBlur;
|
||||
}, [onBlur]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handleKeyDown = useRef<EditorProps['onKeyDown']>(onKeyDown);
|
||||
useEffect(() => {
|
||||
handleKeyDown.current = onKeyDown;
|
||||
}, [onKeyDown]);
|
||||
|
||||
// Update placeholder
|
||||
const placeholderCompartment = useRef(new Compartment());
|
||||
useEffect(
|
||||
function configurePlaceholder() {
|
||||
if (cm.current === null) return;
|
||||
const ext = placeholderExt(placeholderElFromText(placeholder));
|
||||
const effects = placeholderCompartment.current.reconfigure(ext);
|
||||
cm.current?.view.dispatch({ effects });
|
||||
},
|
||||
[placeholder],
|
||||
);
|
||||
|
||||
// Update vim
|
||||
const keymapCompartment = useRef(new Compartment());
|
||||
useEffect(
|
||||
function configureKeymap() {
|
||||
if (cm.current === null) return;
|
||||
const current = keymapCompartment.current.get(cm.current.view.state) ?? [];
|
||||
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
||||
if (settings.editorKeymap === 'default' && current === keymapExtensions.default) return; // Nothing to do
|
||||
if (settings.editorKeymap === 'vim' && current === keymapExtensions.vim) return; // Nothing to do
|
||||
if (settings.editorKeymap === 'vscode' && current === keymapExtensions.vscode) return; // Nothing to do
|
||||
if (settings.editorKeymap === 'emacs' && current === keymapExtensions.emacs) return; // Nothing to do
|
||||
|
||||
const ext = keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default;
|
||||
const effects = keymapCompartment.current.reconfigure(ext);
|
||||
cm.current.view.dispatch({ effects });
|
||||
},
|
||||
[settings.editorKeymap],
|
||||
);
|
||||
|
||||
// Update wrap lines
|
||||
const wrapLinesCompartment = useRef(new Compartment());
|
||||
useEffect(
|
||||
function configureWrapLines() {
|
||||
if (cm.current === null) return;
|
||||
const current = wrapLinesCompartment.current.get(cm.current.view.state) ?? emptyExtension;
|
||||
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
||||
if (wrapLines && current !== emptyExtension) return; // Nothing to do
|
||||
if (!wrapLines && current === emptyExtension) return; // Nothing to do
|
||||
|
||||
const ext = wrapLines ? EditorView.lineWrapping : emptyExtension;
|
||||
const effects = wrapLinesCompartment.current.reconfigure(ext);
|
||||
cm.current?.view.dispatch({ effects });
|
||||
},
|
||||
[wrapLines],
|
||||
);
|
||||
|
||||
// Update tab indent
|
||||
const tabIndentCompartment = useRef(new Compartment());
|
||||
useEffect(
|
||||
function configureTabIndent() {
|
||||
if (cm.current === null) return;
|
||||
const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension;
|
||||
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
||||
if (disableTabIndent && current !== emptyExtension) return; // Nothing to do
|
||||
if (!disableTabIndent && current === emptyExtension) return; // Nothing to do
|
||||
|
||||
const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension;
|
||||
const effects = tabIndentCompartment.current.reconfigure(ext);
|
||||
cm.current?.view.dispatch({ effects });
|
||||
},
|
||||
[disableTabIndent],
|
||||
);
|
||||
|
||||
const onClickFunction = useCallback(
|
||||
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
|
||||
const show = () => {
|
||||
if (cm.current === null) return;
|
||||
TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view);
|
||||
};
|
||||
|
||||
if (fn.name === 'secure') {
|
||||
withEncryptionEnabled(show);
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onClickVariable = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async (v: WrappedEnvironmentVariable, _tagValue: string, _startPos: number) => {
|
||||
await editEnvironment(v.environment, { addOrFocusVariable: v.variable });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onClickMissingVariable = useCallback(async (name: string) => {
|
||||
const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);
|
||||
await editEnvironment(activeEnvironment, {
|
||||
addOrFocusVariable: { name, value: '', enabled: true },
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [, { focusParamValue }] = useRequestEditor();
|
||||
const onClickPathParameter = useCallback(
|
||||
async (name: string) => {
|
||||
focusParamValue(name);
|
||||
},
|
||||
[focusParamValue],
|
||||
);
|
||||
|
||||
const completionOptions = useTemplateFunctionCompletionOptions(
|
||||
onClickFunction,
|
||||
!!autocompleteFunctions,
|
||||
);
|
||||
|
||||
// Update the language extension when the language changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const { view, languageCompartment } = cm.current;
|
||||
const ext = getLanguageExtension({
|
||||
useTemplating,
|
||||
language,
|
||||
hideGutter,
|
||||
environmentVariables,
|
||||
autocomplete,
|
||||
completionOptions,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
graphQLSchema: graphQLSchema ?? null,
|
||||
});
|
||||
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
|
||||
}, [
|
||||
language,
|
||||
autocomplete,
|
||||
environmentVariables,
|
||||
onClickFunction,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
completionOptions,
|
||||
useTemplating,
|
||||
graphQLSchema,
|
||||
hideGutter,
|
||||
]);
|
||||
|
||||
// Initialize the editor when ref mounts
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only reinitialize when necessary
|
||||
const initEditorRef = useCallback(
|
||||
function initEditorRef(container: HTMLDivElement | null) {
|
||||
if (container === null) {
|
||||
cm.current?.view.destroy();
|
||||
cm.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const languageCompartment = new Compartment();
|
||||
const langExt = getLanguageExtension({
|
||||
useTemplating,
|
||||
language,
|
||||
completionOptions,
|
||||
autocomplete,
|
||||
environmentVariables,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
graphQLSchema: graphQLSchema ?? null,
|
||||
});
|
||||
const extensions = [
|
||||
languageCompartment.of(langExt),
|
||||
placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))),
|
||||
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),
|
||||
tabIndentCompartment.current.of(
|
||||
!disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,
|
||||
),
|
||||
keymapCompartment.current.of(
|
||||
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
||||
),
|
||||
...getExtensions({
|
||||
container,
|
||||
readOnly,
|
||||
singleLine,
|
||||
hideGutter,
|
||||
stateKey,
|
||||
onChange: handleChange,
|
||||
onPaste: handlePaste,
|
||||
onPasteOverwrite: handlePasteOverwrite,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onKeyDown: handleKeyDown,
|
||||
}),
|
||||
...(Array.isArray(extraExtensions)
|
||||
? extraExtensions
|
||||
: extraExtensions
|
||||
? [extraExtensions]
|
||||
: []),
|
||||
];
|
||||
|
||||
const cachedJsonState = getCachedEditorState(defaultValue ?? '', stateKey);
|
||||
|
||||
const doc = `${defaultValue ?? ''}`;
|
||||
const config: EditorStateConfig = { extensions, doc };
|
||||
|
||||
const state = cachedJsonState
|
||||
? EditorState.fromJSON(cachedJsonState, config, stateFields)
|
||||
: EditorState.create(config);
|
||||
|
||||
const view = new EditorView({ state, parent: container });
|
||||
|
||||
// For large documents, the parser may parse the max number of lines and fail to add
|
||||
// things like fold markers because of it.
|
||||
// This forces it to parse more but keeps the timeout to the default of 100 ms.
|
||||
forceParsing(view, 9e6, 100);
|
||||
|
||||
cm.current = { view, languageCompartment };
|
||||
if (autoFocus) {
|
||||
view.focus();
|
||||
}
|
||||
if (autoSelect) {
|
||||
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
}
|
||||
setRef?.(view);
|
||||
} catch (e) {
|
||||
console.log('Failed to initialize Codemirror', e);
|
||||
}
|
||||
},
|
||||
[forceUpdateKey],
|
||||
);
|
||||
|
||||
// For read-only mode, update content when `defaultValue` changes
|
||||
useEffect(
|
||||
function updateReadOnlyEditor() {
|
||||
if (readOnly && cm.current?.view != null) {
|
||||
updateContents(cm.current.view, defaultValue || '');
|
||||
}
|
||||
},
|
||||
[defaultValue, readOnly],
|
||||
);
|
||||
|
||||
// Force input to update when receiving change and not in focus
|
||||
useLayoutEffect(
|
||||
function updateNonFocusedEditor() {
|
||||
const notFocused = !cm.current?.view.hasFocus;
|
||||
if (notFocused && cm.current != null) {
|
||||
updateContents(cm.current.view, defaultValue || '');
|
||||
}
|
||||
},
|
||||
[defaultValue],
|
||||
);
|
||||
|
||||
// Add bg classes to actions, so they appear over the text
|
||||
const decoratedActions = useMemo(() => {
|
||||
const results = [];
|
||||
const actionClassName = classNames(
|
||||
'bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow',
|
||||
);
|
||||
|
||||
if (format) {
|
||||
results.push(
|
||||
<IconButton
|
||||
showConfirm
|
||||
key="format"
|
||||
size="sm"
|
||||
title="Reformat contents"
|
||||
icon="magic_wand"
|
||||
variant="border"
|
||||
className={classNames(actionClassName)}
|
||||
onClick={async () => {
|
||||
if (cm.current === null) return;
|
||||
const { doc } = cm.current.view.state;
|
||||
const formatted = await format(doc.toString());
|
||||
// Update editor and blur because the cursor will reset anyway
|
||||
cm.current.view.dispatch({
|
||||
changes: { from: 0, to: doc.length, insert: formatted },
|
||||
});
|
||||
cm.current.view.contentDOM.blur();
|
||||
// Fire change event
|
||||
onChange?.(formatted);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
results.push(
|
||||
Children.map(actions, (existingChild) => {
|
||||
if (!isValidElement<{ className?: string }>(existingChild)) return null;
|
||||
const existingProps = existingChild.props;
|
||||
|
||||
return cloneElement(existingChild, {
|
||||
...existingProps,
|
||||
className: classNames(existingProps.className, actionClassName),
|
||||
});
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}, [actions, format, onChange]);
|
||||
|
||||
const cmContainer = (
|
||||
<div
|
||||
ref={initEditorRef}
|
||||
className={classNames(
|
||||
className,
|
||||
'cm-wrapper text-base',
|
||||
disabled && 'opacity-disabled',
|
||||
type === 'password' && 'cm-obscure-text',
|
||||
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
|
||||
singleLine ? 'cm-singleline' : 'cm-multiline',
|
||||
readOnly && 'cm-readonly',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (singleLine || containerOnly) {
|
||||
return cmContainer;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative h-full w-full x-theme-editor bg-surface">
|
||||
{cmContainer}
|
||||
{decoratedActions && (
|
||||
<HStack
|
||||
space={1}
|
||||
justifyContent="end"
|
||||
className={classNames(
|
||||
'absolute bottom-2 left-0 right-0',
|
||||
'pointer-events-none', // No pointer events, so we don't block the editor
|
||||
)}
|
||||
>
|
||||
{decoratedActions}
|
||||
</HStack>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getExtensions({
|
||||
stateKey,
|
||||
container,
|
||||
readOnly,
|
||||
singleLine,
|
||||
hideGutter,
|
||||
onChange,
|
||||
onPaste,
|
||||
onPasteOverwrite,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
}: Pick<EditorProps, 'singleLine' | 'readOnly' | 'hideGutter'> & {
|
||||
stateKey: EditorProps['stateKey'];
|
||||
container: HTMLDivElement | null;
|
||||
onChange: RefObject<EditorProps['onChange']>;
|
||||
onPaste: RefObject<EditorProps['onPaste']>;
|
||||
onPasteOverwrite: RefObject<EditorProps['onPasteOverwrite']>;
|
||||
onFocus: RefObject<EditorProps['onFocus']>;
|
||||
onBlur: RefObject<EditorProps['onBlur']>;
|
||||
onKeyDown: RefObject<EditorProps['onKeyDown']>;
|
||||
}) {
|
||||
// TODO: Ensure tooltips render inside the dialog if we are in one.
|
||||
const parent =
|
||||
container?.closest<HTMLDivElement>('[role="dialog"]') ??
|
||||
document.querySelector<HTMLDivElement>('#cm-portal') ??
|
||||
undefined;
|
||||
|
||||
return [
|
||||
...baseExtensions, // Must be first
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
onFocus.current?.();
|
||||
},
|
||||
blur: () => {
|
||||
onBlur.current?.();
|
||||
},
|
||||
keydown: (e, view) => {
|
||||
// Check if the hotkey matches the editor.autocomplete action
|
||||
if (eventMatchesHotkey(e, 'editor.autocomplete')) {
|
||||
e.preventDefault();
|
||||
startCompletion(view);
|
||||
return true;
|
||||
}
|
||||
onKeyDown.current?.(e);
|
||||
},
|
||||
paste: (e, v) => {
|
||||
const textData = e.clipboardData?.getData('text/plain') ?? '';
|
||||
onPaste.current?.(textData);
|
||||
if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) {
|
||||
onPasteOverwrite.current?.(e, textData);
|
||||
}
|
||||
},
|
||||
}),
|
||||
tooltips({ parent }),
|
||||
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
|
||||
...(singleLine ? [singleLineExtensions()] : []),
|
||||
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
|
||||
...(readOnly ? readonlyExtensions : []),
|
||||
|
||||
// ------------------------ //
|
||||
// Things that must be last //
|
||||
// ------------------------ //
|
||||
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.startState === update.state) return;
|
||||
|
||||
if (onChange && update.docChanged) {
|
||||
onChange.current?.(update.state.doc.toString());
|
||||
}
|
||||
|
||||
saveCachedEditorState(stateKey, update.state);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const placeholderElFromText = (text: string | undefined) => {
|
||||
const el = document.createElement('div');
|
||||
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
|
||||
// to do with how Yaak "hacks" it with CSS for single line input.
|
||||
el.innerHTML = text ? text.replaceAll('\n', '<br/>') : ' ';
|
||||
return el;
|
||||
};
|
||||
|
||||
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
|
||||
if (!stateKey || state == null) return;
|
||||
const stateObj = state.toJSON(stateFields);
|
||||
|
||||
// Save state in sessionStorage by removing doc and saving the hash of it instead.
|
||||
// This will be checked on restore and put back in if it matches.
|
||||
stateObj.docHash = md5(stateObj.doc);
|
||||
stateObj.doc = undefined;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj));
|
||||
} catch (err) {
|
||||
console.log('Failed to save to editor state', stateKey, err);
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedEditorState(doc: string, stateKey: string | null) {
|
||||
if (stateKey == null) return;
|
||||
|
||||
try {
|
||||
const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey));
|
||||
if (stateStr == null) return null;
|
||||
|
||||
const { docHash, ...state } = JSON.parse(stateStr);
|
||||
|
||||
// Ensure the doc matches the one that was used to save the state
|
||||
if (docHash !== md5(doc)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
state.doc = doc;
|
||||
return state;
|
||||
} catch (err) {
|
||||
console.log('Failed to restore editor storage', stateKey, err);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeFullStateKey(stateKey: string): string {
|
||||
return `editor.${stateKey}`;
|
||||
}
|
||||
|
||||
function updateContents(view: EditorView, text: string) {
|
||||
// Replace codemirror contents
|
||||
const currentDoc = view.state.doc.toString();
|
||||
|
||||
if (currentDoc === text) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith(currentDoc)) {
|
||||
// If we're just appending, append only the changes. This preserves
|
||||
// things like scroll position.
|
||||
view.dispatch({
|
||||
changes: view.state.changes({
|
||||
from: currentDoc.length,
|
||||
insert: text.slice(currentDoc.length),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// If we're replacing everything, reset the entire content
|
||||
view.dispatch({
|
||||
changes: view.state.changes({
|
||||
from: 0,
|
||||
to: currentDoc.length,
|
||||
insert: text,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
12
apps/yaak-client/components/core/Editor/LazyEditor.tsx
Normal file
12
apps/yaak-client/components/core/Editor/LazyEditor.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import type { EditorProps } from './Editor';
|
||||
|
||||
const Editor_ = lazy(() => import('./Editor').then((m) => ({ default: m.Editor })));
|
||||
|
||||
export function Editor(props: EditorProps) {
|
||||
return (
|
||||
<Suspense>
|
||||
<Editor_ {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
326
apps/yaak-client/components/core/Editor/extensions.ts
Normal file
326
apps/yaak-client/components/core/Editor/extensions.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { history, historyKeymap } from '@codemirror/commands';
|
||||
import { go } from '@codemirror/lang-go';
|
||||
import { java } from '@codemirror/lang-java';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { php } from '@codemirror/lang-php';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import {
|
||||
bracketMatching,
|
||||
codeFolding,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
HighlightStyle,
|
||||
indentOnInput,
|
||||
LanguageSupport,
|
||||
StreamLanguage,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import { c, csharp, kotlin, objectiveC } from '@codemirror/legacy-modes/mode/clike';
|
||||
import { clojure } from '@codemirror/legacy-modes/mode/clojure';
|
||||
import { http } from '@codemirror/legacy-modes/mode/http';
|
||||
import { oCaml } from '@codemirror/legacy-modes/mode/mllike';
|
||||
import { powerShell } from '@codemirror/legacy-modes/mode/powershell';
|
||||
import { r } from '@codemirror/legacy-modes/mode/r';
|
||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { swift } from '@codemirror/legacy-modes/mode/swift';
|
||||
import { linter, lintGutter, lintKeymap } from '@codemirror/lint';
|
||||
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import {
|
||||
crosshairCursor,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
EditorView,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
rectangularSelection,
|
||||
} from '@codemirror/view';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { graphql } from 'cm6-graphql';
|
||||
import type { GraphQLSchema } from 'graphql';
|
||||
import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId';
|
||||
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import { renderMarkdown } from '../../../lib/markdown';
|
||||
import { pluralizeCount } from '../../../lib/pluralize';
|
||||
import { showGraphQLDocExplorerAtom } from '../../graphql/graphqlAtoms';
|
||||
import type { EditorProps } from './Editor';
|
||||
import { jsonParseLinter } from './json-lint';
|
||||
import { pairs } from './pairs/extension';
|
||||
import { text } from './text/extension';
|
||||
import { timeline } from './timeline/extension';
|
||||
import type { TwigCompletionOption } from './twig/completion';
|
||||
import { twig } from './twig/extension';
|
||||
import { pathParametersPlugin } from './twig/pathParameters';
|
||||
import { url } from './url/extension';
|
||||
import { searchMatchCount } from './searchMatchCount';
|
||||
|
||||
export const syntaxHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
|
||||
color: 'var(--textSubtlest)',
|
||||
},
|
||||
{
|
||||
tag: [t.emphasis],
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
{
|
||||
tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation],
|
||||
color: 'var(--textSubtle)',
|
||||
},
|
||||
{
|
||||
tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number],
|
||||
color: 'var(--info)',
|
||||
},
|
||||
{ tag: [t.variableName], color: 'var(--success)' },
|
||||
{ tag: [t.bool], color: 'var(--warning)' },
|
||||
{ tag: [t.attributeName, t.propertyName], color: 'var(--primary)' },
|
||||
{ tag: [t.attributeValue], color: 'var(--warning)' },
|
||||
{ tag: [t.string], color: 'var(--notice)' },
|
||||
{ tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: 'var(--danger)' },
|
||||
]);
|
||||
|
||||
const syntaxTheme = EditorView.theme({}, { dark: true });
|
||||
|
||||
const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
|
||||
|
||||
const legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {
|
||||
return () => new LanguageSupport(StreamLanguage.define(mode));
|
||||
};
|
||||
|
||||
const syntaxExtensions: Record<
|
||||
NonNullable<EditorProps['language']>,
|
||||
null | (() => LanguageSupport)
|
||||
> = {
|
||||
graphql: null,
|
||||
json: json,
|
||||
javascript: javascript,
|
||||
// HTML as XML because HTML is oddly slow
|
||||
html: xml,
|
||||
xml: xml,
|
||||
url: url,
|
||||
pairs: pairs,
|
||||
text: text,
|
||||
timeline: timeline,
|
||||
markdown: markdown,
|
||||
c: legacyLang(c),
|
||||
clojure: legacyLang(clojure),
|
||||
csharp: legacyLang(csharp),
|
||||
go: go,
|
||||
http: legacyLang(http),
|
||||
java: java,
|
||||
kotlin: legacyLang(kotlin),
|
||||
objective_c: legacyLang(objectiveC),
|
||||
ocaml: legacyLang(oCaml),
|
||||
php: php,
|
||||
powershell: legacyLang(powerShell),
|
||||
python: python,
|
||||
r: legacyLang(r),
|
||||
ruby: legacyLang(ruby),
|
||||
shell: legacyLang(shell),
|
||||
swift: legacyLang(swift),
|
||||
};
|
||||
|
||||
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript', 'graphql'];
|
||||
|
||||
export function getLanguageExtension({
|
||||
useTemplating,
|
||||
language = 'text',
|
||||
environmentVariables,
|
||||
autocomplete,
|
||||
hideGutter,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
completionOptions,
|
||||
graphQLSchema,
|
||||
}: {
|
||||
useTemplating: boolean;
|
||||
environmentVariables: WrappedEnvironmentVariable[];
|
||||
onClickVariable: (option: WrappedEnvironmentVariable, tagValue: string, startPos: number) => void;
|
||||
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
|
||||
onClickPathParameter: (name: string) => void;
|
||||
completionOptions: TwigCompletionOption[];
|
||||
graphQLSchema: GraphQLSchema | null;
|
||||
} & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter'>) {
|
||||
const extraExtensions: Extension[] = [];
|
||||
|
||||
if (language === 'url') {
|
||||
extraExtensions.push(pathParametersPlugin(onClickPathParameter));
|
||||
}
|
||||
|
||||
// Only close brackets on languages that need it
|
||||
if (language && closeBracketsFor.includes(language)) {
|
||||
extraExtensions.push(closeBracketsExtensions);
|
||||
}
|
||||
|
||||
// GraphQL is a special exception
|
||||
if (language === 'graphql') {
|
||||
return [
|
||||
graphql(graphQLSchema ?? undefined, {
|
||||
async onCompletionInfoRender(gqlCompletionItem): Promise<Node | null> {
|
||||
if (!gqlCompletionItem.documentation) return null;
|
||||
const innerHTML = await renderMarkdown(gqlCompletionItem.documentation);
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = innerHTML;
|
||||
return span;
|
||||
},
|
||||
onShowInDocs(field, type, parentType) {
|
||||
const activeRequestId = jotaiStore.get(activeRequestIdAtom);
|
||||
if (activeRequestId == null) return;
|
||||
jotaiStore.set(showGraphQLDocExplorerAtom, (v) => ({
|
||||
...v,
|
||||
[activeRequestId]: { field, type, parentType },
|
||||
}));
|
||||
},
|
||||
}),
|
||||
extraExtensions,
|
||||
];
|
||||
}
|
||||
|
||||
if (language === 'json') {
|
||||
extraExtensions.push(linter(jsonParseLinter()));
|
||||
if (!hideGutter) {
|
||||
extraExtensions.push(lintGutter());
|
||||
}
|
||||
}
|
||||
|
||||
const maybeBase = language ? syntaxExtensions[language] : null;
|
||||
const base = typeof maybeBase === 'function' ? maybeBase() : null;
|
||||
if (base == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!useTemplating) {
|
||||
return [base, extraExtensions];
|
||||
}
|
||||
|
||||
return twig({
|
||||
base,
|
||||
environmentVariables,
|
||||
completionOptions,
|
||||
autocomplete,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
extraExtensions,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys.
|
||||
// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings.
|
||||
const filteredCompletionKeymap = completionKeymap.filter((binding) => {
|
||||
const key = binding.key?.toLowerCase() ?? '';
|
||||
const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? '';
|
||||
// Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i)
|
||||
const isStartTrigger = key.includes('space') || mac.includes('alt-') || mac.includes('`');
|
||||
return !isStartTrigger;
|
||||
});
|
||||
|
||||
export const baseExtensions = [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
dropCursor(),
|
||||
drawSelection(),
|
||||
autocompletion({
|
||||
tooltipClass: () => 'x-theme-menu',
|
||||
closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
|
||||
defaultKeymap: false, // We handle the trigger via configurable hotkeys
|
||||
compareCompletions: (a, b) => {
|
||||
// Don't sort completions at all, only on boost
|
||||
return (a.boost ?? 0) - (b.boost ?? 0);
|
||||
},
|
||||
}),
|
||||
syntaxHighlighting(syntaxHighlightStyle),
|
||||
syntaxTheme,
|
||||
keymap.of([...historyKeymap, ...filteredCompletionKeymap]),
|
||||
];
|
||||
|
||||
export const readonlyExtensions = [
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.contentAttributes.of({ tabindex: '-1' }),
|
||||
];
|
||||
|
||||
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
|
||||
search({ top: true }),
|
||||
searchMatchCount(),
|
||||
hideGutter
|
||||
? []
|
||||
: [
|
||||
lineNumbers(),
|
||||
foldGutter({
|
||||
markerDOM: (open) => {
|
||||
const el = document.createElement('div');
|
||||
el.classList.add('fold-gutter-icon');
|
||||
el.tabIndex = -1;
|
||||
if (open) {
|
||||
el.setAttribute('data-open', '');
|
||||
}
|
||||
return el;
|
||||
},
|
||||
}),
|
||||
],
|
||||
codeFolding({
|
||||
placeholderDOM(_view, onclick, prepared) {
|
||||
const el = document.createElement('span');
|
||||
el.onclick = onclick;
|
||||
el.className = 'cm-foldPlaceholder';
|
||||
el.innerText = prepared || '…';
|
||||
el.title = 'unfold';
|
||||
el.ariaLabel = 'folded code';
|
||||
return el;
|
||||
},
|
||||
/**
|
||||
* Show the number of items when code folded. NOTE: this doesn't get called when restoring
|
||||
* a previous serialized editor state, which is a bummer
|
||||
*/
|
||||
preparePlaceholder(state, range) {
|
||||
let count: number | undefined;
|
||||
let startToken = '{';
|
||||
let endToken = '}';
|
||||
|
||||
const prevLine = state.doc.lineAt(range.from).text;
|
||||
const isArray = prevLine.lastIndexOf('[') > prevLine.lastIndexOf('{');
|
||||
|
||||
if (isArray) {
|
||||
startToken = '[';
|
||||
endToken = ']';
|
||||
}
|
||||
|
||||
const internal = state.sliceDoc(range.from, range.to);
|
||||
const toParse = startToken + internal + endToken;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(toParse);
|
||||
count = Object.keys(parsed).length;
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
if (count !== undefined) {
|
||||
const label = isArray ? 'item' : 'key';
|
||||
return pluralizeCount(label, count);
|
||||
}
|
||||
},
|
||||
}),
|
||||
indentOnInput(),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
bracketMatching(),
|
||||
highlightActiveLineGutter(),
|
||||
keymap.of([...searchKeymap, ...foldKeymap, ...lintKeymap]),
|
||||
];
|
||||
183
apps/yaak-client/components/core/Editor/filter/extension.ts
Normal file
183
apps/yaak-client/components/core/Editor/filter/extension.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { autocompletion, startCompletion } from '@codemirror/autocomplete';
|
||||
import { LanguageSupport, LRLanguage, syntaxTree } from '@codemirror/language';
|
||||
import type { SyntaxNode } from '@lezer/common';
|
||||
import { parser } from './filter';
|
||||
|
||||
export interface FieldDef {
|
||||
name: string;
|
||||
// Optional static or dynamic value suggestions for this field
|
||||
values?: string[] | (() => string[]);
|
||||
info?: string;
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
|
||||
}
|
||||
|
||||
const IDENT = /[A-Za-z0-9_/]+$/;
|
||||
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
|
||||
|
||||
function normalizeFields(fields: FieldDef[]): {
|
||||
fieldNames: string[];
|
||||
fieldMap: Record<string, { values?: string[] | (() => string[]); info?: string }>;
|
||||
} {
|
||||
const fieldNames: string[] = [];
|
||||
const fieldMap: Record<string, { values?: string[] | (() => string[]); info?: string }> = {};
|
||||
for (const f of fields) {
|
||||
fieldNames.push(f.name);
|
||||
fieldMap[f.name] = { values: f.values, info: f.info };
|
||||
}
|
||||
return { fieldNames, fieldMap };
|
||||
}
|
||||
|
||||
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {
|
||||
const upto = doc.slice(0, pos);
|
||||
const m = upto.match(IDENT);
|
||||
if (!m) return null;
|
||||
const from = pos - m[0].length;
|
||||
return { from, to: pos, text: m[0] };
|
||||
}
|
||||
|
||||
function inPhrase(ctx: CompletionContext): boolean {
|
||||
// Lezer node names from your grammar: Phrase is the quoted token
|
||||
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
|
||||
while (n) {
|
||||
if (n.name === 'Phrase') return true;
|
||||
n = n.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// While typing an incomplete quote, there's no Phrase token yet.
|
||||
function inUnclosedQuote(doc: string, pos: number): boolean {
|
||||
let quotes = 0;
|
||||
for (let i = 0; i < pos; i++) {
|
||||
if (doc[i] === '"' && doc[i - 1] !== '\\') quotes++;
|
||||
}
|
||||
return quotes % 2 === 1; // odd = inside an open quote
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic context detector (works without relying on exact node names):
|
||||
* - If there's a ':' after the last whitespace and before the cursor, we're in a field value.
|
||||
* - Otherwise, we're in a field name or bare term position.
|
||||
*/
|
||||
function contextInfo(stateDoc: string, pos: number) {
|
||||
const lastColon = stateDoc.lastIndexOf(':', pos - 1);
|
||||
const lastBoundary = Math.max(
|
||||
stateDoc.lastIndexOf(' ', pos - 1),
|
||||
stateDoc.lastIndexOf('\t', pos - 1),
|
||||
stateDoc.lastIndexOf('\n', pos - 1),
|
||||
stateDoc.lastIndexOf('(', pos - 1),
|
||||
stateDoc.lastIndexOf(')', pos - 1),
|
||||
);
|
||||
|
||||
const inValue = lastColon > lastBoundary;
|
||||
|
||||
let fieldName: string | null = null;
|
||||
let emptyAfterColon = false;
|
||||
|
||||
if (inValue) {
|
||||
// word before the colon = field name
|
||||
const beforeColon = stateDoc.slice(0, lastColon);
|
||||
const m = beforeColon.match(IDENT);
|
||||
fieldName = m ? m[0] : null;
|
||||
|
||||
// nothing (or only spaces) typed after the colon?
|
||||
const after = stateDoc.slice(lastColon + 1, pos);
|
||||
emptyAfterColon = after.length === 0 || /^\s+$/.test(after);
|
||||
}
|
||||
|
||||
return { inValue, fieldName, lastColon, emptyAfterColon };
|
||||
}
|
||||
|
||||
/** Build a completion list for field names */
|
||||
function fieldNameCompletions(fieldNames: string[]): Completion[] {
|
||||
return fieldNames.map((name) => ({
|
||||
label: name,
|
||||
type: 'property',
|
||||
apply: (view, _completion, from, to) => {
|
||||
// Insert "name:" (leave cursor right after colon)
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: `${name}:` },
|
||||
selection: { anchor: from + name.length + 1 },
|
||||
});
|
||||
startCompletion(view);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/** Build a completion list for field values (if provided) */
|
||||
function fieldValueCompletions(
|
||||
def: { values?: string[] | (() => string[]); info?: string } | undefined,
|
||||
): Completion[] | null {
|
||||
if (!def || !def.values) return null;
|
||||
const vals = Array.isArray(def.values) ? def.values : def.values();
|
||||
return vals.map((v) => ({
|
||||
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
|
||||
displayLabel: v,
|
||||
type: 'constant',
|
||||
}));
|
||||
}
|
||||
|
||||
/** The main completion source */
|
||||
function makeCompletionSource(opts: FilterOptions) {
|
||||
const { fieldNames, fieldMap } = normalizeFields(opts.fields ?? []);
|
||||
return (ctx: CompletionContext): CompletionResult | null => {
|
||||
const { state, pos } = ctx;
|
||||
const doc = state.doc.toString();
|
||||
|
||||
if (inPhrase(ctx) || inUnclosedQuote(doc, pos)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const w = wordBefore(doc, pos);
|
||||
const from = w?.from ?? pos;
|
||||
const to = pos;
|
||||
|
||||
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
|
||||
|
||||
// In field value position
|
||||
if (inValue && fieldName) {
|
||||
const valDefs = fieldMap[fieldName];
|
||||
const vals = fieldValueCompletions(valDefs);
|
||||
|
||||
// If user hasn't typed a value char yet:
|
||||
// - Show value suggestions if available
|
||||
// - Otherwise show nothing (no fallback to field names)
|
||||
if (emptyAfterColon) {
|
||||
if (vals?.length) {
|
||||
return { from, to, options: vals, filter: true };
|
||||
}
|
||||
return null; // <-- key change: do not suggest fields here
|
||||
}
|
||||
|
||||
// User started typing a value; filter value suggestions (if any)
|
||||
if (vals?.length) {
|
||||
return { from, to, options: vals, filter: true };
|
||||
}
|
||||
// No specific values: also show nothing (keeps UI quiet)
|
||||
return null;
|
||||
}
|
||||
|
||||
// Not in a value: suggest field names (and maybe boolean ops)
|
||||
const options: Completion[] = fieldNameCompletions(fieldNames);
|
||||
|
||||
return { from, to, options, filter: true };
|
||||
};
|
||||
}
|
||||
|
||||
const language = LRLanguage.define({
|
||||
name: 'filter',
|
||||
parser,
|
||||
languageData: {
|
||||
autocompletion: {},
|
||||
},
|
||||
});
|
||||
|
||||
/** Public extension */
|
||||
export function filter(options: FilterOptions) {
|
||||
const source = makeCompletionSource(options);
|
||||
return new LanguageSupport(language, [autocompletion({ override: [source] })]);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@top Query { Expr }
|
||||
|
||||
@skip { space+ }
|
||||
@tokens {
|
||||
space { std.whitespace+ }
|
||||
|
||||
LParen { "(" }
|
||||
RParen { ")" }
|
||||
Colon { ":" }
|
||||
Not { "-" | "NOT" }
|
||||
|
||||
// Keywords (case-insensitive)
|
||||
And { "AND" }
|
||||
Or { "OR" }
|
||||
|
||||
// "quoted phrase" with simple escapes: \" and \\
|
||||
Phrase { '"' (!["\\] | "\\" _)* '"' }
|
||||
|
||||
// field/word characters (keep generous for URLs/paths)
|
||||
Word { $[A-Za-z0-9_]+ }
|
||||
|
||||
@precedence { Not, And, Or, Word }
|
||||
}
|
||||
|
||||
@detectDelim
|
||||
|
||||
// Precedence: NOT (highest) > AND > OR (lowest)
|
||||
// We also allow implicit AND in your parser/evaluator, but for highlighting,
|
||||
// this grammar parses explicit AND/OR/NOT + adjacency as a sequence (Seq).
|
||||
Expr {
|
||||
OrExpr
|
||||
}
|
||||
|
||||
OrExpr {
|
||||
AndExpr (Or AndExpr)*
|
||||
}
|
||||
|
||||
AndExpr {
|
||||
Unary (And Unary | Unary)* // allow implicit AND by adjacency: Unary Unary
|
||||
}
|
||||
|
||||
Unary {
|
||||
Not Unary
|
||||
| Primary
|
||||
}
|
||||
|
||||
Primary {
|
||||
Group
|
||||
| Field
|
||||
| Phrase
|
||||
| Term
|
||||
}
|
||||
|
||||
Group {
|
||||
LParen Expr RParen
|
||||
}
|
||||
|
||||
Field {
|
||||
FieldName Colon FieldValue
|
||||
}
|
||||
|
||||
FieldName {
|
||||
Word
|
||||
}
|
||||
|
||||
FieldValue {
|
||||
Phrase
|
||||
| Term
|
||||
}
|
||||
|
||||
Term {
|
||||
Word
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
27
apps/yaak-client/components/core/Editor/filter/filter.ts
Normal file
27
apps/yaak-client/components/core/Editor/filter/filter.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// biome-ignore-all lint: Disable for generated file
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import { LRParser } from '@lezer/lr';
|
||||
import { highlight } from './highlight';
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states:
|
||||
"%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
|
||||
stateData:
|
||||
'$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~',
|
||||
goto: '#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne',
|
||||
nodeNames:
|
||||
'⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or',
|
||||
maxTerm: 25,
|
||||
nodeProps: [
|
||||
['openedBy', 8, 'LParen'],
|
||||
['closedBy', 9, 'RParen'],
|
||||
],
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0, 20],
|
||||
repeatNodeCount: 3,
|
||||
tokenData:
|
||||
")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[",
|
||||
tokenizers: [0],
|
||||
topRules: { Query: [0, 1] },
|
||||
tokenPrec: 145,
|
||||
});
|
||||
21
apps/yaak-client/components/core/Editor/filter/highlight.ts
Normal file
21
apps/yaak-client/components/core/Editor/filter/highlight.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
// Boolean operators
|
||||
And: t.operatorKeyword,
|
||||
Or: t.operatorKeyword,
|
||||
Not: t.operatorKeyword,
|
||||
|
||||
// Structural punctuation
|
||||
LParen: t.paren,
|
||||
RParen: t.paren,
|
||||
Colon: t.punctuation,
|
||||
Minus: t.operator,
|
||||
|
||||
// Literals
|
||||
Phrase: t.string, // "quoted string"
|
||||
|
||||
// Fields
|
||||
'FieldName/Word': t.attributeName,
|
||||
'FieldValue/Term/Word': t.attributeValue,
|
||||
});
|
||||
298
apps/yaak-client/components/core/Editor/filter/query.ts
Normal file
298
apps/yaak-client/components/core/Editor/filter/query.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
// query.ts
|
||||
// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value.
|
||||
|
||||
import { fuzzyMatch } from 'fuzzbunny';
|
||||
/////////////////////////
|
||||
// AST
|
||||
/////////////////////////
|
||||
|
||||
export type Ast =
|
||||
| { type: 'Term'; value: string } // foo
|
||||
| { type: 'Phrase'; value: string } // "hi there"
|
||||
| { type: 'Field'; field: string; value: string } // method:POST or title:"exact phrase"
|
||||
| { type: 'Not'; node: Ast } // -foo or NOT foo
|
||||
| { type: 'And'; left: Ast; right: Ast } // a AND b
|
||||
| { type: 'Or'; left: Ast; right: Ast }; // a OR b
|
||||
|
||||
/////////////////////////
|
||||
// Tokenizer
|
||||
/////////////////////////
|
||||
type Tok =
|
||||
| { kind: 'LPAREN' }
|
||||
| { kind: 'RPAREN' }
|
||||
| { kind: 'AND' }
|
||||
| { kind: 'OR' }
|
||||
| { kind: 'NOT' } // explicit NOT
|
||||
| { kind: 'MINUS' } // unary minus before term/phrase/paren group
|
||||
| { kind: 'COLON' }
|
||||
| { kind: 'WORD'; text: string } // bareword (unquoted)
|
||||
| { kind: 'PHRASE'; text: string } // "quoted phrase"
|
||||
| { kind: 'EOF' };
|
||||
|
||||
const isSpace = (c: string) => /\s/.test(c);
|
||||
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c);
|
||||
|
||||
export function tokenize(input: string): Tok[] {
|
||||
const toks: Tok[] = [];
|
||||
let i = 0;
|
||||
const n = input.length;
|
||||
|
||||
const peek = () => input[i] ?? '';
|
||||
const advance = () => input[i++];
|
||||
|
||||
const readWord = () => {
|
||||
let s = '';
|
||||
while (i < n && isIdent(peek())) s += advance();
|
||||
return s;
|
||||
};
|
||||
|
||||
const readPhrase = () => {
|
||||
// assumes current char is opening quote
|
||||
advance(); // consume opening "
|
||||
let s = '';
|
||||
while (i < n) {
|
||||
const c = advance();
|
||||
if (c === `"`) break;
|
||||
if (c === '\\' && i < n) {
|
||||
// escape \" and \\ (simple)
|
||||
const next = advance();
|
||||
s += next;
|
||||
} else {
|
||||
s += c;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
while (i < n) {
|
||||
const c = peek();
|
||||
|
||||
if (isSpace(c)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === '(') {
|
||||
toks.push({ kind: 'LPAREN' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === ')') {
|
||||
toks.push({ kind: 'RPAREN' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === ':') {
|
||||
toks.push({ kind: 'COLON' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === `"`) {
|
||||
const text = readPhrase();
|
||||
toks.push({ kind: 'PHRASE', text });
|
||||
continue;
|
||||
}
|
||||
if (c === '-') {
|
||||
toks.push({ kind: 'MINUS' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// WORD / AND / OR / NOT
|
||||
if (isIdent(c)) {
|
||||
const w = readWord();
|
||||
const upper = w.toUpperCase();
|
||||
if (upper === 'AND') toks.push({ kind: 'AND' });
|
||||
else if (upper === 'OR') toks.push({ kind: 'OR' });
|
||||
else if (upper === 'NOT') toks.push({ kind: 'NOT' });
|
||||
else toks.push({ kind: 'WORD', text: w });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown char—skip to be forgiving
|
||||
i++;
|
||||
}
|
||||
|
||||
toks.push({ kind: 'EOF' });
|
||||
return toks;
|
||||
}
|
||||
|
||||
class Parser {
|
||||
private i = 0;
|
||||
constructor(private toks: Tok[]) {}
|
||||
|
||||
private peek(): Tok {
|
||||
return this.toks[this.i] ?? { kind: 'EOF' };
|
||||
}
|
||||
private advance(): Tok {
|
||||
return this.toks[this.i++] ?? { kind: 'EOF' };
|
||||
}
|
||||
private at(kind: Tok['kind']) {
|
||||
return this.peek().kind === kind;
|
||||
}
|
||||
|
||||
// Top-level: parse OR-precedence chain, allowing implicit AND.
|
||||
parse(): Ast | null {
|
||||
if (this.at('EOF')) return null;
|
||||
const expr = this.parseOr();
|
||||
if (!this.at('EOF')) {
|
||||
// Optionally, consume remaining tokens or throw
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
// Precedence: NOT (highest), AND, OR (lowest)
|
||||
private parseOr(): Ast {
|
||||
let node = this.parseAnd();
|
||||
while (this.at('OR')) {
|
||||
this.advance();
|
||||
const rhs = this.parseAnd();
|
||||
node = { type: 'Or', left: node, right: rhs };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private parseAnd(): Ast {
|
||||
let node = this.parseUnary();
|
||||
// Implicit AND: if next token starts a primary, treat as AND.
|
||||
while (this.at('AND') || this.startsPrimary()) {
|
||||
if (this.at('AND')) this.advance();
|
||||
const rhs = this.parseUnary();
|
||||
node = { type: 'And', left: node, right: rhs };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private parseUnary(): Ast {
|
||||
if (this.at('NOT') || this.at('MINUS')) {
|
||||
this.advance();
|
||||
const node = this.parseUnary();
|
||||
return { type: 'Not', node };
|
||||
}
|
||||
return this.parsePrimaryOrField();
|
||||
}
|
||||
|
||||
private startsPrimary(): boolean {
|
||||
const k = this.peek().kind;
|
||||
return k === 'WORD' || k === 'PHRASE' || k === 'LPAREN' || k === 'MINUS' || k === 'NOT';
|
||||
}
|
||||
|
||||
private parsePrimaryOrField(): Ast {
|
||||
// Parenthesized group
|
||||
if (this.at('LPAREN')) {
|
||||
this.advance();
|
||||
const inside = this.parseOr();
|
||||
// if (!this.at('RPAREN')) throw new Error("Missing closing ')'");
|
||||
this.advance();
|
||||
return inside;
|
||||
}
|
||||
|
||||
// Phrase
|
||||
if (this.at('PHRASE')) {
|
||||
const t = this.advance() as Extract<Tok, { kind: 'PHRASE' }>;
|
||||
return { type: 'Phrase', value: t.text };
|
||||
}
|
||||
|
||||
// Field or bare word
|
||||
if (this.at('WORD')) {
|
||||
const wordTok = this.advance() as Extract<Tok, { kind: 'WORD' }>;
|
||||
|
||||
if (this.at('COLON')) {
|
||||
// field:value or field:"phrase"
|
||||
this.advance(); // :
|
||||
let value: string;
|
||||
if (this.at('PHRASE')) {
|
||||
const p = this.advance() as Extract<Tok, { kind: 'PHRASE' }>;
|
||||
value = p.text;
|
||||
} else if (this.at('WORD')) {
|
||||
const w = this.advance() as Extract<Tok, { kind: 'WORD' }>;
|
||||
value = w.text;
|
||||
} else {
|
||||
// Anything else after colon is treated literally as a single Term token.
|
||||
const t = this.advance();
|
||||
value = tokText(t);
|
||||
}
|
||||
return { type: 'Field', field: wordTok.text, value };
|
||||
}
|
||||
|
||||
// plain term
|
||||
return { type: 'Term', value: wordTok.text };
|
||||
}
|
||||
|
||||
const w = this.advance() as Extract<Tok, { kind: 'WORD' }>;
|
||||
return { type: 'Phrase', value: 'text' in w ? w.text : '' };
|
||||
}
|
||||
}
|
||||
|
||||
function tokText(t: Tok): string {
|
||||
if ('text' in t) return t.text;
|
||||
|
||||
switch (t.kind) {
|
||||
case 'COLON':
|
||||
return ':';
|
||||
case 'LPAREN':
|
||||
return '(';
|
||||
case 'RPAREN':
|
||||
return ')';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseQuery(q: string): Ast | null {
|
||||
if (q.trim() === '') return null;
|
||||
const toks = tokenize(q);
|
||||
const parser = new Parser(toks);
|
||||
return parser.parse();
|
||||
}
|
||||
|
||||
export type Doc = {
|
||||
text?: string;
|
||||
fields?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type Technique = 'substring' | 'fuzzy' | 'strict';
|
||||
|
||||
function includes(hay: string | undefined, needle: string, technique: Technique): boolean {
|
||||
if (!hay || !needle) return false;
|
||||
if (technique === 'strict') return hay === needle;
|
||||
if (technique === 'fuzzy') return !!fuzzyMatch(hay, needle);
|
||||
return hay.indexOf(needle) !== -1;
|
||||
}
|
||||
|
||||
export function evaluate(ast: Ast | null, doc: Doc): boolean {
|
||||
if (!ast) return true; // Match everything if no query is provided
|
||||
|
||||
const text = (doc.text ?? '').toLowerCase();
|
||||
const fieldsNorm: Record<string, string[]> = {};
|
||||
|
||||
for (const [k, v] of Object.entries(doc.fields ?? {})) {
|
||||
if (!(typeof v === 'string' || Array.isArray(v))) continue;
|
||||
fieldsNorm[k.toLowerCase()] = Array.isArray(v)
|
||||
? v.filter((v) => typeof v === 'string').map((s) => s.toLowerCase())
|
||||
: [String(v ?? '').toLowerCase()];
|
||||
}
|
||||
|
||||
const evalNode = (node: Ast): boolean => {
|
||||
switch (node.type) {
|
||||
case 'Term':
|
||||
return includes(text, node.value.toLowerCase(), 'fuzzy');
|
||||
case 'Phrase':
|
||||
// Quoted phrases match exactly
|
||||
return includes(text, node.value.toLowerCase(), 'substring');
|
||||
case 'Field': {
|
||||
const vals = fieldsNorm[node.field.toLowerCase()] ?? [];
|
||||
if (vals.length === 0) return false;
|
||||
return vals.some((v) => includes(v, node.value.toLowerCase(), 'substring'));
|
||||
}
|
||||
case 'Not':
|
||||
return !evalNode(node.node);
|
||||
case 'And':
|
||||
return evalNode(node.left) && evalNode(node.right);
|
||||
case 'Or':
|
||||
return evalNode(node.left) || evalNode(node.right);
|
||||
}
|
||||
};
|
||||
|
||||
return evalNode(ast);
|
||||
}
|
||||
39
apps/yaak-client/components/core/Editor/genericCompletion.ts
Normal file
39
apps/yaak-client/components/core/Editor/genericCompletion.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
|
||||
import { defaultBoost } from './twig/completion';
|
||||
|
||||
export interface GenericCompletionConfig {
|
||||
minMatch?: number;
|
||||
options: GenericCompletionOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete options, always matching until the start of the line
|
||||
*/
|
||||
export function genericCompletion(config?: GenericCompletionConfig) {
|
||||
if (config == null) return [];
|
||||
|
||||
const { minMatch = 1, options } = config;
|
||||
|
||||
return function completions(context: CompletionContext) {
|
||||
const toMatch = context.matchBefore(/.*/);
|
||||
|
||||
// Only match if we're at the start of the line
|
||||
if (toMatch === null || toMatch.from > 0) return null;
|
||||
|
||||
const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch;
|
||||
if (!matchedMinimumLength && !context.explicit) return null;
|
||||
|
||||
const optionsWithoutExactMatches = options
|
||||
.filter((o) => o.label !== toMatch.text)
|
||||
.map((o) => ({
|
||||
...o,
|
||||
boost: defaultBoost(o),
|
||||
}));
|
||||
return {
|
||||
validFor: () => true, // Not really sure why this is all it needs
|
||||
from: toMatch.from,
|
||||
options: optionsWithoutExactMatches,
|
||||
};
|
||||
};
|
||||
}
|
||||
129
apps/yaak-client/components/core/Editor/hyperlink/extension.ts
Normal file
129
apps/yaak-client/components/core/Editor/hyperlink/extension.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, hoverTooltip, MatchDecorator, ViewPlugin } from '@codemirror/view';
|
||||
import { activeWorkspaceIdAtom } from '../../../../hooks/useActiveWorkspace';
|
||||
import { copyToClipboard } from '../../../../lib/copy';
|
||||
import { createRequestAndNavigate } from '../../../../lib/createRequestAndNavigate';
|
||||
import { jotaiStore } from '../../../../lib/jotai';
|
||||
|
||||
const REGEX =
|
||||
/(https?:\/\/([-a-zA-Z0-9@:%._+*~#=]{1,256})+(\.[a-zA-Z0-9()]{1,6})?\b([-a-zA-Z0-9()@:%_+*.~#?&/={}[\]]*))/g;
|
||||
|
||||
const tooltip = hoverTooltip(
|
||||
(view, pos, side) => {
|
||||
const { from, text } = view.state.doc.lineAt(pos);
|
||||
let match: RegExpExecArray | null;
|
||||
let found: { start: number; end: number } | null = null;
|
||||
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: none
|
||||
while ((match = REGEX.exec(text))) {
|
||||
const start = from + match.index;
|
||||
const end = start + match[0].length;
|
||||
|
||||
if (pos >= start && pos <= end) {
|
||||
found = { start, end };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((found.start === pos && side < 0) || (found.end === pos && side > 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pos: found.start,
|
||||
end: found.end,
|
||||
create() {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
const link = text.substring(found?.start - from, found?.end - from);
|
||||
const dom = document.createElement('div');
|
||||
|
||||
const $open = document.createElement('a');
|
||||
$open.textContent = 'Open in browser';
|
||||
$open.href = link;
|
||||
$open.target = '_blank';
|
||||
$open.rel = 'noopener noreferrer';
|
||||
|
||||
const $copy = document.createElement('button');
|
||||
$copy.textContent = 'Copy to clipboard';
|
||||
$copy.addEventListener('click', () => {
|
||||
copyToClipboard(link);
|
||||
});
|
||||
|
||||
const $create = document.createElement('button');
|
||||
$create.textContent = 'Create new request';
|
||||
$create.addEventListener('click', async () => {
|
||||
await createRequestAndNavigate({
|
||||
model: 'http_request',
|
||||
workspaceId: workspaceId ?? 'n/a',
|
||||
url: link,
|
||||
});
|
||||
});
|
||||
|
||||
dom.appendChild($open);
|
||||
dom.appendChild($copy);
|
||||
if (workspaceId != null) {
|
||||
dom.appendChild($create);
|
||||
}
|
||||
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
hoverTime: 150,
|
||||
},
|
||||
);
|
||||
|
||||
const decorator = () => {
|
||||
const placeholderMatcher = new MatchDecorator({
|
||||
regexp: REGEX,
|
||||
decoration(match, view, matchStartPos) {
|
||||
const matchEndPos = matchStartPos + match[0].length - 1;
|
||||
|
||||
// Don't decorate if the cursor is inside the match
|
||||
for (const r of view.state.selection.ranges) {
|
||||
if (r.from > matchStartPos && r.to <= matchEndPos) {
|
||||
return Decoration.replace({});
|
||||
}
|
||||
}
|
||||
|
||||
const groupMatch = match[1];
|
||||
if (groupMatch == null) {
|
||||
// Should never happen, but make TS happy
|
||||
console.warn('Group match was empty', match);
|
||||
return Decoration.replace({});
|
||||
}
|
||||
|
||||
return Decoration.mark({
|
||||
class: 'hyperlink-widget',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = placeholderMatcher.createDeco(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
this.decorations = placeholderMatcher.updateDeco(update, this.decorations);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.decorations,
|
||||
provide: (plugin) =>
|
||||
EditorView.bidiIsolatedRanges.of((view) => {
|
||||
return view.plugin(plugin)?.decorations || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const hyperlink = [tooltip, decorator()];
|
||||
36
apps/yaak-client/components/core/Editor/json-lint.ts
Normal file
36
apps/yaak-client/components/core/Editor/json-lint.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Diagnostic } from '@codemirror/lint';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { parse as jsonLintParse } from '@prantlf/jsonlint';
|
||||
|
||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
||||
|
||||
export function jsonParseLinter() {
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
try {
|
||||
const doc = view.state.doc.toString();
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => '1'.repeat(m.length));
|
||||
jsonLintParse(escapedDoc);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
} catch (err: any) {
|
||||
if (!('location' in err)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// const line = location?.start?.line;
|
||||
// const column = location?.start?.column;
|
||||
if (err.location.start.offset) {
|
||||
return [
|
||||
{
|
||||
from: err.location.start.offset,
|
||||
to: err.location.start.offset,
|
||||
severity: 'error',
|
||||
message: err.message,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
}
|
||||
12
apps/yaak-client/components/core/Editor/pairs/extension.ts
Normal file
12
apps/yaak-client/components/core/Editor/pairs/extension.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parser } from './pairs';
|
||||
|
||||
const language = LRLanguage.define({
|
||||
name: 'pairs',
|
||||
parser,
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
export function pairs() {
|
||||
return new LanguageSupport(language, []);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
Sep: t.bracket,
|
||||
Key: t.attributeName,
|
||||
Value: t.string,
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
@top pairs { (Key Sep Value "\n")* }
|
||||
|
||||
@tokens {
|
||||
Sep { ":" }
|
||||
Key { ":"? ![:]+ }
|
||||
Value { ![\n]+ }
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
@@ -0,0 +1,5 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const pairs = 1,
|
||||
Key = 2,
|
||||
Sep = 3,
|
||||
Value = 4;
|
||||
29
apps/yaak-client/components/core/Editor/pairs/pairs.ts
Normal file
29
apps/yaak-client/components/core/Editor/pairs/pairs.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import { LRParser } from '@lezer/lr';
|
||||
import { highlight } from './highlight';
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "zQQOPOOOVOQO'#CaQQOPOOO[OSO,58{OOOO-E6_-E6_OaOQO1G.gOOOO7+$R7+$R",
|
||||
stateData: 'f~OQPO~ORRO~OSTO~OVUO~O',
|
||||
goto: ']UPPPPPVQQORSQ',
|
||||
nodeNames: '⚠ pairs Key Sep Value',
|
||||
maxTerm: 7,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData:
|
||||
"$]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#vVRQSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOh",
|
||||
tokenizers: [0, 1, 2],
|
||||
topRules: { pairs: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
termNames: {
|
||||
'0': '⚠',
|
||||
'1': '@top',
|
||||
'2': 'Key',
|
||||
'3': 'Sep',
|
||||
'4': 'Value',
|
||||
'5': '(Key Sep Value "\\n")+',
|
||||
'6': '␄',
|
||||
'7': '"\\n"',
|
||||
},
|
||||
});
|
||||
116
apps/yaak-client/components/core/Editor/searchMatchCount.ts
Normal file
116
apps/yaak-client/components/core/Editor/searchMatchCount.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { getSearchQuery, searchPanelOpen } from '@codemirror/search';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
* A CodeMirror extension that displays the total number of search matches
|
||||
* inside the built-in search panel.
|
||||
*/
|
||||
export function searchMatchCount(): Extension {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
private countEl: HTMLElement | null = null;
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.updateCount();
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// Recompute when doc changes, search state changes, or selection moves
|
||||
const query = getSearchQuery(update.state);
|
||||
const prevQuery = getSearchQuery(update.startState);
|
||||
const open = searchPanelOpen(update.state);
|
||||
const prevOpen = searchPanelOpen(update.startState);
|
||||
|
||||
if (update.docChanged || update.selectionSet || !query.eq(prevQuery) || open !== prevOpen) {
|
||||
this.updateCount();
|
||||
}
|
||||
}
|
||||
|
||||
private updateCount() {
|
||||
const state = this.view.state;
|
||||
const open = searchPanelOpen(state);
|
||||
const query = getSearchQuery(state);
|
||||
|
||||
if (!open) {
|
||||
this.removeCountEl();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureCountEl();
|
||||
|
||||
if (!query.search) {
|
||||
if (this.countEl) {
|
||||
this.countEl.textContent = '0/0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = state.selection.main;
|
||||
let count = 0;
|
||||
let currentIndex = 0;
|
||||
const MAX_COUNT = 9999;
|
||||
const cursor = query.getCursor(state);
|
||||
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||
count++;
|
||||
const match = result.value;
|
||||
if (match.from <= selection.from && match.to >= selection.to) {
|
||||
currentIndex = count;
|
||||
}
|
||||
if (count > MAX_COUNT) break;
|
||||
}
|
||||
|
||||
if (this.countEl) {
|
||||
if (count > MAX_COUNT) {
|
||||
this.countEl.textContent = `${MAX_COUNT}+`;
|
||||
} else if (count === 0) {
|
||||
this.countEl.textContent = '0/0';
|
||||
} else if (currentIndex > 0) {
|
||||
this.countEl.textContent = `${currentIndex}/${count}`;
|
||||
} else {
|
||||
this.countEl.textContent = `0/${count}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureCountEl() {
|
||||
// Find the search panel in the editor DOM
|
||||
const panel = this.view.dom.querySelector('.cm-search');
|
||||
if (!panel) {
|
||||
this.countEl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.countEl && this.countEl.parentElement === panel) {
|
||||
return; // Already attached
|
||||
}
|
||||
|
||||
this.countEl = document.createElement('span');
|
||||
this.countEl.className = 'cm-search-match-count';
|
||||
|
||||
// Reorder: insert prev button, then next button, then count after the search input
|
||||
const searchInput = panel.querySelector('input');
|
||||
const prevBtn = panel.querySelector('button[name="prev"]');
|
||||
const nextBtn = panel.querySelector('button[name="next"]');
|
||||
if (searchInput && searchInput.parentElement === panel) {
|
||||
searchInput.after(this.countEl);
|
||||
if (prevBtn) this.countEl.after(prevBtn);
|
||||
if (nextBtn && prevBtn) prevBtn.after(nextBtn);
|
||||
} else {
|
||||
panel.prepend(this.countEl);
|
||||
}
|
||||
}
|
||||
|
||||
private removeCountEl() {
|
||||
if (this.countEl) {
|
||||
this.countEl.remove();
|
||||
this.countEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.removeCountEl();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
47
apps/yaak-client/components/core/Editor/singleLine.ts
Normal file
47
apps/yaak-client/components/core/Editor/singleLine.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Extension, TransactionSpec } from '@codemirror/state';
|
||||
import { EditorSelection, EditorState, Transaction } from '@codemirror/state';
|
||||
|
||||
/**
|
||||
* A CodeMirror extension that forces single-line input by stripping
|
||||
* all newline characters from user input, including pasted content.
|
||||
*
|
||||
* This extension uses a transaction filter to intercept user input,
|
||||
* removes any newline characters, and adjusts the selection to the end
|
||||
* of the inserted text.
|
||||
*
|
||||
* IME composition events are ignored to preserve proper input behavior
|
||||
* for non-Latin languages.
|
||||
*
|
||||
* @returns A CodeMirror extension that enforces single-line editing.
|
||||
*/
|
||||
export function singleLineExtensions(): Extension {
|
||||
return EditorState.transactionFilter.of(
|
||||
(tr: Transaction): TransactionSpec | readonly TransactionSpec[] => {
|
||||
if (!tr.isUserEvent('input') || tr.isUserEvent('input.type.compose')) return tr;
|
||||
|
||||
const changes: { from: number; to: number; insert: string }[] = [];
|
||||
|
||||
tr.changes.iterChanges((_fromA, toA, fromB, _toB, inserted) => {
|
||||
let insert = '';
|
||||
for (const line of inserted.iterLines()) {
|
||||
insert += line.replace(/\n/g, '');
|
||||
}
|
||||
|
||||
if (insert !== inserted.toString()) {
|
||||
changes.push({ from: fromB, to: toA, insert });
|
||||
}
|
||||
});
|
||||
|
||||
const lastChange = changes[changes.length - 1];
|
||||
if (lastChange == null) return tr;
|
||||
|
||||
const selection = EditorSelection.cursor(lastChange.from + lastChange.insert.length);
|
||||
|
||||
return {
|
||||
changes,
|
||||
selection,
|
||||
userEvent: tr.annotation(Transaction.userEvent) ?? undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
12
apps/yaak-client/components/core/Editor/text/extension.ts
Normal file
12
apps/yaak-client/components/core/Editor/text/extension.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parser } from './text';
|
||||
|
||||
export const textLanguage = LRLanguage.define({
|
||||
name: 'text',
|
||||
parser,
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
export function text() {
|
||||
return new LanguageSupport(textLanguage);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@top Template { Text }
|
||||
|
||||
@tokens {
|
||||
Text { ![]+ }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const Template = 1,
|
||||
Text = 2;
|
||||
16
apps/yaak-client/components/core/Editor/text/text.ts
Normal file
16
apps/yaak-client/components/core/Editor/text/text.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import { LRParser } from '@lezer/lr';
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: '[OQOPOOQOOOOO',
|
||||
stateData: 'V~OQPO~O',
|
||||
goto: 'QPP',
|
||||
nodeNames: '⚠ Template Text',
|
||||
maxTerm: 3,
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 0,
|
||||
tokenData: "p~RRO;'S[;'S;=`j<%lO[~aRQ~O;'S[;'S;=`j<%lO[~mP;=`<%l[",
|
||||
tokenizers: [0],
|
||||
topRules: { Template: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parser } from './timeline';
|
||||
|
||||
export const timelineLanguage = LRLanguage.define({
|
||||
name: 'timeline',
|
||||
parser,
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
export function timeline() {
|
||||
return new LanguageSupport(timelineLanguage);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
OutgoingText: t.propertyName, // > lines - primary color (matches timeline icons)
|
||||
IncomingText: t.tagName, // < lines - info color (matches timeline icons)
|
||||
InfoText: t.comment, // * lines - subtle color (matches timeline icons)
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
@top Timeline { line* }
|
||||
|
||||
line { OutgoingLine | IncomingLine | InfoLine | PlainLine }
|
||||
|
||||
@skip {} {
|
||||
OutgoingLine { OutgoingText Newline }
|
||||
IncomingLine { IncomingText Newline }
|
||||
InfoLine { InfoText Newline }
|
||||
PlainLine { PlainText Newline }
|
||||
}
|
||||
|
||||
@tokens {
|
||||
OutgoingText { "> " ![\n]* }
|
||||
IncomingText { "< " ![\n]* }
|
||||
InfoText { "* " ![\n]* }
|
||||
PlainText { ![\n]+ }
|
||||
Newline { "\n" }
|
||||
@precedence { OutgoingText, IncomingText, InfoText, PlainText }
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
@@ -0,0 +1,12 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
Timeline = 1,
|
||||
OutgoingLine = 2,
|
||||
OutgoingText = 3,
|
||||
Newline = 4,
|
||||
IncomingLine = 5,
|
||||
IncomingText = 6,
|
||||
InfoLine = 7,
|
||||
InfoText = 8,
|
||||
PlainLine = 9,
|
||||
PlainText = 10
|
||||
18
apps/yaak-client/components/core/Editor/timeline/timeline.ts
Normal file
18
apps/yaak-client/components/core/Editor/timeline/timeline.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {highlight} from "./highlight"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e",
|
||||
stateData: "z~ORPOUQOWROYSO~OSWO~OSXO~OSYO~OSZO~ORUWYW~",
|
||||
goto: "m^PP_PP_P_P_PcPiTTOVQVOR[VTUOV",
|
||||
nodeNames: "⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText",
|
||||
maxTerm: 13,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData: "%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}",
|
||||
tokenizers: [0],
|
||||
topRules: {"Timeline":[0,1]},
|
||||
tokenPrec: 36
|
||||
})
|
||||
127
apps/yaak-client/components/core/Editor/twig/completion.ts
Normal file
127
apps/yaak-client/components/core/Editor/twig/completion.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
|
||||
import { startCompletion } from '@codemirror/autocomplete';
|
||||
import type { TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
|
||||
const openTag = '${[ ';
|
||||
const closeTag = ' ]}';
|
||||
|
||||
export type TwigCompletionOptionVariable = {
|
||||
type: 'variable';
|
||||
};
|
||||
|
||||
export type TwigCompletionOptionNamespace = {
|
||||
type: 'namespace';
|
||||
};
|
||||
|
||||
export type TwigCompletionOptionFunction = TemplateFunction & {
|
||||
type: 'function';
|
||||
};
|
||||
|
||||
export type TwigCompletionOption = (
|
||||
| TwigCompletionOptionFunction
|
||||
| TwigCompletionOptionVariable
|
||||
| TwigCompletionOptionNamespace
|
||||
) & {
|
||||
name: string;
|
||||
label: string | HTMLElement;
|
||||
description?: string;
|
||||
onClick: (rawTag: string, startPos: number) => void;
|
||||
value: string | null;
|
||||
invalid?: boolean;
|
||||
};
|
||||
|
||||
export interface TwigCompletionConfig {
|
||||
options: TwigCompletionOption[];
|
||||
}
|
||||
|
||||
const MIN_MATCH_NAME = 1;
|
||||
|
||||
export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
return function completions(context: CompletionContext) {
|
||||
const toStartOfName = context.matchBefore(/[\w_.]*/);
|
||||
const toMatch = toStartOfName ?? null;
|
||||
|
||||
if (toMatch === null) return null;
|
||||
|
||||
const matchLen = toMatch.to - toMatch.from;
|
||||
if (!context.explicit && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completions: Completion[] = options
|
||||
.flatMap((o): Completion[] => {
|
||||
const matchSegments = toMatch.text.replace(/^\$/, '').split('.');
|
||||
const optionSegments = o.name.split('.');
|
||||
|
||||
// If not on the last segment, only complete the namespace
|
||||
if (matchSegments.length < optionSegments.length) {
|
||||
const prefix = optionSegments.slice(0, matchSegments.length).join('.');
|
||||
return [
|
||||
{
|
||||
label: `${prefix}.*`,
|
||||
type: 'namespace',
|
||||
detail: 'namespace',
|
||||
apply: (view, _completion, from, to) => {
|
||||
const insert = `${prefix}.`;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: insert },
|
||||
selection: { anchor: from + insert.length },
|
||||
});
|
||||
// Leave the autocomplete open so the user can continue typing the rest of the namespace
|
||||
startCompletion(view);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// If on the last segment, wrap the entire tag
|
||||
const inner = o.type === 'function' ? `${o.name}()` : o.name;
|
||||
return [
|
||||
{
|
||||
label: o.name,
|
||||
info: o.description,
|
||||
detail: o.type,
|
||||
type: o.type === 'variable' ? 'variable' : 'function',
|
||||
apply: (view, _completion, from, to) => {
|
||||
const insert = openTag + inner + closeTag;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: insert },
|
||||
selection: { anchor: from + insert.length },
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
})
|
||||
.filter((v) => v != null);
|
||||
|
||||
const uniqueCompletions = uniqueBy(completions, 'label');
|
||||
const sortedCompletions = uniqueCompletions.sort((a, b) => {
|
||||
const boostDiff = defaultBoost(b) - defaultBoost(a);
|
||||
if (boostDiff !== 0) return boostDiff;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return {
|
||||
matchLen,
|
||||
validFor: () => true, // Not really sure why this is all it needs
|
||||
from: toMatch.from,
|
||||
options: sortedCompletions,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function uniqueBy<T, K extends keyof T>(arr: T[], key: K): T[] {
|
||||
const map = new Map<T[K], T>();
|
||||
for (const item of arr) {
|
||||
map.set(item[key], item); // overwrites → keeps last
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
export function defaultBoost(o: Completion) {
|
||||
if (o.type === 'variable') return 4;
|
||||
if (o.type === 'constant') return 3;
|
||||
if (o.type === 'function') return 2;
|
||||
if (o.type === 'namespace') return 1;
|
||||
return 0;
|
||||
}
|
||||
85
apps/yaak-client/components/core/Editor/twig/extension.ts
Normal file
85
apps/yaak-client/components/core/Editor/twig/extension.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { LanguageSupport } from '@codemirror/language';
|
||||
import { LRLanguage } from '@codemirror/language';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import type { WrappedEnvironmentVariable } from '../../../../hooks/useEnvironmentVariables';
|
||||
import type { GenericCompletionConfig } from '../genericCompletion';
|
||||
import { genericCompletion } from '../genericCompletion';
|
||||
import { textLanguage } from '../text/extension';
|
||||
import type { TwigCompletionOption } from './completion';
|
||||
import { twigCompletion } from './completion';
|
||||
import { templateTagsPlugin } from './templateTags';
|
||||
import { parser as twigParser } from './twig';
|
||||
|
||||
export function twig({
|
||||
base,
|
||||
environmentVariables,
|
||||
completionOptions,
|
||||
autocomplete,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
extraExtensions,
|
||||
}: {
|
||||
base: LanguageSupport;
|
||||
environmentVariables: WrappedEnvironmentVariable[];
|
||||
completionOptions: TwigCompletionOption[];
|
||||
autocomplete?: GenericCompletionConfig;
|
||||
onClickVariable: (option: WrappedEnvironmentVariable, tagValue: string, startPos: number) => void;
|
||||
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
|
||||
onClickPathParameter: (name: string) => void;
|
||||
extraExtensions: Extension[];
|
||||
}) {
|
||||
const language = mixLanguage(base);
|
||||
|
||||
const variableOptions: TwigCompletionOption[] =
|
||||
environmentVariables.map((v) => ({
|
||||
name: v.variable.name,
|
||||
value: v.variable.value,
|
||||
type: 'variable',
|
||||
label: v.variable.name,
|
||||
description: `Inherited from ${v.source}`,
|
||||
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
|
||||
})) ?? [];
|
||||
|
||||
const options = [...variableOptions, ...completionOptions];
|
||||
const completions = twigCompletion({ options });
|
||||
|
||||
return [
|
||||
language,
|
||||
base.support,
|
||||
language.data.of({ autocomplete: completions }),
|
||||
base.language.data.of({ autocomplete: completions }),
|
||||
language.data.of({ autocomplete: genericCompletion(autocomplete) }),
|
||||
base.language.data.of({ autocomplete: genericCompletion(autocomplete) }),
|
||||
templateTagsPlugin(options, onClickMissingVariable),
|
||||
...extraExtensions,
|
||||
];
|
||||
}
|
||||
|
||||
const mixedLanguagesCache: Record<string, LRLanguage> = {};
|
||||
|
||||
function mixLanguage(base: LanguageSupport): LRLanguage {
|
||||
// It can be slow to mix languages when there are hundreds of editors, so we'll cache them to speed it up
|
||||
const cached = mixedLanguagesCache[base.language.name];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const parser = twigParser.configure({
|
||||
wrap: parseMixed((node) => {
|
||||
// If the base language is text, we can overwrite at the top
|
||||
if (base.language.name !== textLanguage.name && !node.type.isTop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
parser: base.language.parser,
|
||||
overlay: (node) => node.type.name === 'Text',
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const language = LRLanguage.define({ name: 'twig', parser });
|
||||
mixedLanguagesCache[base.language.name] = language;
|
||||
return language;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
TagOpen: t.bracket,
|
||||
TagClose: t.bracket,
|
||||
TagContent: t.keyword,
|
||||
});
|
||||
108
apps/yaak-client/components/core/Editor/twig/pathParameters.ts
Normal file
108
apps/yaak-client/components/core/Editor/twig/pathParameters.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import type { Range } from '@codemirror/state';
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
|
||||
class PathPlaceholderWidget extends WidgetType {
|
||||
readonly #clickListenerCallback: () => void;
|
||||
|
||||
constructor(
|
||||
readonly rawText: string,
|
||||
readonly startPos: number,
|
||||
readonly onClick: () => void,
|
||||
) {
|
||||
super();
|
||||
this.#clickListenerCallback = () => {
|
||||
this.onClick?.();
|
||||
};
|
||||
}
|
||||
|
||||
eq(other: PathPlaceholderWidget) {
|
||||
return this.startPos === other.startPos && this.rawText === other.rawText;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = 'x-theme-templateTag x-theme-templateTag--secondary template-tag';
|
||||
elt.textContent = this.rawText;
|
||||
elt.addEventListener('click', this.#clickListenerCallback);
|
||||
return elt;
|
||||
}
|
||||
|
||||
destroy(dom: HTMLElement) {
|
||||
dom.removeEventListener('click', this.#clickListenerCallback);
|
||||
super.destroy(dom);
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pathParameters(
|
||||
view: EditorView,
|
||||
onClickPathParameter: (name: string) => void,
|
||||
): DecorationSet {
|
||||
const widgets: Range<Decoration>[] = [];
|
||||
const tree = syntaxTree(view.state);
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
tree.iterate({
|
||||
from,
|
||||
to,
|
||||
enter(node) {
|
||||
if (node.name === 'Text') {
|
||||
// Find the `url` node and then jump into it to find the placeholders
|
||||
for (let i = node.from; i < node.to; i++) {
|
||||
const innerTree = syntaxTree(view.state).resolveInner(i);
|
||||
if (innerTree.node.name === 'url') {
|
||||
innerTree.toTree().iterate({
|
||||
enter(node) {
|
||||
if (node.name !== 'Placeholder') return;
|
||||
const globalFrom = innerTree.node.from + node.from;
|
||||
const globalTo = innerTree.node.from + node.to;
|
||||
const rawText = view.state.doc.sliceString(globalFrom, globalTo);
|
||||
const onClick = () => onClickPathParameter(rawText);
|
||||
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
|
||||
const deco = Decoration.replace({ widget, inclusive: false });
|
||||
widgets.push(deco.range(globalFrom, globalTo));
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Widgets must be sorted start to end
|
||||
widgets.sort((a, b) => a.from - b.from);
|
||||
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
|
||||
export function pathParametersPlugin(onClickPathParameter: (name: string) => void) {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = pathParameters(view, onClickPathParameter);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
this.decorations = pathParameters(update.view, onClickPathParameter);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations(v) {
|
||||
return v.decorations;
|
||||
},
|
||||
provide(plugin) {
|
||||
return EditorView.atomicRanges.of((view) => {
|
||||
return view.plugin(plugin)?.decorations || Decoration.none;
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
224
apps/yaak-client/components/core/Editor/twig/templateTags.ts
Normal file
224
apps/yaak-client/components/core/Editor/twig/templateTags.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import type { Range } from '@codemirror/state';
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
import type { SyntaxNodeRef } from '@lezer/common';
|
||||
import { applyFormInputDefaults, validateTemplateFunctionArgs } from '@yaakapp-internal/lib';
|
||||
import type { FormInput, JsonPrimitive, TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
import { parseTemplate } from '@yaakapp-internal/templates';
|
||||
import type { TwigCompletionOption } from './completion';
|
||||
import { collectArgumentValues } from './util';
|
||||
|
||||
class TemplateTagWidget extends WidgetType {
|
||||
readonly #clickListenerCallback: () => void;
|
||||
|
||||
constructor(
|
||||
readonly option: TwigCompletionOption,
|
||||
readonly rawTag: string,
|
||||
readonly startPos: number,
|
||||
) {
|
||||
super();
|
||||
this.#clickListenerCallback = () => {
|
||||
this.option.onClick?.(this.rawTag, this.startPos);
|
||||
};
|
||||
}
|
||||
|
||||
eq(other: TemplateTagWidget) {
|
||||
return (
|
||||
this.option.name === other.option.name &&
|
||||
this.option.type === other.option.type &&
|
||||
this.option.value === other.option.value &&
|
||||
this.rawTag === other.rawTag &&
|
||||
this.startPos === other.startPos
|
||||
);
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = `x-theme-templateTag template-tag ${
|
||||
this.option.invalid
|
||||
? 'x-theme-templateTag--danger'
|
||||
: this.option.type === 'variable'
|
||||
? 'x-theme-templateTag--primary'
|
||||
: 'x-theme-templateTag--info'
|
||||
}`;
|
||||
elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? '');
|
||||
elt.setAttribute('data-tag-type', this.option.type);
|
||||
if (typeof this.option.label === 'string') elt.textContent = this.option.label;
|
||||
else elt.appendChild(this.option.label);
|
||||
elt.addEventListener('click', this.#clickListenerCallback);
|
||||
return elt;
|
||||
}
|
||||
|
||||
destroy(dom: HTMLElement) {
|
||||
dom.removeEventListener('click', this.#clickListenerCallback);
|
||||
super.destroy(dom);
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function templateTags(
|
||||
view: EditorView,
|
||||
options: TwigCompletionOption[],
|
||||
onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void,
|
||||
): DecorationSet {
|
||||
const widgets: Range<Decoration>[] = [];
|
||||
const tree = syntaxTree(view.state);
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
tree.iterate({
|
||||
from,
|
||||
to,
|
||||
enter(node) {
|
||||
if (node.name === 'Tag') {
|
||||
// Don't decorate if the cursor is inside the match
|
||||
if (isSelectionInsideNode(view, node)) return;
|
||||
|
||||
const rawTag = view.state.doc.sliceString(node.from, node.to);
|
||||
|
||||
// TODO: Search `node.tree` instead of using Regex here
|
||||
const inner = rawTag.replace(/^\$\{\[\s*/, '').replace(/\s*]}$/, '');
|
||||
let name = inner.match(/([\w.]+)[(]/)?.[1] ?? inner;
|
||||
|
||||
if (inner.includes('\n')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The beta named the function `Response` but was changed in stable.
|
||||
// Keep this here for a while because there's no easy way to migrate
|
||||
if (name === 'Response') {
|
||||
name = 'response';
|
||||
}
|
||||
|
||||
let option = options.find(
|
||||
(o) => o.name === name || (o.type === 'function' && o.aliases?.includes(name)),
|
||||
);
|
||||
|
||||
if (option == null) {
|
||||
const from = node.from; // Cache here so the reference doesn't change
|
||||
option = {
|
||||
type: 'variable',
|
||||
invalid: true,
|
||||
name: inner,
|
||||
value: null,
|
||||
label: inner,
|
||||
onClick: () => {
|
||||
onClickMissingVariable(name, rawTag, from);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (option.type === 'function') {
|
||||
const tokens = parseTemplate(rawTag);
|
||||
const rawValues = collectArgumentValues(tokens, option);
|
||||
const values = applyFormInputDefaults(option.args, rawValues);
|
||||
const label = makeFunctionLabel(option, values);
|
||||
const validationErr = validateTemplateFunctionArgs(option.name, option.args, values);
|
||||
option = { ...option, label, invalid: !!validationErr }; // Clone so we don't mutate the original
|
||||
}
|
||||
|
||||
const widget = new TemplateTagWidget(option, rawTag, node.from);
|
||||
const deco = Decoration.replace({ widget, inclusive: true });
|
||||
widgets.push(deco.range(node.from, node.to));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Widgets must be sorted start to end
|
||||
widgets.sort((a, b) => a.from - b.from);
|
||||
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
|
||||
export function templateTagsPlugin(
|
||||
options: TwigCompletionOption[],
|
||||
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void,
|
||||
) {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = templateTags(view, options, onClickMissingVariable);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
this.decorations = templateTags(update.view, options, onClickMissingVariable);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations(v) {
|
||||
return v.decorations;
|
||||
},
|
||||
provide(plugin) {
|
||||
return EditorView.atomicRanges.of((view) => {
|
||||
return view.plugin(plugin)?.decorations || Decoration.none;
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function isSelectionInsideNode(view: EditorView, node: SyntaxNodeRef) {
|
||||
for (const r of view.state.selection.ranges) {
|
||||
if (r.from > node.from && r.to < node.to) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function makeFunctionLabel(
|
||||
fn: TemplateFunction,
|
||||
values: { [p: string]: JsonPrimitive | undefined },
|
||||
): HTMLElement | string {
|
||||
if (fn.args.length === 0) return fn.name;
|
||||
|
||||
const $outer = document.createElement('span');
|
||||
$outer.className = 'fn';
|
||||
const $bOpen = document.createElement('span');
|
||||
$bOpen.className = 'fn-bracket';
|
||||
$bOpen.textContent = '(';
|
||||
$outer.appendChild(document.createTextNode(fn.name));
|
||||
$outer.appendChild($bOpen);
|
||||
|
||||
const $inner = document.createElement('span');
|
||||
$inner.className = 'fn-inner';
|
||||
$inner.title = '';
|
||||
fn.previewArgs?.forEach((name: string, i: number, all: string[]) => {
|
||||
const v = String(values[name] || '');
|
||||
if (!v) return;
|
||||
if (all.length > 1) {
|
||||
const $c = document.createElement('span');
|
||||
$c.className = 'fn-arg-name';
|
||||
$c.textContent = i > 0 ? `, ${name}=` : `${name}=`;
|
||||
$inner.appendChild($c);
|
||||
}
|
||||
|
||||
const $v = document.createElement('span');
|
||||
$v.className = 'fn-arg-value';
|
||||
$v.textContent = v.includes(' ') ? `'${v}'` : v;
|
||||
$inner.appendChild($v);
|
||||
});
|
||||
fn.args.forEach((a: FormInput, i: number) => {
|
||||
if (!('name' in a)) return;
|
||||
const v = values[a.name];
|
||||
if (v == null) return;
|
||||
if (i > 0) $inner.title += '\n';
|
||||
$inner.title += `${a.name} = ${JSON.stringify(v)}`;
|
||||
});
|
||||
|
||||
if ($inner.childNodes.length === 0) {
|
||||
$inner.appendChild(document.createTextNode('…'));
|
||||
}
|
||||
|
||||
$outer.appendChild($inner);
|
||||
|
||||
const $bClose = document.createElement('span');
|
||||
$bClose.className = 'fn-bracket';
|
||||
$bClose.textContent = ')';
|
||||
$outer.appendChild($bClose);
|
||||
|
||||
return $outer;
|
||||
}
|
||||
17
apps/yaak-client/components/core/Editor/twig/twig.grammar
Normal file
17
apps/yaak-client/components/core/Editor/twig/twig.grammar
Normal file
@@ -0,0 +1,17 @@
|
||||
@top Template { (Tag | Text)* }
|
||||
|
||||
@local tokens {
|
||||
TagClose { "]}" }
|
||||
@else TagContent
|
||||
}
|
||||
|
||||
@skip { } {
|
||||
TagOpen { "${[" }
|
||||
Tag { TagOpen (TagContent)+ TagClose }
|
||||
}
|
||||
|
||||
@tokens {
|
||||
Text { ![$] Text? | "$" (@eof | ![{] Text? | "{" ![[] Text?) }
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const Template = 1,
|
||||
Tag = 2,
|
||||
TagOpen = 3,
|
||||
TagContent = 4,
|
||||
TagClose = 5,
|
||||
Text = 6;
|
||||
108
apps/yaak-client/components/core/Editor/twig/twig.test.ts
Normal file
108
apps/yaak-client/components/core/Editor/twig/twig.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: We're testing this, specifically
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { parser } from './twig';
|
||||
|
||||
function getNodeNames(input: string): string[] {
|
||||
const tree = parser.parse(input);
|
||||
const nodes: string[] = [];
|
||||
const cursor = tree.cursor();
|
||||
do {
|
||||
if (cursor.name !== 'Template') {
|
||||
nodes.push(cursor.name);
|
||||
}
|
||||
} while (cursor.next());
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function hasTag(input: string): boolean {
|
||||
return getNodeNames(input).includes('Tag');
|
||||
}
|
||||
|
||||
function hasError(input: string): boolean {
|
||||
return getNodeNames(input).includes('⚠');
|
||||
}
|
||||
|
||||
describe('twig grammar', () => {
|
||||
describe('${[var]} format (valid template tags)', () => {
|
||||
test('parses simple variable as Tag', () => {
|
||||
expect(hasTag('${[var]}')).toBe(true);
|
||||
expect(hasError('${[var]}')).toBe(false);
|
||||
});
|
||||
|
||||
test('parses variable with whitespace as Tag', () => {
|
||||
expect(hasTag('${[ var ]}')).toBe(true);
|
||||
expect(hasError('${[ var ]}')).toBe(false);
|
||||
});
|
||||
|
||||
test('parses embedded variable as Tag', () => {
|
||||
expect(hasTag('hello ${[name]} world')).toBe(true);
|
||||
expect(hasError('hello ${[name]} world')).toBe(false);
|
||||
});
|
||||
|
||||
test('parses function call as Tag', () => {
|
||||
expect(hasTag('${[fn()]}')).toBe(true);
|
||||
expect(hasError('${[fn()]}')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('${var} format (should be plain text, not tags)', () => {
|
||||
test('parses ${var} as plain Text without errors', () => {
|
||||
expect(hasTag('${var}')).toBe(false);
|
||||
expect(hasError('${var}')).toBe(false);
|
||||
});
|
||||
|
||||
test('parses embedded ${var} as plain Text', () => {
|
||||
expect(hasTag('hello ${name} world')).toBe(false);
|
||||
expect(hasError('hello ${name} world')).toBe(false);
|
||||
});
|
||||
|
||||
test('parses JSON with ${var} as plain Text', () => {
|
||||
const json = '{"key": "${value}"}';
|
||||
expect(hasTag(json)).toBe(false);
|
||||
expect(hasError(json)).toBe(false);
|
||||
});
|
||||
|
||||
test('parses multiple ${var} as plain Text', () => {
|
||||
expect(hasTag('${a} and ${b}')).toBe(false);
|
||||
expect(hasError('${a} and ${b}')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed content', () => {
|
||||
test('distinguishes ${var} from ${[var]} in same string', () => {
|
||||
const input = '${plain} and ${[tag]}';
|
||||
expect(hasTag(input)).toBe(true);
|
||||
expect(hasError(input)).toBe(false);
|
||||
});
|
||||
|
||||
test('parses JSON with ${[var]} as having Tag', () => {
|
||||
const json = '{"key": "${[value]}"}';
|
||||
expect(hasTag(json)).toBe(true);
|
||||
expect(hasError(json)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('handles $ at end of string', () => {
|
||||
expect(hasError('hello$')).toBe(false);
|
||||
expect(hasTag('hello$')).toBe(false);
|
||||
});
|
||||
|
||||
test('handles ${ at end of string without crash', () => {
|
||||
// Incomplete syntax may produce errors, but should not crash
|
||||
expect(() => parser.parse('hello${')).not.toThrow();
|
||||
});
|
||||
|
||||
test('handles ${[ without closing without crash', () => {
|
||||
// Unclosed tag may produce partial match, but should not crash
|
||||
expect(() => parser.parse('${[unclosed')).not.toThrow();
|
||||
});
|
||||
|
||||
test('handles empty ${[]}', () => {
|
||||
// Empty tags may or may not be valid depending on grammar
|
||||
// Just ensure no crash
|
||||
expect(() => parser.parse('${[]}')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
20
apps/yaak-client/components/core/Editor/twig/twig.ts
Normal file
20
apps/yaak-client/components/core/Editor/twig/twig.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import { LocalTokenGroup, LRParser } from '@lezer/lr';
|
||||
import { highlight } from './highlight';
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states:
|
||||
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
|
||||
stateData: 'g~OUROYPO~OSTO~OSTOTXO~O',
|
||||
goto: 'nXPPY^PPPbhTROSTQOSQSORVSQUQRWU',
|
||||
nodeNames: '⚠ Template Tag TagOpen TagContent TagClose Text',
|
||||
maxTerm: 10,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 2,
|
||||
tokenData:
|
||||
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~",
|
||||
tokenizers: [1, new LocalTokenGroup('b~RP#P#QU~XP#q#r[~aOT~~', 17, 4)],
|
||||
topRules: { Template: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
});
|
||||
37
apps/yaak-client/components/core/Editor/twig/util.ts
Normal file
37
apps/yaak-client/components/core/Editor/twig/util.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { FormInput, TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
import type { Tokens } from '@yaakapp-internal/templates';
|
||||
|
||||
/**
|
||||
* Process the initial tokens from the template and merge those with the default values pulled from
|
||||
* the template function definition.
|
||||
*/
|
||||
export function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) {
|
||||
const initial: Record<string, string | boolean> = {};
|
||||
const initialArgs =
|
||||
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
|
||||
? initialTokens.tokens[0]?.val.args
|
||||
: [];
|
||||
|
||||
const processArg = (arg: FormInput) => {
|
||||
if ('inputs' in arg && arg.inputs) {
|
||||
arg.inputs.forEach(processArg);
|
||||
}
|
||||
if (!('name' in arg)) return;
|
||||
|
||||
const initialArg = initialArgs.find((a) => a.name === arg.name);
|
||||
const initialArgValue =
|
||||
initialArg?.value.type === 'str'
|
||||
? initialArg?.value.text
|
||||
: initialArg?.value.type === 'bool'
|
||||
? initialArg.value.value
|
||||
: undefined;
|
||||
const value = initialArgValue ?? arg.defaultValue;
|
||||
if (value != null) {
|
||||
initial[arg.name] = value;
|
||||
}
|
||||
};
|
||||
|
||||
templateFunction.args.forEach(processArg);
|
||||
|
||||
return initial;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { genericCompletion } from '../genericCompletion';
|
||||
|
||||
export const completions = genericCompletion({
|
||||
options: [
|
||||
{ label: 'http://', type: 'constant' },
|
||||
{ label: 'https://', type: 'constant' },
|
||||
],
|
||||
minMatch: 1,
|
||||
});
|
||||
12
apps/yaak-client/components/core/Editor/url/extension.ts
Normal file
12
apps/yaak-client/components/core/Editor/url/extension.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parser } from './url';
|
||||
|
||||
const urlLanguage = LRLanguage.define({
|
||||
name: 'url',
|
||||
parser,
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
export function url() {
|
||||
return new LanguageSupport(urlLanguage, []);
|
||||
}
|
||||
10
apps/yaak-client/components/core/Editor/url/highlight.ts
Normal file
10
apps/yaak-client/components/core/Editor/url/highlight.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
Protocol: t.comment,
|
||||
Placeholder: t.emphasis,
|
||||
// PathSegment: t.tagName,
|
||||
// Host: t.variableName,
|
||||
// Path: t.bool,
|
||||
// Query: t.string,
|
||||
});
|
||||
19
apps/yaak-client/components/core/Editor/url/url.grammar
Normal file
19
apps/yaak-client/components/core/Editor/url/url.grammar
Normal file
@@ -0,0 +1,19 @@
|
||||
@top url { Protocol? Host Path? Query? }
|
||||
|
||||
Path { ("/" (Placeholder | PathSegment))+ }
|
||||
|
||||
Query { "?" queryPair ("&" queryPair)* }
|
||||
|
||||
@tokens {
|
||||
Protocol { $[a-zA-Z]+ "://" }
|
||||
Host { $[a-zA-Z0-9-_.:\[\]]+ }
|
||||
@precedence { Protocol, Host }
|
||||
|
||||
Placeholder { ":" ![/?#]+ }
|
||||
PathSegment { ![?#/]+ }
|
||||
@precedence { Placeholder, PathSegment }
|
||||
|
||||
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
9
apps/yaak-client/components/core/Editor/url/url.terms.ts
Normal file
9
apps/yaak-client/components/core/Editor/url/url.terms.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const url = 1,
|
||||
Protocol = 2,
|
||||
Host = 3,
|
||||
Port = 4,
|
||||
Path = 5,
|
||||
Placeholder = 6,
|
||||
PathSegment = 7,
|
||||
Query = 8;
|
||||
20
apps/yaak-client/components/core/Editor/url/url.ts
Normal file
20
apps/yaak-client/components/core/Editor/url/url.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import { LRParser } from '@lezer/lr';
|
||||
import { highlight } from './highlight';
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states:
|
||||
"!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c",
|
||||
stateData: '!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~',
|
||||
goto: 'nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[',
|
||||
nodeNames: '⚠ url Protocol Host Path Placeholder PathSegment Query',
|
||||
maxTerm: 14,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 2,
|
||||
tokenData:
|
||||
".i~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+W!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)RQ)YUTQUQOs)Rt!P)R!Q!a)R!b;'S)R;'S;=`)l<%lO)RQ)oP;=`<%l)RR){cRPTQUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)R~+]O[~V+fe]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],w!]!_!j!_!`&u!`!a!j!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jR-OdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.^!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.aP!P!Q.dP.iOQP",
|
||||
tokenizers: [0, 1, 2],
|
||||
topRules: { url: [0, 1] },
|
||||
tokenPrec: 63,
|
||||
});
|
||||
258
apps/yaak-client/components/core/EventViewer.tsx
Normal file
258
apps/yaak-client/components/core/EventViewer.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
import { format } from 'date-fns';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useEventViewerKeyboard } from '../../hooks/useEventViewerKeyboard';
|
||||
import { CopyIconButton } from '../CopyIconButton';
|
||||
import { AutoScroller } from './AutoScroller';
|
||||
import { Banner } from './Banner';
|
||||
import { Button } from './Button';
|
||||
import { Separator } from './Separator';
|
||||
import { SplitLayout } from './SplitLayout';
|
||||
import { HStack } from './Stacks';
|
||||
import { IconButton } from './IconButton';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface EventViewerProps<T> {
|
||||
/** Array of events to display */
|
||||
events: T[];
|
||||
|
||||
/** Get unique key for each event */
|
||||
getEventKey: (event: T, index: number) => string;
|
||||
|
||||
/** Render the event row - receives event, index, isActive, and onClick */
|
||||
renderRow: (props: {
|
||||
event: T;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) => ReactNode;
|
||||
|
||||
/** Render the detail pane for the selected event */
|
||||
renderDetail?: (props: { event: T; index: number; onClose: () => void }) => ReactNode;
|
||||
|
||||
/** Optional header above the event list (e.g., connection status) */
|
||||
header?: ReactNode;
|
||||
|
||||
/** Error message to display as a banner */
|
||||
error?: string | null;
|
||||
|
||||
/** Name for SplitLayout state persistence */
|
||||
splitLayoutName: string;
|
||||
|
||||
/** Default ratio for the split (0.0 - 1.0) */
|
||||
defaultRatio?: number;
|
||||
|
||||
/** Enable keyboard navigation (arrow keys) */
|
||||
enableKeyboardNav?: boolean;
|
||||
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
|
||||
/** Message to show while loading */
|
||||
loadingMessage?: string;
|
||||
|
||||
/** Message to show when no events */
|
||||
emptyMessage?: string;
|
||||
|
||||
/** Callback when active index changes (for controlled state in parent) */
|
||||
onActiveIndexChange?: (index: number | null) => void;
|
||||
}
|
||||
|
||||
export function EventViewer<T>({
|
||||
events,
|
||||
getEventKey,
|
||||
renderRow,
|
||||
renderDetail,
|
||||
header,
|
||||
error,
|
||||
splitLayoutName,
|
||||
defaultRatio = 0.4,
|
||||
enableKeyboardNav = true,
|
||||
isLoading = false,
|
||||
loadingMessage = 'Loading events...',
|
||||
emptyMessage = 'No events recorded',
|
||||
onActiveIndexChange,
|
||||
}: EventViewerProps<T>) {
|
||||
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
||||
// Wrap setActiveIndex to notify parent
|
||||
const setActiveIndex = useCallback(
|
||||
(indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {
|
||||
setActiveIndexInternal((prev) => {
|
||||
const newIndex =
|
||||
typeof indexOrUpdater === 'function' ? indexOrUpdater(prev) : indexOrUpdater;
|
||||
onActiveIndexChange?.(newIndex);
|
||||
return newIndex;
|
||||
});
|
||||
},
|
||||
[onActiveIndexChange],
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element> | null>(null);
|
||||
|
||||
const activeEvent = useMemo(
|
||||
() => (activeIndex != null ? events[activeIndex] : null),
|
||||
[activeIndex, events],
|
||||
);
|
||||
|
||||
// Check if the event list container is focused
|
||||
const isContainerFocused = useCallback(() => {
|
||||
return containerRef.current?.contains(document.activeElement) ?? false;
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation
|
||||
useEventViewerKeyboard({
|
||||
totalCount: events.length,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
virtualizer: virtualizerRef.current,
|
||||
isContainerFocused,
|
||||
enabled: enableKeyboardNav,
|
||||
closePanel: () => setIsPanelOpen(false),
|
||||
openPanel: () => setIsPanelOpen(true),
|
||||
});
|
||||
|
||||
// Handle virtualizer ready callback
|
||||
const handleVirtualizerReady = useCallback(
|
||||
(virtualizer: Virtualizer<HTMLDivElement, Element>) => {
|
||||
virtualizerRef.current = virtualizer;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle row click - select and open panel, scroll into view
|
||||
const handleRowClick = useCallback(
|
||||
(index: number) => {
|
||||
setActiveIndex(index);
|
||||
setIsPanelOpen(true);
|
||||
// Scroll to ensure selected item is visible after panel opens
|
||||
requestAnimationFrame(() => {
|
||||
virtualizerRef.current?.scrollToIndex(index, { align: 'auto' });
|
||||
});
|
||||
},
|
||||
[setActiveIndex],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-3 text-text-subtlest italic">{loadingMessage}</div>;
|
||||
}
|
||||
|
||||
if (events.length === 0 && !error) {
|
||||
return <div className="p-3 text-text-subtlest italic">{emptyMessage}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full">
|
||||
<SplitLayout
|
||||
layout="vertical"
|
||||
name={splitLayoutName}
|
||||
defaultRatio={defaultRatio}
|
||||
minHeightPx={10}
|
||||
firstSlot={({ style }) => (
|
||||
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
{header ?? <span aria-hidden />}
|
||||
<AutoScroller
|
||||
data={events}
|
||||
focusable={enableKeyboardNav}
|
||||
onVirtualizerReady={handleVirtualizerReady}
|
||||
header={
|
||||
error && (
|
||||
<Banner color="danger" className="m-3">
|
||||
{error}
|
||||
</Banner>
|
||||
)
|
||||
}
|
||||
render={(event, index) => (
|
||||
<div key={getEventKey(event, index)}>
|
||||
{renderRow({
|
||||
event,
|
||||
index,
|
||||
isActive: index === activeIndex,
|
||||
onClick: () => handleRowClick(index),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
secondSlot={
|
||||
activeEvent != null && renderDetail && isPanelOpen
|
||||
? ({ style }) => (
|
||||
<div style={style} className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface">
|
||||
<div className="pb-3 px-2">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="mx-2 overflow-y-auto">
|
||||
{renderDetail({ event: activeEvent, index: activeIndex ?? 0, onClose: handleClose })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface EventDetailAction {
|
||||
/** Unique key for React */
|
||||
key: string;
|
||||
/** Button label */
|
||||
label: string;
|
||||
/** Optional icon */
|
||||
icon?: ReactNode;
|
||||
/** Click handler */
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface EventDetailHeaderProps {
|
||||
title: string;
|
||||
prefix?: ReactNode;
|
||||
timestamp?: string;
|
||||
actions?: EventDetailAction[];
|
||||
copyText?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function EventDetailHeader({
|
||||
title,
|
||||
prefix,
|
||||
timestamp,
|
||||
actions,
|
||||
copyText,
|
||||
onClose,
|
||||
}: EventDetailHeaderProps) {
|
||||
const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), 'HH:mm:ss.SSS') : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 mb-2 h-xs">
|
||||
<HStack space={2} className="items-center min-w-0">
|
||||
{prefix}
|
||||
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
|
||||
</HStack>
|
||||
<HStack space={2} className="items-center">
|
||||
{actions?.map((action) => (
|
||||
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
{copyText != null && (
|
||||
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
|
||||
)}
|
||||
{formattedTime && (
|
||||
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
|
||||
)}
|
||||
<div className={classNames(copyText != null || formattedTime || (actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3")}>
|
||||
<IconButton color="custom" className="text-text-subtle -mr-3" size="xs" icon="x" title="Close event panel" onClick={onClose} />
|
||||
</div>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/yaak-client/components/core/EventViewerRow.tsx
Normal file
38
apps/yaak-client/components/core/EventViewerRow.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import classNames from 'classnames';
|
||||
import { format } from 'date-fns';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EventViewerRowProps {
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
icon: ReactNode;
|
||||
content: ReactNode;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export function EventViewerRow({
|
||||
isActive,
|
||||
onClick,
|
||||
icon,
|
||||
content,
|
||||
timestamp,
|
||||
}: EventViewerRowProps) {
|
||||
return (
|
||||
<div className="px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
|
||||
'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded',
|
||||
isActive && 'bg-surface-active !text-text',
|
||||
'text-text-subtle hover:text',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<div className="w-full truncate">{content}</div>
|
||||
{timestamp && <div className="opacity-50">{format(`${timestamp}Z`, 'HH:mm:ss.SSS')}</div>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
apps/yaak-client/components/core/FormattedError.tsx
Normal file
23
apps/yaak-client/components/core/FormattedError.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormattedError({ children, className }: Props) {
|
||||
return (
|
||||
<pre
|
||||
className={classNames(
|
||||
className,
|
||||
'cursor-text select-auto',
|
||||
'[&_*]:cursor-text [&_*]:select-auto',
|
||||
'font-mono text-sm w-full bg-surface-highlight p-3 rounded',
|
||||
'whitespace-pre-wrap border border-danger border-dashed overflow-x-auto',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
22
apps/yaak-client/components/core/Heading.tsx
Normal file
22
apps/yaak-client/components/core/Heading.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLHeadingElement> {
|
||||
level?: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
export function Heading({ className, level = 1, ...props }: Props) {
|
||||
const Component = level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3';
|
||||
return (
|
||||
<Component
|
||||
className={classNames(
|
||||
className,
|
||||
'font-semibold text-text',
|
||||
level === 1 && 'text-2xl',
|
||||
level === 2 && 'text-xl',
|
||||
level === 3 && 'text-lg',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
apps/yaak-client/components/core/Hotkey.tsx
Normal file
45
apps/yaak-client/components/core/Hotkey.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||
import { useFormattedHotkey } from '../../hooks/useHotKey';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
interface Props {
|
||||
action: HotkeyAction | null;
|
||||
className?: string;
|
||||
variant?: 'text' | 'with-bg';
|
||||
}
|
||||
|
||||
export function Hotkey({ action, className, variant }: Props) {
|
||||
const labelParts = useFormattedHotkey(action);
|
||||
if (labelParts === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HotkeyRaw labelParts={labelParts} className={className} variant={variant} />;
|
||||
}
|
||||
|
||||
interface HotkeyRawProps {
|
||||
labelParts: string[];
|
||||
className?: string;
|
||||
variant?: 'text' | 'with-bg';
|
||||
}
|
||||
|
||||
export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
|
||||
return (
|
||||
<HStack
|
||||
className={classNames(
|
||||
className,
|
||||
variant === 'with-bg' &&
|
||||
'rounded bg-surface-highlight px-1 border border-border text-text-subtle',
|
||||
variant === 'text' && 'text-text-subtlest',
|
||||
)}
|
||||
>
|
||||
{labelParts.map((char, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<div key={index} className="min-w-[1em] text-center">
|
||||
{char}
|
||||
</div>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
15
apps/yaak-client/components/core/HotkeyLabel.tsx
Normal file
15
apps/yaak-client/components/core/HotkeyLabel.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||
import { useHotkeyLabel } from '../../hooks/useHotKey';
|
||||
|
||||
interface Props {
|
||||
action: HotkeyAction;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HotkeyLabel({ action, className }: Props) {
|
||||
const label = useHotkeyLabel(action);
|
||||
return (
|
||||
<span className={classNames(className, 'text-text-subtle whitespace-nowrap')}>{label}</span>
|
||||
);
|
||||
}
|
||||
28
apps/yaak-client/components/core/HotkeyList.tsx
Normal file
28
apps/yaak-client/components/core/HotkeyList.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import type { HotkeyAction } from '../../hooks/useHotKey';
|
||||
import { Hotkey } from './Hotkey';
|
||||
import { HotkeyLabel } from './HotkeyLabel';
|
||||
|
||||
interface Props {
|
||||
hotkeys: HotkeyAction[];
|
||||
bottomSlot?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
|
||||
return (
|
||||
<div className={classNames(className, 'h-full flex items-center justify-center')}>
|
||||
<div className="grid gap-2 grid-cols-[auto_auto]">
|
||||
{hotkeys.map((hotkey) => (
|
||||
<Fragment key={hotkey}>
|
||||
<HotkeyLabel className="truncate" action={hotkey} />
|
||||
<Hotkey className="ml-4" action={hotkey} />
|
||||
</Fragment>
|
||||
))}
|
||||
{bottomSlot}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
86
apps/yaak-client/components/core/HttpMethodTag.tsx
Normal file
86
apps/yaak-client/components/core/HttpMethodTag.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
request: HttpRequest | GrpcRequest | WebsocketRequest;
|
||||
className?: string;
|
||||
short?: boolean;
|
||||
noAlias?: boolean;
|
||||
}
|
||||
|
||||
const methodNames: Record<string, string> = {
|
||||
get: 'GET',
|
||||
put: 'PUT',
|
||||
post: 'POST',
|
||||
patch: 'PTCH',
|
||||
delete: 'DELE',
|
||||
options: 'OPTN',
|
||||
head: 'HEAD',
|
||||
query: 'QURY',
|
||||
graphql: 'GQL',
|
||||
grpc: 'GRPC',
|
||||
websocket: 'WS',
|
||||
};
|
||||
|
||||
export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short, noAlias }: Props) {
|
||||
const method =
|
||||
request.model === 'http_request' && (request.bodyType === 'graphql' && !noAlias)
|
||||
? 'graphql'
|
||||
: request.model === 'grpc_request'
|
||||
? 'grpc'
|
||||
: request.model === 'websocket_request'
|
||||
? 'websocket'
|
||||
: request.method;
|
||||
|
||||
return <HttpMethodTagRaw method={method} className={className} short={short} />;
|
||||
});
|
||||
|
||||
export function HttpMethodTagRaw({
|
||||
className,
|
||||
method,
|
||||
short,
|
||||
forceColor,
|
||||
}: {
|
||||
method: string;
|
||||
className?: string;
|
||||
short?: boolean;
|
||||
forceColor?: boolean;
|
||||
}) {
|
||||
let label = method.toUpperCase();
|
||||
if (short) {
|
||||
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
|
||||
label = label.padStart(4, ' ');
|
||||
}
|
||||
|
||||
const m = method.toUpperCase();
|
||||
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const colored = forceColor || settings.coloredMethods;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
!colored && 'text-text-subtle',
|
||||
colored && m === 'GRAPHQL' && 'text-info',
|
||||
colored && m === 'WEBSOCKET' && 'text-info',
|
||||
colored && m === 'GRPC' && 'text-info',
|
||||
colored && m === 'QUERY' && 'text-text-subtle',
|
||||
colored && m === 'OPTIONS' && 'text-info',
|
||||
colored && m === 'HEAD' && 'text-text-subtle',
|
||||
colored && m === 'GET' && 'text-primary',
|
||||
colored && m === 'PUT' && 'text-warning',
|
||||
colored && m === 'PATCH' && 'text-notice',
|
||||
colored && m === 'POST' && 'text-success',
|
||||
colored && m === 'DELETE' && 'text-danger',
|
||||
'font-mono flex-shrink-0 whitespace-pre',
|
||||
'pt-[0.15em]', // Fix for monospace font not vertically centering
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
45
apps/yaak-client/components/core/HttpResponseDurationTag.tsx
Normal file
45
apps/yaak-client/components/core/HttpResponseDurationTag.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function HttpResponseDurationTag({ response }: Props) {
|
||||
const [fallbackElapsed, setFallbackElapsed] = useState<number>(0);
|
||||
const timeout = useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
// Calculate the duration of the response for use when the response hasn't finished yet
|
||||
useEffect(() => {
|
||||
clearInterval(timeout.current);
|
||||
if (response.state === 'closed') return;
|
||||
timeout.current = setInterval(() => {
|
||||
setFallbackElapsed(Date.now() - new Date(`${response.createdAt}Z`).getTime());
|
||||
}, 100);
|
||||
return () => clearInterval(timeout.current);
|
||||
}, [response.createdAt, response.state]);
|
||||
|
||||
const dnsValue = response.elapsedDns > 0 ? formatMillis(response.elapsedDns) : '--';
|
||||
const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`;
|
||||
|
||||
const elapsed = response.state === 'closed' ? response.elapsed : fallbackElapsed;
|
||||
|
||||
return (
|
||||
<span className="font-mono" title={title}>
|
||||
{formatMillis(elapsed)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMillis(ms: number) {
|
||||
if (ms < 1000) {
|
||||
return `${ms} ms`;
|
||||
}
|
||||
if (ms < 60_000) {
|
||||
const seconds = (ms / 1000).toFixed(ms < 10_000 ? 1 : 0);
|
||||
return `${seconds} s`;
|
||||
}
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const seconds = Math.round((ms % 60_000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
55
apps/yaak-client/components/core/HttpStatusTag.tsx
Normal file
55
apps/yaak-client/components/core/HttpStatusTag.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { HttpResponse, HttpResponseState } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
className?: string;
|
||||
showReason?: boolean;
|
||||
short?: boolean;
|
||||
}
|
||||
|
||||
export function HttpStatusTag({ response, ...props }: Props) {
|
||||
const { status, state, statusReason } = response;
|
||||
return <HttpStatusTagRaw status={status} state={state} statusReason={statusReason} {...props} />;
|
||||
}
|
||||
|
||||
export function HttpStatusTagRaw({
|
||||
status,
|
||||
state,
|
||||
className,
|
||||
showReason,
|
||||
statusReason,
|
||||
short,
|
||||
}: Omit<Props, 'response'> & {
|
||||
status: number | string;
|
||||
state?: HttpResponseState;
|
||||
statusReason?: string | null;
|
||||
}) {
|
||||
let colorClass: string;
|
||||
let label = `${status}`;
|
||||
const statusN = typeof status === 'number' ? status : parseInt(status, 10);
|
||||
|
||||
if (state === 'initialized') {
|
||||
label = short ? 'CONN' : 'CONNECTING';
|
||||
colorClass = 'text-text-subtle';
|
||||
} else if (statusN < 100) {
|
||||
label = short ? 'ERR' : 'ERROR';
|
||||
colorClass = 'text-danger';
|
||||
} else if (statusN < 200) {
|
||||
colorClass = 'text-info';
|
||||
} else if (statusN < 300) {
|
||||
colorClass = 'text-success';
|
||||
} else if (statusN < 400) {
|
||||
colorClass = 'text-primary';
|
||||
} else if (statusN < 500) {
|
||||
colorClass = 'text-warning';
|
||||
} else {
|
||||
colorClass = 'text-danger';
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
|
||||
{label} {showReason && statusReason}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
326
apps/yaak-client/components/core/Icon.tsx
Normal file
326
apps/yaak-client/components/core/Icon.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import type { Color } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
AlarmClockIcon,
|
||||
AlertTriangleIcon,
|
||||
ArchiveIcon,
|
||||
ArrowBigDownDashIcon,
|
||||
ArrowBigLeftDashIcon,
|
||||
ArrowBigRightDashIcon,
|
||||
ArrowBigRightIcon,
|
||||
ArrowBigUpDashIcon,
|
||||
ArrowDownIcon,
|
||||
ArrowDownToDotIcon,
|
||||
ArrowDownToLineIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowRightCircleIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
ArrowUpFromLineIcon,
|
||||
ArrowUpIcon,
|
||||
BadgeCheckIcon,
|
||||
BookOpenText,
|
||||
BoxIcon,
|
||||
CakeIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsDownUpIcon,
|
||||
ChevronsUpDownIcon,
|
||||
CircleAlertIcon,
|
||||
CircleDashedIcon,
|
||||
CircleDollarSignIcon,
|
||||
CircleFadingArrowUpIcon,
|
||||
CircleHelpIcon,
|
||||
CircleOffIcon,
|
||||
ClipboardPasteIcon,
|
||||
ClockIcon,
|
||||
CodeIcon,
|
||||
Columns2Icon,
|
||||
CommandIcon,
|
||||
CookieIcon,
|
||||
CopyCheck,
|
||||
CopyIcon,
|
||||
CornerRightDownIcon,
|
||||
CornerRightUpIcon,
|
||||
CreditCardIcon,
|
||||
CrosshairIcon,
|
||||
DotIcon,
|
||||
DownloadIcon,
|
||||
EllipsisIcon,
|
||||
EllipsisVerticalIcon,
|
||||
ExpandIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileCodeIcon,
|
||||
FileIcon,
|
||||
FileTextIcon,
|
||||
FilterIcon,
|
||||
FlameIcon,
|
||||
FlaskConicalIcon,
|
||||
FolderCodeIcon,
|
||||
FolderCogIcon,
|
||||
FolderDownIcon,
|
||||
FolderGitIcon,
|
||||
FolderIcon,
|
||||
FolderInputIcon,
|
||||
FolderOpenIcon,
|
||||
FolderOutputIcon,
|
||||
FolderSymlinkIcon,
|
||||
FolderSyncIcon,
|
||||
FolderUpIcon,
|
||||
GiftIcon,
|
||||
GitBranchIcon,
|
||||
GitBranchPlusIcon,
|
||||
GitCommitIcon,
|
||||
GitCommitVerticalIcon,
|
||||
GitForkIcon,
|
||||
GitPullRequestIcon,
|
||||
GlobeIcon,
|
||||
GripVerticalIcon,
|
||||
HandIcon,
|
||||
HardDriveDownloadIcon,
|
||||
HistoryIcon,
|
||||
HomeIcon,
|
||||
ImportIcon,
|
||||
InfoIcon,
|
||||
KeyboardIcon,
|
||||
KeyRoundIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MergeIcon,
|
||||
MessageSquare,
|
||||
MinusCircleIcon,
|
||||
MinusIcon,
|
||||
MoonIcon,
|
||||
MoreVerticalIcon,
|
||||
PaletteIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PencilIcon,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
Plug,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
PuzzleIcon,
|
||||
RefreshCcwIcon,
|
||||
RefreshCwIcon,
|
||||
RocketIcon,
|
||||
RotateCcwIcon,
|
||||
Rows2Icon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
SendHorizontalIcon,
|
||||
SettingsIcon,
|
||||
ShieldAlertIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldIcon,
|
||||
ShieldOffIcon,
|
||||
SparklesIcon,
|
||||
SquareCheckIcon,
|
||||
SquareIcon,
|
||||
SquareTerminalIcon,
|
||||
SunIcon,
|
||||
TableIcon,
|
||||
Trash2Icon,
|
||||
UploadIcon,
|
||||
VariableIcon,
|
||||
Wand2Icon,
|
||||
WifiIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import type { CSSProperties, HTMLAttributes } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
const icons = {
|
||||
alarm_clock: AlarmClockIcon,
|
||||
alert_triangle: AlertTriangleIcon,
|
||||
archive: ArchiveIcon,
|
||||
arrow_big_down_dash: ArrowBigDownDashIcon,
|
||||
arrow_big_left_dash: ArrowBigLeftDashIcon,
|
||||
arrow_big_right: ArrowBigRightIcon,
|
||||
arrow_big_right_dash: ArrowBigRightDashIcon,
|
||||
arrow_big_up_dash: ArrowBigUpDashIcon,
|
||||
arrow_down: ArrowDownIcon,
|
||||
arrow_down_to_dot: ArrowDownToDotIcon,
|
||||
arrow_down_to_line: ArrowDownToLineIcon,
|
||||
arrow_left: ArrowLeftIcon,
|
||||
arrow_right: ArrowRightIcon,
|
||||
arrow_right_circle: ArrowRightCircleIcon,
|
||||
arrow_up: ArrowUpIcon,
|
||||
arrow_up_down: ArrowUpDownIcon,
|
||||
arrow_up_from_dot: ArrowUpFromDotIcon,
|
||||
arrow_up_from_line: ArrowUpFromLineIcon,
|
||||
badge_check: BadgeCheckIcon,
|
||||
book_open_text: BookOpenText,
|
||||
box: BoxIcon,
|
||||
cake: CakeIcon,
|
||||
chat: MessageSquare,
|
||||
check: CheckIcon,
|
||||
check_circle: CheckCircleIcon,
|
||||
check_square_checked: SquareCheckIcon,
|
||||
check_square_unchecked: SquareIcon,
|
||||
chevron_down: ChevronDownIcon,
|
||||
chevron_left: ChevronLeftIcon,
|
||||
chevrons_up_down: ChevronsUpDownIcon,
|
||||
chevrons_down_up: ChevronsDownUpIcon,
|
||||
chevron_right: ChevronRightIcon,
|
||||
circle_alert: CircleAlertIcon,
|
||||
circle_dashed: CircleDashedIcon,
|
||||
circle_dollar_sign: CircleDollarSignIcon,
|
||||
circle_fading_arrow_up: CircleFadingArrowUpIcon,
|
||||
clock: ClockIcon,
|
||||
code: CodeIcon,
|
||||
columns_2: Columns2Icon,
|
||||
command: CommandIcon,
|
||||
cookie: CookieIcon,
|
||||
copy: CopyIcon,
|
||||
copy_check: CopyCheck,
|
||||
corner_right_down: CornerRightDownIcon,
|
||||
corner_right_up: CornerRightUpIcon,
|
||||
credit_card: CreditCardIcon,
|
||||
crosshair: CrosshairIcon,
|
||||
dot: DotIcon,
|
||||
download: DownloadIcon,
|
||||
ellipsis: EllipsisIcon,
|
||||
ellipsis_vertical: EllipsisVerticalIcon,
|
||||
expand: ExpandIcon,
|
||||
external_link: ExternalLinkIcon,
|
||||
eye: EyeIcon,
|
||||
eye_closed: EyeOffIcon,
|
||||
file: FileIcon,
|
||||
file_code: FileCodeIcon,
|
||||
file_text: FileTextIcon,
|
||||
filter: FilterIcon,
|
||||
flame: FlameIcon,
|
||||
flask: FlaskConicalIcon,
|
||||
folder: FolderIcon,
|
||||
folder_code: FolderCodeIcon,
|
||||
folder_cog: FolderCogIcon,
|
||||
folder_git: FolderGitIcon,
|
||||
folder_input: FolderInputIcon,
|
||||
folder_open: FolderOpenIcon,
|
||||
folder_output: FolderOutputIcon,
|
||||
folder_symlink: FolderSymlinkIcon,
|
||||
folder_sync: FolderSyncIcon,
|
||||
folder_down: FolderDownIcon,
|
||||
folder_up: FolderUpIcon,
|
||||
gift: GiftIcon,
|
||||
git_branch: GitBranchIcon,
|
||||
git_branch_plus: GitBranchPlusIcon,
|
||||
git_commit: GitCommitIcon,
|
||||
git_commit_vertical: GitCommitVerticalIcon,
|
||||
git_fork: GitForkIcon,
|
||||
git_pull_request: GitPullRequestIcon,
|
||||
globe: GlobeIcon,
|
||||
grip_vertical: GripVerticalIcon,
|
||||
circle_off: CircleOffIcon,
|
||||
hand: HandIcon,
|
||||
hard_drive_download: HardDriveDownloadIcon,
|
||||
help: CircleHelpIcon,
|
||||
history: HistoryIcon,
|
||||
house: HomeIcon,
|
||||
import: ImportIcon,
|
||||
info: InfoIcon,
|
||||
key_round: KeyRoundIcon,
|
||||
keyboard: KeyboardIcon,
|
||||
left_panel_hidden: PanelLeftOpenIcon,
|
||||
left_panel_visible: PanelLeftCloseIcon,
|
||||
lock: LockIcon,
|
||||
lock_open: LockOpenIcon,
|
||||
magic_wand: Wand2Icon,
|
||||
merge: MergeIcon,
|
||||
minus: MinusIcon,
|
||||
minus_circle: MinusCircleIcon,
|
||||
moon: MoonIcon,
|
||||
more_vertical: MoreVerticalIcon,
|
||||
palette: PaletteIcon,
|
||||
paste: ClipboardPasteIcon,
|
||||
pencil: PencilIcon,
|
||||
pin: PinIcon,
|
||||
plug: Plug,
|
||||
plus: PlusIcon,
|
||||
plus_circle: PlusCircleIcon,
|
||||
puzzle: PuzzleIcon,
|
||||
refresh: RefreshCwIcon,
|
||||
rocket: RocketIcon,
|
||||
rotate_ccw: RotateCcwIcon,
|
||||
rows_2: Rows2Icon,
|
||||
save: SaveIcon,
|
||||
search: SearchIcon,
|
||||
send_horizontal: SendHorizontalIcon,
|
||||
settings: SettingsIcon,
|
||||
shield: ShieldIcon,
|
||||
shield_check: ShieldCheckIcon,
|
||||
shield_off: ShieldOffIcon,
|
||||
sparkles: SparklesIcon,
|
||||
square_terminal: SquareTerminalIcon,
|
||||
sun: SunIcon,
|
||||
table: TableIcon,
|
||||
text: FileTextIcon,
|
||||
trash: Trash2Icon,
|
||||
unpin: PinOffIcon,
|
||||
update: RefreshCcwIcon,
|
||||
upload: UploadIcon,
|
||||
variable: VariableIcon,
|
||||
wifi: WifiIcon,
|
||||
wrench: WrenchIcon,
|
||||
x: XIcon,
|
||||
_unknown: ShieldAlertIcon,
|
||||
|
||||
empty: (props: HTMLAttributes<HTMLSpanElement>) => <div {...props} />,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
icon: keyof typeof icons;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
spin?: boolean;
|
||||
title?: string;
|
||||
color?: Color | 'custom' | 'default';
|
||||
}
|
||||
|
||||
export const Icon = memo(function Icon({
|
||||
icon,
|
||||
color = 'default',
|
||||
spin,
|
||||
size = 'md',
|
||||
style,
|
||||
className,
|
||||
title,
|
||||
}: IconProps) {
|
||||
const Component = icons[icon] ?? icons._unknown;
|
||||
return (
|
||||
<Component
|
||||
style={style}
|
||||
title={title}
|
||||
className={classNames(
|
||||
className,
|
||||
!spin && 'transform-gpu',
|
||||
spin && 'animate-spin',
|
||||
'flex-shrink-0',
|
||||
size === 'xl' && 'h-6 w-6',
|
||||
size === 'lg' && 'h-5 w-5',
|
||||
size === 'md' && 'h-4 w-4',
|
||||
size === 'sm' && 'h-3.5 w-3.5',
|
||||
size === 'xs' && 'h-3 w-3',
|
||||
size === '2xs' && 'h-2.5 w-2.5',
|
||||
color === 'default' && 'inherit',
|
||||
color === 'danger' && 'text-danger',
|
||||
color === 'warning' && 'text-warning',
|
||||
color === 'notice' && 'text-notice',
|
||||
color === 'info' && 'text-info',
|
||||
color === 'success' && 'text-success',
|
||||
color === 'primary' && 'text-primary',
|
||||
color === 'secondary' && 'text-secondary',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
95
apps/yaak-client/components/core/IconButton.tsx
Normal file
95
apps/yaak-client/components/core/IconButton.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import classNames from 'classnames';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { useTimedBoolean } from '../../hooks/useTimedBoolean';
|
||||
import type { ButtonProps } from './Button';
|
||||
import { Button } from './Button';
|
||||
import type { IconProps } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import { LoadingIcon } from './LoadingIcon';
|
||||
|
||||
export type IconButtonProps = IconProps &
|
||||
ButtonProps & {
|
||||
showConfirm?: boolean;
|
||||
iconClassName?: string;
|
||||
iconSize?: IconProps['size'];
|
||||
iconColor?: IconProps['color'];
|
||||
title: string;
|
||||
showBadge?: boolean;
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
||||
{
|
||||
showConfirm,
|
||||
icon,
|
||||
color = 'default',
|
||||
spin,
|
||||
onClick,
|
||||
className,
|
||||
iconClassName,
|
||||
tabIndex,
|
||||
size = 'md',
|
||||
iconSize,
|
||||
showBadge,
|
||||
iconColor,
|
||||
isLoading,
|
||||
type = 'button',
|
||||
...props
|
||||
}: IconButtonProps,
|
||||
ref,
|
||||
) {
|
||||
const [confirmed, setConfirmed] = useTimedBoolean();
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (showConfirm) setConfirmed();
|
||||
onClick?.(e);
|
||||
},
|
||||
[onClick, setConfirmed, showConfirm],
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
aria-hidden={icon === 'empty'}
|
||||
disabled={icon === 'empty'}
|
||||
tabIndex={(tabIndex ?? icon === 'empty') ? -1 : undefined}
|
||||
onClick={handleClick}
|
||||
innerClassName="flex items-center justify-center"
|
||||
size={size}
|
||||
color={color}
|
||||
type={type}
|
||||
className={classNames(
|
||||
className,
|
||||
'group/button relative flex-shrink-0',
|
||||
'!px-0',
|
||||
size === 'md' && 'w-md',
|
||||
size === 'sm' && 'w-sm',
|
||||
size === 'xs' && 'w-xs',
|
||||
size === '2xs' && 'w-5',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{showBadge && (
|
||||
<div className="absolute top-0 right-0 w-1/2 h-1/2 flex items-center justify-center">
|
||||
<div className="w-2.5 h-2.5 bg-pink-500 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<LoadingIcon size={iconSize} className={iconClassName} />
|
||||
) : (
|
||||
<Icon
|
||||
size={iconSize}
|
||||
icon={confirmed ? 'check' : icon}
|
||||
spin={spin}
|
||||
color={iconColor}
|
||||
className={classNames(
|
||||
iconClassName,
|
||||
'group-hover/button:text-text',
|
||||
confirmed && '!text-success', // Don't use Icon.color here because it won't override the hover color
|
||||
props.disabled && 'opacity-70',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
31
apps/yaak-client/components/core/IconTooltip.tsx
Normal file
31
apps/yaak-client/components/core/IconTooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { IconProps } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import type { TooltipProps } from './Tooltip';
|
||||
import { Tooltip } from './Tooltip';
|
||||
|
||||
type Props = Omit<TooltipProps, 'children'> & {
|
||||
icon?: IconProps['icon'];
|
||||
iconSize?: IconProps['size'];
|
||||
iconColor?: IconProps['color'];
|
||||
className?: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export function IconTooltip({
|
||||
content,
|
||||
icon = 'info',
|
||||
iconColor,
|
||||
iconSize,
|
||||
...tooltipProps
|
||||
}: Props) {
|
||||
return (
|
||||
<Tooltip content={content} {...tooltipProps}>
|
||||
<Icon
|
||||
className="opacity-60 hover:opacity-100"
|
||||
icon={icon}
|
||||
size={iconSize}
|
||||
color={iconColor}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
15
apps/yaak-client/components/core/InlineCode.tsx
Normal file
15
apps/yaak-client/components/core/InlineCode.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<code
|
||||
className={classNames(
|
||||
className,
|
||||
'font-mono text-shrink bg-surface-highlight border border-border-subtle flex-grow-0',
|
||||
'px-1.5 py-0.5 rounded text shadow-inner break-words',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
611
apps/yaak-client/components/core/Input.tsx
Normal file
611
apps/yaak-client/components/core/Input.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { Color } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createFastMutation } from '../../hooks/useFastMutation';
|
||||
import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled';
|
||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||
import { copyToClipboard } from '../../lib/copy';
|
||||
import {
|
||||
analyzeTemplate,
|
||||
convertTemplateToInsecure,
|
||||
convertTemplateToSecure,
|
||||
} from '../../lib/encryption';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import {
|
||||
setupOrConfigureEncryption,
|
||||
withEncryptionEnabled,
|
||||
} from '../../lib/setupOrConfigureEncryption';
|
||||
import { Button } from './Button';
|
||||
import type { DropdownItem } from './Dropdown';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import type { EditorProps } from './Editor/Editor';
|
||||
import { Editor } from './Editor/LazyEditor';
|
||||
import type { IconProps } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import { IconTooltip } from './IconTooltip';
|
||||
import { Label } from './Label';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
export type InputProps = Pick<
|
||||
EditorProps,
|
||||
| 'language'
|
||||
| 'autocomplete'
|
||||
| 'forcedEnvironmentId'
|
||||
| 'forceUpdateKey'
|
||||
| 'disabled'
|
||||
| 'autoFocus'
|
||||
| 'autoSelect'
|
||||
| 'autocompleteVariables'
|
||||
| 'autocompleteFunctions'
|
||||
| 'onKeyDown'
|
||||
| 'readOnly'
|
||||
> & {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
inputWrapperClassName?: string;
|
||||
defaultValue?: string | null;
|
||||
disableObscureToggle?: boolean;
|
||||
fullHeight?: boolean;
|
||||
hideLabel?: boolean;
|
||||
help?: ReactNode;
|
||||
label: ReactNode;
|
||||
labelClassName?: string;
|
||||
labelPosition?: 'top' | 'left';
|
||||
leftSlot?: ReactNode;
|
||||
multiLine?: boolean;
|
||||
name?: string;
|
||||
onBlur?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onPaste?: (value: string) => void;
|
||||
onPasteOverwrite?: EditorProps['onPasteOverwrite'];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
rightSlot?: ReactNode;
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
|
||||
stateKey: EditorProps['stateKey'];
|
||||
extraExtensions?: EditorProps['extraExtensions'];
|
||||
tint?: Color;
|
||||
type?: 'text' | 'password';
|
||||
validate?: boolean | ((v: string) => boolean);
|
||||
wrapLines?: boolean;
|
||||
setRef?: (h: InputHandle | null) => void;
|
||||
};
|
||||
|
||||
export interface InputHandle {
|
||||
focus: () => void;
|
||||
isFocused: () => boolean;
|
||||
value: () => string;
|
||||
selectAll: () => void;
|
||||
dispatch: EditorView['dispatch'];
|
||||
}
|
||||
|
||||
export function Input({ type, ...props }: InputProps) {
|
||||
// If it's a password and template functions are supported (ie. secure(...)) then
|
||||
// use the encrypted input component.
|
||||
if (type === 'password' && props.autocompleteFunctions) {
|
||||
return <EncryptionInput {...props} />;
|
||||
}
|
||||
return <BaseInput type={type} {...props} />;
|
||||
}
|
||||
|
||||
function BaseInput({
|
||||
className,
|
||||
containerClassName,
|
||||
defaultValue,
|
||||
disableObscureToggle,
|
||||
disabled,
|
||||
forceUpdateKey,
|
||||
fullHeight,
|
||||
help,
|
||||
hideLabel,
|
||||
inputWrapperClassName,
|
||||
label,
|
||||
labelClassName,
|
||||
labelPosition = 'top',
|
||||
leftSlot,
|
||||
multiLine,
|
||||
onBlur,
|
||||
onChange,
|
||||
onFocus,
|
||||
onPaste,
|
||||
onPasteOverwrite,
|
||||
placeholder,
|
||||
readOnly,
|
||||
required,
|
||||
rightSlot,
|
||||
size = 'md',
|
||||
stateKey,
|
||||
tint,
|
||||
type = 'text',
|
||||
validate,
|
||||
wrapLines,
|
||||
setRef,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
|
||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
const skipNextFocus = useRef<boolean>(false);
|
||||
|
||||
const handle = useMemo<InputHandle>(
|
||||
() => ({
|
||||
focus: () => {
|
||||
if (editorRef.current == null) return;
|
||||
const anchor = editorRef.current.state.doc.length;
|
||||
skipNextFocus.current = true;
|
||||
editorRef.current.focus();
|
||||
editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true });
|
||||
},
|
||||
isFocused: () => editorRef.current?.hasFocus ?? false,
|
||||
value: () => editorRef.current?.state.doc.toString() ?? '',
|
||||
dispatch: (...args) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
editorRef.current?.dispatch(...(args as any));
|
||||
},
|
||||
selectAll() {
|
||||
if (editorRef.current == null) return;
|
||||
editorRef.current.focus();
|
||||
editorRef.current.dispatch({
|
||||
selection: { anchor: 0, head: editorRef.current.state.doc.length },
|
||||
});
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const setEditorRef = useCallback(
|
||||
(h: EditorView | null) => {
|
||||
editorRef.current = h;
|
||||
setRef?.(handle);
|
||||
},
|
||||
[handle, setRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => {
|
||||
skipNextFocus.current = true;
|
||||
};
|
||||
window.addEventListener('focus', fn);
|
||||
return () => {
|
||||
window.removeEventListener('focus', fn);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
|
||||
if (!skipNextFocus.current) {
|
||||
editorRef.current?.dispatch({
|
||||
selection: { anchor: 0, head: editorRef.current.state.doc.length },
|
||||
});
|
||||
}
|
||||
|
||||
setFocused(true);
|
||||
onFocus?.();
|
||||
skipNextFocus.current = false;
|
||||
}, [onFocus, readOnly]);
|
||||
|
||||
const handleBlur = useCallback(async () => {
|
||||
setFocused(false);
|
||||
// Move selection to the end on blur
|
||||
const anchor = editorRef.current?.state.doc.length ?? 0;
|
||||
editorRef.current?.dispatch({
|
||||
selection: { anchor, head: anchor },
|
||||
});
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
|
||||
const id = useRef(`input-${generateId()}`);
|
||||
const editorClassName = classNames(
|
||||
className,
|
||||
'!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder',
|
||||
);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (required && !validateRequire(defaultValue ?? '')) return false;
|
||||
if (typeof validate === 'boolean') return validate;
|
||||
if (typeof validate === 'function' && !validate(defaultValue ?? '')) return false;
|
||||
return true;
|
||||
}, [required, defaultValue, validate]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange?.(value);
|
||||
setHasChanged(true);
|
||||
},
|
||||
[onChange, setHasChanged],
|
||||
);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Submit the nearest form on Enter key press
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
|
||||
const form = wrapperRef.current?.closest('form');
|
||||
if (!isValid || form == null) return;
|
||||
|
||||
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
},
|
||||
[isValid],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={classNames(
|
||||
'pointer-events-auto', // Just in case we're placing in disabled parent
|
||||
'w-full',
|
||||
fullHeight && 'h-full',
|
||||
labelPosition === 'left' && 'flex items-center gap-2',
|
||||
labelPosition === 'top' && 'flex-row gap-0.5',
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
htmlFor={id.current}
|
||||
help={help}
|
||||
required={required}
|
||||
visuallyHidden={hideLabel}
|
||||
className={classNames(labelClassName)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<HStack
|
||||
alignItems="stretch"
|
||||
className={classNames(
|
||||
containerClassName,
|
||||
fullHeight && 'h-full',
|
||||
'x-theme-input',
|
||||
'relative w-full rounded-md text overflow-hidden',
|
||||
'border',
|
||||
focused && !disabled ? 'border-border-focus' : 'border-border',
|
||||
disabled && 'border-dotted',
|
||||
!isValid && hasChanged && '!border-danger',
|
||||
size === 'md' && 'min-h-md',
|
||||
size === 'sm' && 'min-h-sm',
|
||||
size === 'xs' && 'min-h-xs',
|
||||
size === '2xs' && 'min-h-2xs',
|
||||
)}
|
||||
>
|
||||
{tint != null && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
'absolute inset-0 opacity-5 pointer-events-none',
|
||||
tint === 'primary' && 'bg-primary',
|
||||
tint === 'secondary' && 'bg-secondary',
|
||||
tint === 'info' && 'bg-info',
|
||||
tint === 'success' && 'bg-success',
|
||||
tint === 'notice' && 'bg-notice',
|
||||
tint === 'warning' && 'bg-warning',
|
||||
tint === 'danger' && 'bg-danger',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{leftSlot}
|
||||
<HStack
|
||||
className={classNames(
|
||||
inputWrapperClassName,
|
||||
'w-full min-w-0 px-2',
|
||||
fullHeight && 'h-full',
|
||||
leftSlot ? 'pl-0.5 -ml-2' : null,
|
||||
rightSlot ? 'pr-0.5 -mr-2' : null,
|
||||
)}
|
||||
>
|
||||
<Editor
|
||||
setRef={setEditorRef}
|
||||
id={id.current}
|
||||
hideGutter
|
||||
singleLine={!multiLine}
|
||||
containerOnly
|
||||
stateKey={stateKey}
|
||||
wrapLines={wrapLines}
|
||||
heightMode="auto"
|
||||
onKeyDown={handleKeyDown}
|
||||
type={type === 'password' && !obscured ? 'text' : type}
|
||||
defaultValue={defaultValue}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
onPaste={onPaste}
|
||||
onPasteOverwrite={onPasteOverwrite}
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
editorClassName,
|
||||
multiLine && size === 'md' && 'py-1.5',
|
||||
multiLine && size === 'sm' && 'py-1',
|
||||
)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
readOnly={readOnly}
|
||||
{...props}
|
||||
/>
|
||||
</HStack>
|
||||
{type === 'password' && !disableObscureToggle && (
|
||||
<IconButton
|
||||
title={obscured ? `Show ${label}` : `Obscure ${label}`}
|
||||
size="xs"
|
||||
className={classNames('mr-0.5 !h-auto my-0.5', disabled && 'opacity-disabled')}
|
||||
color={tint}
|
||||
// iconClassName={classNames(
|
||||
// tint === 'primary' && 'text-primary',
|
||||
// tint === 'secondary' && 'text-secondary',
|
||||
// tint === 'info' && 'text-info',
|
||||
// tint === 'success' && 'text-success',
|
||||
// tint === 'notice' && 'text-notice',
|
||||
// tint === 'warning' && 'text-warning',
|
||||
// tint === 'danger' && 'text-danger',
|
||||
// )}
|
||||
iconSize="sm"
|
||||
icon={obscured ? 'eye' : 'eye_closed'}
|
||||
onClick={() => setObscured((o) => !o)}
|
||||
/>
|
||||
)}
|
||||
{rightSlot}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function validateRequire(v: string) {
|
||||
return v.length > 0;
|
||||
}
|
||||
|
||||
type PasswordFieldType = 'text' | 'encrypted';
|
||||
|
||||
function EncryptionInput({
|
||||
defaultValue,
|
||||
onChange,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
forceUpdateKey: ogForceUpdateKey,
|
||||
setRef,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const isEncryptionEnabled = useIsEncryptionEnabled();
|
||||
const [state, setState] = useStateWithDeps<{
|
||||
fieldType: PasswordFieldType;
|
||||
value: string | null;
|
||||
security: ReturnType<typeof analyzeTemplate> | null;
|
||||
obscured: boolean;
|
||||
error: string | null;
|
||||
}>(
|
||||
{
|
||||
fieldType: isEncryptionEnabled ? 'encrypted' : 'text',
|
||||
value: null,
|
||||
security: null,
|
||||
obscured: true,
|
||||
error: null,
|
||||
},
|
||||
[ogForceUpdateKey],
|
||||
);
|
||||
|
||||
const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
|
||||
const inputRef = useRef<InputHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.value != null) {
|
||||
// We already configured it
|
||||
return;
|
||||
}
|
||||
|
||||
const security = analyzeTemplate(defaultValue ?? '');
|
||||
if (analyzeTemplate(defaultValue ?? '') === 'global_secured') {
|
||||
// Lazily update value to decrypted representation
|
||||
templateToInsecure.mutate(defaultValue ?? '', {
|
||||
onSuccess: (value) => {
|
||||
setState({ fieldType: 'encrypted', security, value, obscured: true, error: null });
|
||||
// We're calling this here because we want the input to be fully initialized so the caller
|
||||
// can do stuff like change the selection.
|
||||
requestAnimationFrame(() => setRef?.(inputRef.current));
|
||||
},
|
||||
onError: (value) => {
|
||||
setState({
|
||||
fieldType: 'encrypted',
|
||||
security,
|
||||
value: null,
|
||||
error: String(value),
|
||||
obscured: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
} else if (isEncryptionEnabled && !defaultValue) {
|
||||
// Default to encrypted field for new encrypted inputs
|
||||
setState({ fieldType: 'encrypted', security, value: '', obscured: true, error: null });
|
||||
requestAnimationFrame(() => setRef?.(inputRef.current));
|
||||
} else if (isEncryptionEnabled) {
|
||||
// Don't obscure plain text when encryption is enabled
|
||||
setState({
|
||||
fieldType: 'text',
|
||||
security,
|
||||
value: defaultValue ?? '',
|
||||
obscured: false,
|
||||
error: null,
|
||||
});
|
||||
requestAnimationFrame(() => setRef?.(inputRef.current));
|
||||
} else {
|
||||
// Don't obscure plain text when encryption is disabled
|
||||
setState({
|
||||
fieldType: 'text',
|
||||
security,
|
||||
value: defaultValue ?? '',
|
||||
obscured: true,
|
||||
error: null,
|
||||
});
|
||||
requestAnimationFrame(() => setRef?.(inputRef.current));
|
||||
}
|
||||
}, [defaultValue, isEncryptionEnabled, setRef, setState, state.value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string, fieldType: PasswordFieldType) => {
|
||||
if (fieldType === 'encrypted') {
|
||||
templateToSecure.mutate(value, { onSuccess: (value) => onChange?.(value) });
|
||||
} else {
|
||||
onChange?.(value);
|
||||
}
|
||||
setState((s) => {
|
||||
// We can't analyze when encrypted because we don't have the raw value, so assume it's secured
|
||||
const security = fieldType === 'encrypted' ? 'global_secured' : analyzeTemplate(value);
|
||||
// Reset obscured value when the field type is being changed
|
||||
const obscured = fieldType === s.fieldType ? s.obscured : fieldType !== 'text';
|
||||
return { fieldType, value, security, obscured, error: s.error };
|
||||
});
|
||||
},
|
||||
[onChange, setState],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(value: string) => {
|
||||
if (state.fieldType != null) {
|
||||
handleChange(value, state.fieldType);
|
||||
}
|
||||
},
|
||||
[handleChange, state],
|
||||
);
|
||||
|
||||
const setInputRef = useCallback((h: InputHandle | null) => {
|
||||
inputRef.current = h;
|
||||
}, []);
|
||||
|
||||
const handleFieldTypeChange = useCallback(
|
||||
(newFieldType: PasswordFieldType) => {
|
||||
const { value, fieldType } = state;
|
||||
if (value == null || fieldType === newFieldType) {
|
||||
return;
|
||||
}
|
||||
|
||||
withEncryptionEnabled(async () => {
|
||||
const newValue = await convertTemplateToInsecure(value);
|
||||
handleChange(newValue, newFieldType);
|
||||
});
|
||||
},
|
||||
[handleChange, state],
|
||||
);
|
||||
|
||||
const dropdownItems = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
label: state.obscured ? 'Show' : 'Hide',
|
||||
disabled: isEncryptionEnabled && state.fieldType === 'text',
|
||||
leftSlot: <Icon icon={state.obscured ? 'eye' : 'eye_closed'} />,
|
||||
onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })),
|
||||
},
|
||||
{
|
||||
label: 'Copy',
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: !state.value,
|
||||
onSelect: () => copyToClipboard(state.value ?? ''),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: state.fieldType === 'text' ? 'Encrypt Field' : 'Decrypt Field',
|
||||
leftSlot: <Icon icon={state.fieldType === 'text' ? 'lock' : 'lock_open'} />,
|
||||
onSelect: () => handleFieldTypeChange(state.fieldType === 'text' ? 'encrypted' : 'text'),
|
||||
},
|
||||
],
|
||||
[
|
||||
handleFieldTypeChange,
|
||||
isEncryptionEnabled,
|
||||
setState,
|
||||
state.fieldType,
|
||||
state.obscured,
|
||||
state.value,
|
||||
],
|
||||
);
|
||||
|
||||
let tint: InputProps['tint'];
|
||||
if (!isEncryptionEnabled) {
|
||||
tint = undefined;
|
||||
} else if (state.fieldType === 'encrypted') {
|
||||
tint = 'info';
|
||||
} else if (state.security === 'local_secured') {
|
||||
tint = 'secondary';
|
||||
} else if (state.security === 'insecure') {
|
||||
tint = 'notice';
|
||||
}
|
||||
|
||||
const rightSlot = useMemo(() => {
|
||||
let icon: IconProps['icon'];
|
||||
if (isEncryptionEnabled) {
|
||||
icon = state.security === 'insecure' ? 'shield_off' : 'shield_check';
|
||||
} else {
|
||||
icon = state.obscured ? 'eye_closed' : 'eye';
|
||||
}
|
||||
return (
|
||||
<HStack className="h-auto m-0.5">
|
||||
<Dropdown items={dropdownItems}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
color={tint}
|
||||
aria-label="Configure encryption"
|
||||
className={classNames(
|
||||
'flex items-center justify-center !h-full !px-1',
|
||||
'opacity-70', // Makes it a bit subtler
|
||||
props.disabled && '!opacity-disabled',
|
||||
)}
|
||||
>
|
||||
<HStack space={0.5}>
|
||||
<Icon size="sm" title="Configure encryption" icon={icon} />
|
||||
<Icon size="xs" title="Configure encryption" icon="chevron_down" />
|
||||
</HStack>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
);
|
||||
}, [dropdownItems, isEncryptionEnabled, props.disabled, state.obscured, state.security, tint]);
|
||||
|
||||
const type = state.obscured ? 'password' : 'text';
|
||||
|
||||
if (state.error) {
|
||||
return (
|
||||
<Button
|
||||
variant="border"
|
||||
color="danger"
|
||||
size={props.size}
|
||||
className="text-sm"
|
||||
rightSlot={<IconTooltip tabIndex={-1} content={state.error} icon="alert_triangle" />}
|
||||
onClick={() => {
|
||||
setupOrConfigureEncryption();
|
||||
}}
|
||||
>
|
||||
{state.error.replace(/^Render Error: /i, '')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseInput
|
||||
setRef={setInputRef}
|
||||
disableObscureToggle
|
||||
autocompleteFunctions={autocompleteFunctions}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
defaultValue={state.value ?? ''}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
onChange={handleInputChange}
|
||||
tint={tint}
|
||||
type={type}
|
||||
rightSlot={rightSlot}
|
||||
disabled={state.error != null}
|
||||
className="pr-1.5" // To account for encryption dropdown
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const templateToSecure = createFastMutation({
|
||||
mutationKey: ['template-to-secure'],
|
||||
mutationFn: convertTemplateToSecure,
|
||||
});
|
||||
|
||||
const templateToInsecure = createFastMutation({
|
||||
mutationKey: ['template-to-insecure'],
|
||||
mutationFn: convertTemplateToInsecure,
|
||||
disableToastError: true,
|
||||
});
|
||||
146
apps/yaak-client/components/core/JsonAttributeTree.tsx
Normal file
146
apps/yaak-client/components/core/JsonAttributeTree.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
depth?: number;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
attrValue: any;
|
||||
attrKey?: string | number;
|
||||
attrKeyJsonPath?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const JsonAttributeTree = ({
|
||||
depth = 0,
|
||||
attrKey,
|
||||
attrValue,
|
||||
attrKeyJsonPath,
|
||||
className,
|
||||
}: Props) => {
|
||||
attrKeyJsonPath = attrKeyJsonPath ?? `${attrKey}`;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const toggleExpanded = () => setIsExpanded((v) => !v);
|
||||
|
||||
const { isExpandable, children, label, labelClassName } = useMemo<{
|
||||
isExpandable: boolean;
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
labelClassName?: string;
|
||||
}>(() => {
|
||||
const jsonType = Object.prototype.toString.call(attrValue);
|
||||
if (jsonType === '[object Object]') {
|
||||
return {
|
||||
children: isExpanded
|
||||
? Object.keys(attrValue)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.flatMap((k) => (
|
||||
<JsonAttributeTree
|
||||
key={k}
|
||||
depth={depth + 1}
|
||||
attrValue={attrValue[k]}
|
||||
attrKey={k}
|
||||
attrKeyJsonPath={joinObjectKey(attrKeyJsonPath, k)}
|
||||
/>
|
||||
))
|
||||
: null,
|
||||
isExpandable: Object.keys(attrValue).length > 0,
|
||||
label: isExpanded ? `{${Object.keys(attrValue).length || ' '}}` : '{⋯}',
|
||||
labelClassName: 'text-text-subtlest',
|
||||
};
|
||||
}
|
||||
if (jsonType === '[object Array]') {
|
||||
return {
|
||||
children: isExpanded
|
||||
? // biome-ignore lint/suspicious/noExplicitAny: none
|
||||
attrValue.flatMap((v: any, i: number) => (
|
||||
<JsonAttributeTree
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
key={i}
|
||||
depth={depth + 1}
|
||||
attrValue={v}
|
||||
attrKey={i}
|
||||
attrKeyJsonPath={joinArrayKey(attrKeyJsonPath, i)}
|
||||
/>
|
||||
))
|
||||
: null,
|
||||
isExpandable: attrValue.length > 0,
|
||||
label: isExpanded ? `[${attrValue.length || ' '}]` : '[⋯]',
|
||||
labelClassName: 'text-text-subtlest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
children: null,
|
||||
isExpandable: false,
|
||||
label: jsonType === '[object String]' ? `"${attrValue}"` : `${attrValue}`,
|
||||
labelClassName: classNames(
|
||||
jsonType === '[object Boolean]' && 'text-primary',
|
||||
jsonType === '[object Number]' && 'text-info',
|
||||
jsonType === '[object String]' && 'text-notice',
|
||||
jsonType === '[object Null]' && 'text-danger',
|
||||
),
|
||||
};
|
||||
}, [attrValue, attrKeyJsonPath, isExpanded, depth]);
|
||||
|
||||
const labelEl = (
|
||||
<span
|
||||
className={classNames(labelClassName, 'cursor-text select-text group-hover:text-text-subtle')}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
/*depth === 0 && '-ml-4',*/ 'font-mono text-xs',
|
||||
depth === 0 && 'h-full overflow-y-auto pb-2',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{isExpandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="group relative flex items-center pl-4 w-full"
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
<Icon
|
||||
size="xs"
|
||||
icon="chevron_right"
|
||||
className={classNames(
|
||||
'left-0 absolute transition-transform flex items-center',
|
||||
'group-hover:text-text-subtle',
|
||||
isExpanded ? 'rotate-90' : '',
|
||||
)}
|
||||
/>
|
||||
<span className="text-primary group-hover:text-primary mr-1.5 whitespace-nowrap">
|
||||
{attrKey === undefined ? '$' : attrKey}:
|
||||
</span>
|
||||
{labelEl}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-primary mr-1.5 pl-4 whitespace-nowrap cursor-text select-text">
|
||||
{attrKey}:
|
||||
</span>
|
||||
{labelEl}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{children && <div className="ml-4 whitespace-nowrap">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function joinObjectKey(baseKey: string | undefined, key: string): string {
|
||||
const quotedKey = key.match(/^[a-z0-9_]+$/i) ? key : `\`${key}\``;
|
||||
|
||||
if (baseKey == null) return quotedKey;
|
||||
return `${baseKey}.${quotedKey}`;
|
||||
}
|
||||
|
||||
function joinArrayKey(baseKey: string | undefined, index: number): string {
|
||||
return `${baseKey ?? ''}[${index}]`;
|
||||
}
|
||||
63
apps/yaak-client/components/core/KeyValueRow.tsx
Normal file
63
apps/yaak-client/components/core/KeyValueRow.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children:
|
||||
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
||||
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
|
||||
}
|
||||
|
||||
export function KeyValueRows({ children }: Props) {
|
||||
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
|
||||
return (
|
||||
<table className="text-editor font-mono min-w-0 w-full mb-auto">
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{childArray.map((child, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<tr key={i}>{child}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
interface KeyValueRowProps {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
leftSlot?: ReactNode;
|
||||
labelClassName?: string;
|
||||
labelColor?: 'secondary' | 'primary' | 'info';
|
||||
}
|
||||
|
||||
export function KeyValueRow({
|
||||
label,
|
||||
children,
|
||||
rightSlot,
|
||||
leftSlot,
|
||||
labelColor = 'secondary',
|
||||
labelClassName,
|
||||
}: KeyValueRowProps) {
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
className={classNames(
|
||||
'select-none py-0.5 pr-2 h-full align-top max-w-[10rem]',
|
||||
labelClassName,
|
||||
labelColor === 'primary' && 'text-primary',
|
||||
labelColor === 'secondary' && 'text-text-subtle',
|
||||
labelColor === 'info' && 'text-info',
|
||||
)}
|
||||
>
|
||||
<span className="select-text cursor-text">{label}</span>
|
||||
</td>
|
||||
<td className="select-none py-0.5 break-all align-top max-w-[15rem]">
|
||||
<div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
|
||||
{leftSlot ?? <span aria-hidden />}
|
||||
{children}
|
||||
{rightSlot ? <div className="ml-1.5">{rightSlot}</div> : <span aria-hidden />}
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
apps/yaak-client/components/core/Label.tsx
Normal file
49
apps/yaak-client/components/core/Label.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { IconTooltip } from './IconTooltip';
|
||||
|
||||
export function Label({
|
||||
htmlFor,
|
||||
className,
|
||||
children,
|
||||
visuallyHidden,
|
||||
tags = [],
|
||||
required,
|
||||
rightSlot,
|
||||
help,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLLabelElement> & {
|
||||
htmlFor: string | null;
|
||||
required?: boolean;
|
||||
tags?: string[];
|
||||
visuallyHidden?: boolean;
|
||||
rightSlot?: ReactNode;
|
||||
children: ReactNode;
|
||||
help?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor ?? undefined}
|
||||
className={classNames(
|
||||
className,
|
||||
visuallyHidden && 'sr-only',
|
||||
'flex-shrink-0 text-sm',
|
||||
'text-text-subtle whitespace-nowrap flex items-center gap-1 mb-0.5',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span>
|
||||
{children}
|
||||
{required === true && <span className="text-text-subtlest">*</span>}
|
||||
</span>
|
||||
{tags.map((tag, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<span key={i} className="text-xs text-text-subtlest">
|
||||
({tag})
|
||||
</span>
|
||||
))}
|
||||
{help && <IconTooltip tabIndex={-1} content={help} />}
|
||||
{rightSlot && <div className="ml-auto">{rightSlot}</div>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
59
apps/yaak-client/components/core/Link.tsx
Normal file
59
apps/yaak-client/components/core/Link.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Link as RouterLink } from '@tanstack/react-router';
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { appInfo } from '../../lib/appInfo';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLAnchorElement> {
|
||||
href: string;
|
||||
noUnderline?: boolean;
|
||||
}
|
||||
|
||||
export function Link({ href, children, noUnderline, className, ...other }: Props) {
|
||||
const isExternal = href.match(/^https?:\/\//);
|
||||
|
||||
className = classNames(
|
||||
className,
|
||||
'relative',
|
||||
'inline-flex items-center hover:underline group',
|
||||
!noUnderline && 'underline',
|
||||
);
|
||||
|
||||
if (isExternal) {
|
||||
const isYaakLink = href.startsWith('https://yaak.app');
|
||||
let finalHref = href;
|
||||
if (isYaakLink) {
|
||||
const url = new URL(href);
|
||||
url.searchParams.set('ref', appInfo.identifier);
|
||||
finalHref = url.toString();
|
||||
}
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-no-target-blank
|
||||
<a
|
||||
href={finalHref}
|
||||
target="_blank"
|
||||
rel={isYaakLink ? undefined : 'noopener noreferrer'}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className={className}
|
||||
{...other}
|
||||
>
|
||||
<span className="pr-5">{children}</span>
|
||||
<Icon
|
||||
className="inline absolute right-0.5 top-[0.3em] opacity-70 group-hover:opacity-100"
|
||||
size="xs"
|
||||
icon="external_link"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RouterLink to={href} className={className} {...other}>
|
||||
{children}
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedbackLink() {
|
||||
return <Link href="https://yaak.app/roadmap">Feedback</Link>;
|
||||
}
|
||||
35
apps/yaak-client/components/core/LoadingIcon.tsx
Normal file
35
apps/yaak-client/components/core/LoadingIcon.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingIcon({ size = 'md', className }: Props) {
|
||||
const classes = classNames(
|
||||
className,
|
||||
'text-inherit flex-shrink-0',
|
||||
size === 'xl' && 'h-6 w-6',
|
||||
size === 'lg' && 'h-5 w-5',
|
||||
size === 'md' && 'h-4 w-4',
|
||||
size === 'sm' && 'h-3.5 w-3.5',
|
||||
size === 'xs' && 'h-3 w-3',
|
||||
size === '2xs' && 'h-2.5 w-2.5',
|
||||
'animate-spin',
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
classes,
|
||||
'border-[currentColor] border-b-transparent rounded-full',
|
||||
size === 'xl' && 'border-[0.2rem]',
|
||||
size === 'lg' && 'border-[0.16rem]',
|
||||
size === 'md' && 'border-[0.13rem]',
|
||||
size === 'sm' && 'border-[0.1rem]',
|
||||
size === 'xs' && 'border-[0.08rem]',
|
||||
size === '2xs' && 'border-[0.06rem]',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
858
apps/yaak-client/components/core/PairEditor.tsx
Normal file
858
apps/yaak-client/components/core/PairEditor.tsx
Normal file
@@ -0,0 +1,858 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { basename } from '@tauri-apps/api/path';
|
||||
import classNames from 'classnames';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { WrappedEnvironmentVariable } from '../../hooks/useEnvironmentVariables';
|
||||
import { useRandomKey } from '../../hooks/useRandomKey';
|
||||
import { useToggle } from '../../hooks/useToggle';
|
||||
import { languageFromContentType } from '../../lib/contentType';
|
||||
import { showDialog } from '../../lib/dialog';
|
||||
import { computeSideForDragMove } from '../../lib/dnd';
|
||||
import { showPrompt } from '../../lib/prompt';
|
||||
import { DropMarker } from '../DropMarker';
|
||||
import { SelectFile } from '../SelectFile';
|
||||
import { Button } from './Button';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import type { DropdownItem } from './Dropdown';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import type { EditorProps } from './Editor/Editor';
|
||||
import type { GenericCompletionConfig } from './Editor/genericCompletion';
|
||||
import { Editor } from './Editor/LazyEditor';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import type { InputHandle, InputProps } from './Input';
|
||||
import { Input } from './Input';
|
||||
import { ensurePairId } from './PairEditor.util';
|
||||
import type { RadioDropdownItem } from './RadioDropdown';
|
||||
import { RadioDropdown } from './RadioDropdown';
|
||||
|
||||
export interface PairEditorHandle {
|
||||
focusName(id: string): void;
|
||||
focusValue(id: string): void;
|
||||
}
|
||||
|
||||
export type PairEditorProps = {
|
||||
allowFileValues?: boolean;
|
||||
allowMultilineValues?: boolean;
|
||||
className?: string;
|
||||
forcedEnvironmentId?: string;
|
||||
forceUpdateKey?: string;
|
||||
nameAutocomplete?: GenericCompletionConfig;
|
||||
nameAutocompleteFunctions?: boolean;
|
||||
nameAutocompleteVariables?: boolean;
|
||||
namePlaceholder?: string;
|
||||
nameValidate?: InputProps['validate'];
|
||||
noScroll?: boolean;
|
||||
onChange: (pairs: PairWithId[]) => void;
|
||||
pairs: Pair[];
|
||||
stateKey: InputProps['stateKey'];
|
||||
setRef?: (n: PairEditorHandle) => void;
|
||||
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
||||
valueAutocompleteFunctions?: boolean;
|
||||
valueAutocompleteVariables?: boolean | 'environment';
|
||||
valuePlaceholder?: string;
|
||||
valueType?: InputProps['type'] | ((pair: Pair) => InputProps['type']);
|
||||
valueValidate?: InputProps['validate'];
|
||||
};
|
||||
|
||||
export type Pair = {
|
||||
id?: string;
|
||||
enabled?: boolean;
|
||||
name: string;
|
||||
value: string;
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
isFile?: boolean;
|
||||
readOnlyName?: boolean;
|
||||
};
|
||||
|
||||
export type PairWithId = Pair & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
/** Max number of pairs to show before prompting the user to reveal the rest */
|
||||
const MAX_INITIAL_PAIRS = 30;
|
||||
|
||||
export function PairEditor({
|
||||
allowFileValues,
|
||||
allowMultilineValues,
|
||||
className,
|
||||
forcedEnvironmentId,
|
||||
forceUpdateKey,
|
||||
nameAutocomplete,
|
||||
nameAutocompleteFunctions,
|
||||
nameAutocompleteVariables,
|
||||
namePlaceholder,
|
||||
nameValidate,
|
||||
noScroll,
|
||||
onChange,
|
||||
pairs: originalPairs,
|
||||
stateKey,
|
||||
valueAutocomplete,
|
||||
valueAutocompleteFunctions,
|
||||
valueAutocompleteVariables,
|
||||
valuePlaceholder,
|
||||
valueType,
|
||||
valueValidate,
|
||||
setRef,
|
||||
}: PairEditorProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
|
||||
const [pairs, setPairs] = useState<PairWithId[]>([]);
|
||||
const [showAll, toggleShowAll] = useToggle(false);
|
||||
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
|
||||
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
|
||||
const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey();
|
||||
|
||||
const rowsRef = useRef<Record<string, RowHandle | null>>({});
|
||||
|
||||
const handle = useMemo<PairEditorHandle>(
|
||||
() => ({
|
||||
focusName(id: string) {
|
||||
rowsRef.current[id]?.focusName();
|
||||
},
|
||||
focusValue(id: string) {
|
||||
rowsRef.current[id]?.focusValue();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const initPairEditorRow = useCallback(
|
||||
(id: string, n: RowHandle | null) => {
|
||||
const isLast = id === pairs[pairs.length - 1]?.id;
|
||||
if (isLast) return; // Never add the last pair
|
||||
|
||||
rowsRef.current[id] = n;
|
||||
const validHandles = Object.values(rowsRef.current).filter((v) => v != null);
|
||||
|
||||
// Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke)
|
||||
const ready = validHandles.length >= pairs.length - 1;
|
||||
if (ready) {
|
||||
setRef?.(handle);
|
||||
}
|
||||
},
|
||||
[handle, pairs, setRef],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only care about forceUpdateKey
|
||||
useEffect(() => {
|
||||
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
|
||||
const newPairs: PairWithId[] = [];
|
||||
for (let i = 0; i < originalPairs.length; i++) {
|
||||
const p = originalPairs[i];
|
||||
if (!p) continue; // Make TS happy
|
||||
if (isPairEmpty(p)) continue;
|
||||
newPairs.push(ensurePairId(p));
|
||||
}
|
||||
|
||||
// Add empty last pair if there is none
|
||||
const lastPair = newPairs[newPairs.length - 1];
|
||||
if (lastPair == null || !isPairEmpty(lastPair)) {
|
||||
newPairs.push(emptyPair());
|
||||
}
|
||||
|
||||
setPairs(newPairs);
|
||||
regenerateLocalForceUpdateKey();
|
||||
}, [forceUpdateKey]);
|
||||
|
||||
const setPairsAndSave = useCallback(
|
||||
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
|
||||
setPairs((oldPairs) => {
|
||||
const pairs = fn(oldPairs);
|
||||
onChange(pairs);
|
||||
return pairs;
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(pair: PairWithId) =>
|
||||
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
||||
[setPairsAndSave],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(pair: Pair, focusPrevious: boolean) => {
|
||||
if (focusPrevious) {
|
||||
const index = pairs.findIndex((p) => p.id === pair.id);
|
||||
const id = pairs[index - 1]?.id ?? null;
|
||||
rowsRef.current[id ?? 'n/a']?.focusName();
|
||||
}
|
||||
return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
|
||||
},
|
||||
[setPairsAndSave, pairs],
|
||||
);
|
||||
|
||||
const handleFocusName = useCallback(
|
||||
(pair: Pair) => {
|
||||
const isLast = pair.id === pairs[pairs.length - 1]?.id;
|
||||
if (isLast) setPairs([...pairs, emptyPair()]);
|
||||
},
|
||||
[pairs],
|
||||
);
|
||||
|
||||
const handleFocusValue = useCallback(
|
||||
(pair: Pair) => {
|
||||
const isLast = pair.id === pairs[pairs.length - 1]?.id;
|
||||
if (isLast) setPairs([...pairs, emptyPair()]);
|
||||
},
|
||||
[pairs],
|
||||
);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
// dnd-kit: show the “between rows” marker while hovering
|
||||
const onDragMove = useCallback(
|
||||
(e: DragMoveEvent) => {
|
||||
const overId = e.over?.id as string | undefined;
|
||||
if (!overId) return setHoveredIndex(null);
|
||||
|
||||
const overPair = pairs.find((p) => p.id === overId);
|
||||
if (overPair == null) return setHoveredIndex(null);
|
||||
|
||||
const side = computeSideForDragMove(overPair.id, e);
|
||||
const overIndex = pairs.findIndex((p) => p.id === overId);
|
||||
const hoveredIndex = overIndex + (side === 'before' ? 0 : 1);
|
||||
|
||||
setHoveredIndex(hoveredIndex);
|
||||
},
|
||||
[pairs],
|
||||
);
|
||||
|
||||
const onDragStart = useCallback(
|
||||
(e: DragStartEvent) => {
|
||||
const pair = pairs.find((p) => p.id === e.active.id);
|
||||
setIsDragging(pair ?? null);
|
||||
},
|
||||
[pairs],
|
||||
);
|
||||
|
||||
const onDragCancel = useCallback(() => setIsDragging(null), []);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
setIsDragging(null);
|
||||
setHoveredIndex(null);
|
||||
const activeId = e.active.id as string | undefined;
|
||||
const overId = e.over?.id as string | undefined;
|
||||
if (!activeId || !overId) return;
|
||||
|
||||
const from = pairs.findIndex((p) => p.id === activeId);
|
||||
const baseTo = pairs.findIndex((p) => p.id === overId);
|
||||
const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);
|
||||
|
||||
if (from !== -1 && to !== -1 && from !== to) {
|
||||
setPairsAndSave((ps) => {
|
||||
const next = [...ps];
|
||||
const [moved] = next.splice(from, 1);
|
||||
if (moved === undefined) return ps; // Make TS happy
|
||||
next.splice(to > from ? to - 1 : to, 0, moved);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[pairs, hoveredIndex, setPairsAndSave],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'@container relative',
|
||||
'pb-2 mb-auto h-full',
|
||||
!noScroll && 'overflow-y-auto max-h-full',
|
||||
// Move over the width of the drag handle
|
||||
'-mr-2 pr-2',
|
||||
// Pad to make room for the drag divider
|
||||
'pt-0.5',
|
||||
'grid grid-rows-[auto_1fr]',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<DndContext
|
||||
autoScroll
|
||||
sensors={sensors}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={onDragStart}
|
||||
onDragCancel={onDragCancel}
|
||||
collisionDetection={pointerWithin}
|
||||
>
|
||||
{pairs.map((p, i) => {
|
||||
if (!showAll && i > MAX_INITIAL_PAIRS) return null;
|
||||
|
||||
const isLast = i === pairs.length - 1;
|
||||
return (
|
||||
<Fragment key={p.id}>
|
||||
{hoveredIndex === i && <DropMarker />}
|
||||
<PairEditorRow
|
||||
setRef={initPairEditorRow}
|
||||
allowFileValues={allowFileValues}
|
||||
allowMultilineValues={allowMultilineValues}
|
||||
className="py-1"
|
||||
forcedEnvironmentId={forcedEnvironmentId}
|
||||
forceUpdateKey={localForceUpdateKey}
|
||||
index={i}
|
||||
isLast={isLast}
|
||||
isDraggingGlobal={!!isDragging}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
nameAutocompleteFunctions={nameAutocompleteFunctions}
|
||||
nameAutocompleteVariables={nameAutocompleteVariables}
|
||||
namePlaceholder={namePlaceholder}
|
||||
nameValidate={nameValidate}
|
||||
onChange={handleChange}
|
||||
onDelete={handleDelete}
|
||||
onFocusName={handleFocusName}
|
||||
onFocusValue={handleFocusValue}
|
||||
pair={p}
|
||||
stateKey={stateKey}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
valueAutocompleteFunctions={valueAutocompleteFunctions}
|
||||
valueAutocompleteVariables={valueAutocompleteVariables}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
valueType={valueType}
|
||||
valueValidate={valueValidate}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{!showAll && pairs.length > MAX_INITIAL_PAIRS && (
|
||||
<Button onClick={toggleShowAll} variant="border" className="m-2" size="xs">
|
||||
Show {pairs.length - MAX_INITIAL_PAIRS} More
|
||||
</Button>
|
||||
)}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{isDragging && (
|
||||
<PairEditorRow
|
||||
namePlaceholder={namePlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
className="opacity-80"
|
||||
pair={isDragging}
|
||||
index={0}
|
||||
stateKey={null}
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
<div
|
||||
// There's a weird bug where clicking below one of the above Codemirror inputs will cause
|
||||
// it to focus. Putting this element here prevents that
|
||||
aria-hidden
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PairEditorRowProps = {
|
||||
className?: string;
|
||||
pair: PairWithId;
|
||||
forceFocusNamePairId?: string | null;
|
||||
forceFocusValuePairId?: string | null;
|
||||
onChange?: (pair: PairWithId) => void;
|
||||
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
|
||||
onFocusName?: (pair: PairWithId) => void;
|
||||
onFocusValue?: (pair: PairWithId) => void;
|
||||
onSubmit?: (pair: PairWithId) => void;
|
||||
isLast?: boolean;
|
||||
disabled?: boolean;
|
||||
disableDrag?: boolean;
|
||||
index: number;
|
||||
isDraggingGlobal?: boolean;
|
||||
setRef?: (id: string, n: RowHandle | null) => void;
|
||||
} & Pick<
|
||||
PairEditorProps,
|
||||
| 'allowFileValues'
|
||||
| 'allowMultilineValues'
|
||||
| 'forcedEnvironmentId'
|
||||
| 'forceUpdateKey'
|
||||
| 'nameAutocomplete'
|
||||
| 'nameAutocompleteVariables'
|
||||
| 'namePlaceholder'
|
||||
| 'nameValidate'
|
||||
| 'nameAutocompleteFunctions'
|
||||
| 'stateKey'
|
||||
| 'valueAutocomplete'
|
||||
| 'valueAutocompleteFunctions'
|
||||
| 'valueAutocompleteVariables'
|
||||
| 'valuePlaceholder'
|
||||
| 'valueType'
|
||||
| 'valueValidate'
|
||||
>;
|
||||
|
||||
interface RowHandle {
|
||||
focusName(): void;
|
||||
focusValue(): void;
|
||||
}
|
||||
|
||||
export function PairEditorRow({
|
||||
allowFileValues,
|
||||
allowMultilineValues,
|
||||
className,
|
||||
disableDrag,
|
||||
disabled,
|
||||
forceUpdateKey,
|
||||
forcedEnvironmentId,
|
||||
index,
|
||||
isLast,
|
||||
nameAutocomplete,
|
||||
nameAutocompleteFunctions,
|
||||
nameAutocompleteVariables,
|
||||
namePlaceholder,
|
||||
nameValidate,
|
||||
isDraggingGlobal,
|
||||
onChange,
|
||||
onDelete,
|
||||
onFocusName,
|
||||
onFocusValue,
|
||||
pair,
|
||||
stateKey,
|
||||
valueAutocomplete,
|
||||
valueAutocompleteFunctions,
|
||||
valueAutocompleteVariables,
|
||||
valuePlaceholder,
|
||||
valueType,
|
||||
valueValidate,
|
||||
setRef,
|
||||
}: PairEditorRowProps) {
|
||||
const nameInputRef = useRef<InputHandle>(null);
|
||||
const valueInputRef = useRef<InputHandle>(null);
|
||||
const handle = useRef<RowHandle>({
|
||||
focusName() {
|
||||
nameInputRef.current?.focus();
|
||||
},
|
||||
focusValue() {
|
||||
valueInputRef.current?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const initNameInputRef = useCallback(
|
||||
(n: InputHandle | null) => {
|
||||
nameInputRef.current = n;
|
||||
if (nameInputRef.current && valueInputRef.current) {
|
||||
setRef?.(pair.id, handle.current);
|
||||
}
|
||||
},
|
||||
[pair.id, setRef],
|
||||
);
|
||||
|
||||
const initValueInputRef = useCallback(
|
||||
(n: InputHandle | null) => {
|
||||
valueInputRef.current = n;
|
||||
if (nameInputRef.current && valueInputRef.current) {
|
||||
setRef?.(pair.id, handle.current);
|
||||
}
|
||||
},
|
||||
[pair.id, setRef],
|
||||
);
|
||||
|
||||
const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]);
|
||||
const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]);
|
||||
const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]);
|
||||
|
||||
const handleChangeEnabled = useMemo(
|
||||
() => (enabled: boolean) => onChange?.({ ...pair, enabled }),
|
||||
[onChange, pair],
|
||||
);
|
||||
|
||||
const handleChangeName = useMemo(
|
||||
() => (name: string) => onChange?.({ ...pair, name }),
|
||||
[onChange, pair],
|
||||
);
|
||||
|
||||
const handleChangeValueText = useMemo(
|
||||
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
|
||||
[onChange, pair],
|
||||
);
|
||||
|
||||
const handleChangeValueFile = useMemo(
|
||||
() =>
|
||||
({ filePath }: { filePath: string | null }) =>
|
||||
onChange?.({ ...pair, value: filePath ?? '', isFile: true }),
|
||||
[onChange, pair],
|
||||
);
|
||||
|
||||
const handleChangeValueContentType = useMemo(
|
||||
() => (contentType: string) => onChange?.({ ...pair, contentType }),
|
||||
[onChange, pair],
|
||||
);
|
||||
|
||||
const handleChangeValueFilename = useMemo(
|
||||
() => (filename: string) => onChange?.({ ...pair, filename }),
|
||||
[onChange, pair],
|
||||
);
|
||||
|
||||
const handleEditMultiLineValue = useCallback(
|
||||
() =>
|
||||
showDialog({
|
||||
id: 'pair-edit-multiline',
|
||||
size: 'dynamic',
|
||||
title: <>Edit {pair.name}</>,
|
||||
render: ({ hide }) => (
|
||||
<MultilineEditDialog
|
||||
hide={hide}
|
||||
onChange={handleChangeValueText}
|
||||
defaultValue={pair.value}
|
||||
contentType={pair.contentType ?? null}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[handleChangeValueText, pair.contentType, pair.name, pair.value],
|
||||
);
|
||||
|
||||
const defaultItems = useMemo(
|
||||
(): DropdownItem[] => [
|
||||
{
|
||||
label: 'Edit Multi-line',
|
||||
onSelect: handleEditMultiLineValue,
|
||||
hidden: !allowMultilineValues,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onSelect: handleDelete,
|
||||
color: 'danger',
|
||||
},
|
||||
],
|
||||
[allowMultilineValues, handleDelete, handleEditMultiLineValue],
|
||||
);
|
||||
|
||||
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id });
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id });
|
||||
|
||||
// Filter out the current pair name
|
||||
const valueAutocompleteVariablesFiltered = useMemo<EditorProps['autocompleteVariables']>(() => {
|
||||
if (valueAutocompleteVariables === 'environment') {
|
||||
return (v: WrappedEnvironmentVariable): boolean => v.variable.name !== pair.name;
|
||||
}
|
||||
return valueAutocompleteVariables;
|
||||
}, [pair.name, valueAutocompleteVariables]);
|
||||
|
||||
const handleSetRef = useCallback(
|
||||
(n: HTMLDivElement | null) => {
|
||||
setDraggableRef(n);
|
||||
setDroppableRef(n);
|
||||
},
|
||||
[setDraggableRef, setDroppableRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={handleSetRef}
|
||||
className={classNames(
|
||||
className,
|
||||
'group/pair-row grid grid-cols-[auto_auto_minmax(0,1fr)_auto]',
|
||||
'grid-rows-1 items-center',
|
||||
!pair.enabled && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title={pair.enabled ? 'Disable item' : 'Enable item'}
|
||||
disabled={isLast || disabled}
|
||||
checked={isLast ? false : !!pair.enabled}
|
||||
className={classNames(isLast && '!opacity-disabled')}
|
||||
onChange={handleChangeEnabled}
|
||||
/>
|
||||
{!isLast && !disableDrag ? (
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={classNames(
|
||||
'py-2 h-7 w-4 flex items-center',
|
||||
'justify-center opacity-0 group-hover/pair-row:opacity-70',
|
||||
)}
|
||||
>
|
||||
<Icon size="sm" icon="grip_vertical" className="pointer-events-none" />
|
||||
</div>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
'grid items-center',
|
||||
'@xs:gap-2 @xs:!grid-rows-1 @xs:!grid-cols-[minmax(0,1fr)_minmax(0,1fr)]',
|
||||
'gap-0.5 grid-cols-1 grid-rows-2',
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
setRef={initNameInputRef}
|
||||
hideLabel
|
||||
stateKey={`name.${pair.id}.${stateKey}`}
|
||||
disabled={disabled}
|
||||
wrapLines={false}
|
||||
readOnly={pair.readOnlyName || isDraggingGlobal}
|
||||
size="sm"
|
||||
required={!isLast && !!pair.enabled && !!pair.value}
|
||||
validate={nameValidate}
|
||||
forcedEnvironmentId={forcedEnvironmentId}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
containerClassName={classNames('bg-surface', isLast && 'border-dashed')}
|
||||
defaultValue={pair.name}
|
||||
label="Name"
|
||||
name={`name[${index}]`}
|
||||
onChange={handleChangeName}
|
||||
onFocus={handleFocusName}
|
||||
placeholder={namePlaceholder ?? 'name'}
|
||||
autocomplete={nameAutocomplete}
|
||||
autocompleteVariables={nameAutocompleteVariables}
|
||||
autocompleteFunctions={nameAutocompleteFunctions}
|
||||
/>
|
||||
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
|
||||
{pair.isFile ? (
|
||||
<SelectFile
|
||||
disabled={disabled}
|
||||
inline
|
||||
size="xs"
|
||||
filePath={pair.value}
|
||||
nameOverride={pair.filename || null}
|
||||
onChange={handleChangeValueFile}
|
||||
/>
|
||||
) : pair.value.includes('\n') ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleEditMultiLineValue}
|
||||
title={pair.value}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{pair.value.split('\n').join(' ')}
|
||||
</Button>
|
||||
) : (
|
||||
<Input
|
||||
setRef={initValueInputRef}
|
||||
hideLabel
|
||||
stateKey={`value.${pair.id}.${stateKey}`}
|
||||
wrapLines={false}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
readOnly={isDraggingGlobal}
|
||||
containerClassName={classNames('bg-surface', isLast && 'border-dashed')}
|
||||
validate={valueValidate}
|
||||
forcedEnvironmentId={forcedEnvironmentId}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
defaultValue={pair.value}
|
||||
label="Value"
|
||||
name={`value[${index}]`}
|
||||
onChange={handleChangeValueText}
|
||||
onFocus={handleFocusValue}
|
||||
type={isLast ? 'text' : typeof valueType === 'function' ? valueType(pair) : valueType}
|
||||
placeholder={valuePlaceholder ?? 'value'}
|
||||
autocomplete={valueAutocomplete?.(pair.name)}
|
||||
autocompleteFunctions={valueAutocompleteFunctions}
|
||||
autocompleteVariables={valueAutocompleteVariablesFiltered}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{allowFileValues ? (
|
||||
<FileActionsDropdown
|
||||
pair={pair}
|
||||
onChangeFile={handleChangeValueFile}
|
||||
onChangeText={handleChangeValueText}
|
||||
onChangeContentType={handleChangeValueContentType}
|
||||
onChangeFilename={handleChangeValueFilename}
|
||||
onDelete={handleDelete}
|
||||
editMultiLine={handleEditMultiLineValue}
|
||||
/>
|
||||
) : (
|
||||
<Dropdown items={defaultItems}>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
icon={isLast || disabled ? 'empty' : 'chevron_down'}
|
||||
title="Select form data type"
|
||||
className="text-text-subtlest"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileItems: RadioDropdownItem<string>[] = [
|
||||
{ label: 'Text', value: 'text' },
|
||||
{ label: 'File', value: 'file' },
|
||||
];
|
||||
|
||||
function FileActionsDropdown({
|
||||
pair,
|
||||
onChangeFile,
|
||||
onChangeText,
|
||||
onChangeContentType,
|
||||
onChangeFilename,
|
||||
onDelete,
|
||||
editMultiLine,
|
||||
}: {
|
||||
pair: Pair;
|
||||
onChangeFile: ({ filePath }: { filePath: string | null }) => void;
|
||||
onChangeText: (text: string) => void;
|
||||
onChangeContentType: (contentType: string) => void;
|
||||
onChangeFilename: (filename: string) => void;
|
||||
onDelete: () => void;
|
||||
editMultiLine: () => void;
|
||||
}) {
|
||||
const onChange = useCallback(
|
||||
(v: string) => {
|
||||
if (v === 'file') onChangeFile({ filePath: '' });
|
||||
else onChangeText('');
|
||||
},
|
||||
[onChangeFile, onChangeText],
|
||||
);
|
||||
|
||||
const itemsAfter = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
label: 'Edit Multi-Line',
|
||||
leftSlot: <Icon icon="file_code" />,
|
||||
hidden: pair.isFile,
|
||||
onSelect: editMultiLine,
|
||||
},
|
||||
{
|
||||
label: 'Set Content-Type',
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
onSelect: async () => {
|
||||
const contentType = await showPrompt({
|
||||
id: 'content-type',
|
||||
title: 'Override Content-Type',
|
||||
label: 'Content-Type',
|
||||
required: false,
|
||||
placeholder: 'text/plain',
|
||||
defaultValue: pair.contentType ?? '',
|
||||
confirmText: 'Set',
|
||||
description: 'Leave blank to auto-detect',
|
||||
});
|
||||
if (contentType == null) return;
|
||||
onChangeContentType(contentType);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Set File Name',
|
||||
leftSlot: <Icon icon="file_code" />,
|
||||
onSelect: async () => {
|
||||
console.log('PAIR', pair);
|
||||
const defaultFilename = await basename(pair.value ?? '');
|
||||
const filename = await showPrompt({
|
||||
id: 'filename',
|
||||
title: 'Override Filename',
|
||||
label: 'Filename',
|
||||
required: false,
|
||||
placeholder: defaultFilename ?? 'myfile.png',
|
||||
defaultValue: pair.filename,
|
||||
confirmText: 'Set',
|
||||
description: 'Leave blank to use the name of the selected file',
|
||||
});
|
||||
if (filename == null) return;
|
||||
onChangeFilename(filename);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Unset File',
|
||||
leftSlot: <Icon icon="x" />,
|
||||
hidden: pair.isFile,
|
||||
onSelect: async () => {
|
||||
onChangeFile({ filePath: null });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onSelect: onDelete,
|
||||
variant: 'danger',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
color: 'danger',
|
||||
},
|
||||
],
|
||||
[
|
||||
editMultiLine,
|
||||
onChangeContentType,
|
||||
onChangeFile,
|
||||
onDelete,
|
||||
pair.contentType,
|
||||
pair.isFile,
|
||||
onChangeFilename,
|
||||
pair.filename,
|
||||
pair,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<RadioDropdown
|
||||
value={pair.isFile ? 'file' : 'text'}
|
||||
onChange={onChange}
|
||||
items={fileItems}
|
||||
itemsAfter={itemsAfter}
|
||||
>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
icon="chevron_down"
|
||||
title="Select form data type"
|
||||
className="text-text-subtlest"
|
||||
/>
|
||||
</RadioDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyPair(): PairWithId {
|
||||
return ensurePairId({ enabled: true, name: '', value: '' });
|
||||
}
|
||||
|
||||
function isPairEmpty(pair: Pair): boolean {
|
||||
return !pair.name && !pair.value;
|
||||
}
|
||||
|
||||
function MultilineEditDialog({
|
||||
defaultValue,
|
||||
contentType,
|
||||
onChange,
|
||||
hide,
|
||||
}: {
|
||||
defaultValue: string;
|
||||
contentType: string | null;
|
||||
onChange: (value: string) => void;
|
||||
hide: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState<string>(defaultValue);
|
||||
const language = languageFromContentType(contentType, value);
|
||||
return (
|
||||
<div className="w-[100vw] max-w-[40rem] h-[50vh] max-h-full grid grid-rows-[minmax(0,1fr)_auto]">
|
||||
<Editor
|
||||
heightMode="auto"
|
||||
defaultValue={defaultValue}
|
||||
language={language}
|
||||
onChange={setValue}
|
||||
stateKey={null}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
color="primary"
|
||||
className="ml-auto my-2"
|
||||
onClick={() => {
|
||||
onChange(value);
|
||||
hide();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
apps/yaak-client/components/core/PairEditor.util.tsx
Normal file
9
apps/yaak-client/components/core/PairEditor.util.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import type { Pair, PairWithId } from './PairEditor';
|
||||
|
||||
export function ensurePairId(p: Pair): PairWithId {
|
||||
if (typeof p.id === 'string') {
|
||||
return p as PairWithId;
|
||||
}
|
||||
return { ...p, id: p.id ?? generateId() };
|
||||
}
|
||||
38
apps/yaak-client/components/core/PairOrBulkEditor.tsx
Normal file
38
apps/yaak-client/components/core/PairOrBulkEditor.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import classNames from 'classnames';
|
||||
import { useKeyValue } from '../../hooks/useKeyValue';
|
||||
import { BulkPairEditor } from './BulkPairEditor';
|
||||
import { IconButton } from './IconButton';
|
||||
import type { PairEditorProps } from './PairEditor';
|
||||
import { PairEditor } from './PairEditor';
|
||||
|
||||
interface Props extends PairEditorProps {
|
||||
preferenceName: string;
|
||||
forcedEnvironmentId?: string;
|
||||
}
|
||||
|
||||
export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
|
||||
const { value: useBulk, set: setUseBulk } = useKeyValue<boolean>({
|
||||
namespace: 'global',
|
||||
key: ['bulk_edit', preferenceName],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full group/wrapper">
|
||||
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}
|
||||
<div className="absolute right-0 bottom-0">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
title={useBulk ? 'Enable form edit' : 'Enable bulk edit'}
|
||||
className={classNames(
|
||||
'transition-opacity opacity-0 group-hover:opacity-80 hover:!opacity-100 shadow',
|
||||
'bg-surface hover:text group-hover/wrapper:opacity-100',
|
||||
)}
|
||||
onClick={() => setUseBulk((b) => !b)}
|
||||
icon={useBulk ? 'table' : 'file_code'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
apps/yaak-client/components/core/PillButton.tsx
Normal file
14
apps/yaak-client/components/core/PillButton.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ButtonProps } from './Button';
|
||||
import { Button } from './Button';
|
||||
|
||||
export function PillButton({ className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
className={classNames(className, '!rounded-full mx-1 !px-3')}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
233
apps/yaak-client/components/core/PlainInput.tsx
Normal file
233
apps/yaak-client/components/core/PlainInput.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import classNames from 'classnames';
|
||||
import type { FocusEvent, HTMLAttributes, ReactNode } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useRandomKey } from '../../hooks/useRandomKey';
|
||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import { IconButton } from './IconButton';
|
||||
import type { InputProps } from './Input';
|
||||
import { Label } from './Label';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
export type PlainInputProps = Omit<
|
||||
InputProps,
|
||||
| 'wrapLines'
|
||||
| 'onKeyDown'
|
||||
| 'type'
|
||||
| 'stateKey'
|
||||
| 'autocompleteVariables'
|
||||
| 'autocompleteFunctions'
|
||||
| 'autocomplete'
|
||||
| 'extraExtensions'
|
||||
| 'forcedEnvironmentId'
|
||||
> &
|
||||
Pick<HTMLAttributes<HTMLInputElement>, 'onKeyDownCapture'> & {
|
||||
onFocusRaw?: HTMLAttributes<HTMLInputElement>['onFocus'];
|
||||
type?: 'text' | 'password' | 'number';
|
||||
step?: number;
|
||||
hideObscureToggle?: boolean;
|
||||
labelRightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput(
|
||||
{
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
className,
|
||||
containerClassName,
|
||||
defaultValue,
|
||||
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||
help,
|
||||
hideLabel,
|
||||
hideObscureToggle,
|
||||
label,
|
||||
labelClassName,
|
||||
labelPosition = 'top',
|
||||
labelRightSlot,
|
||||
leftSlot,
|
||||
name,
|
||||
onBlur,
|
||||
onChange,
|
||||
onFocus,
|
||||
onFocusRaw,
|
||||
onKeyDownCapture,
|
||||
onPaste,
|
||||
placeholder,
|
||||
required,
|
||||
rightSlot,
|
||||
size = 'md',
|
||||
tint,
|
||||
type = 'text',
|
||||
validate,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
// Track a local key for updates. If the default value is changed when the input is not in focus,
|
||||
// regenerate this to force the field to update.
|
||||
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
|
||||
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
|
||||
|
||||
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
|
||||
ref,
|
||||
() => inputRef.current,
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: FocusEvent<HTMLInputElement>) => {
|
||||
onFocusRaw?.(e);
|
||||
setFocused(true);
|
||||
if (autoSelect) {
|
||||
inputRef.current?.select();
|
||||
textareaRef.current?.select();
|
||||
}
|
||||
onFocus?.();
|
||||
},
|
||||
[autoSelect, onFocus, onFocusRaw],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
|
||||
// Force input to update when receiving change and not in focus
|
||||
useLayoutEffect(() => {
|
||||
const isFocused = document.activeElement === inputRef.current;
|
||||
if (defaultValue != null && !isFocused) {
|
||||
regenerateFocusedUpdateKey();
|
||||
}
|
||||
}, [regenerateFocusedUpdateKey, defaultValue]);
|
||||
|
||||
const id = useRef(`input-${generateId()}`);
|
||||
const commonClassName = classNames(
|
||||
className,
|
||||
'!bg-transparent min-w-0 w-full focus:outline-none placeholder:text-placeholder',
|
||||
'px-2 text-xs font-mono cursor-text',
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange?.(value);
|
||||
setHasChanged(true);
|
||||
const isValid = (value: string) => {
|
||||
if (required && !validateRequire(value)) return false;
|
||||
if (typeof validate === 'boolean') return validate;
|
||||
if (typeof validate === 'function' && !validate(value)) return false;
|
||||
return true;
|
||||
};
|
||||
inputRef.current?.setCustomValidity(isValid(value) ? '' : 'Invalid value');
|
||||
},
|
||||
[onChange, required, setHasChanged, validate],
|
||||
);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={classNames(
|
||||
'w-full',
|
||||
'pointer-events-auto', // Just in case we're placing in disabled parent
|
||||
labelPosition === 'left' && 'flex items-center gap-2',
|
||||
labelPosition === 'top' && 'flex-row gap-0.5',
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
htmlFor={id.current}
|
||||
className={labelClassName}
|
||||
visuallyHidden={hideLabel}
|
||||
required={required}
|
||||
help={help}
|
||||
rightSlot={labelRightSlot}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<HStack
|
||||
alignItems="stretch"
|
||||
className={classNames(
|
||||
containerClassName,
|
||||
'x-theme-input',
|
||||
'relative w-full rounded-md text',
|
||||
'border',
|
||||
'overflow-hidden',
|
||||
focused ? 'border-border-focus' : 'border-border-subtle',
|
||||
hasChanged && 'has-[:invalid]:border-danger', // For built-in HTML validation
|
||||
size === 'md' && 'min-h-md',
|
||||
size === 'sm' && 'min-h-sm',
|
||||
size === 'xs' && 'min-h-xs',
|
||||
size === '2xs' && 'min-h-2xs',
|
||||
)}
|
||||
>
|
||||
{tint != null && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
'absolute inset-0 opacity-5 pointer-events-none',
|
||||
tint === 'info' && 'bg-info',
|
||||
tint === 'warning' && 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{leftSlot}
|
||||
<HStack
|
||||
className={classNames(
|
||||
'w-full min-w-0',
|
||||
leftSlot ? 'pl-0.5 -ml-2' : null,
|
||||
rightSlot ? 'pr-0.5 -mr-2' : null,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={id.current}
|
||||
ref={inputRef}
|
||||
key={forceUpdateKey}
|
||||
type={type === 'password' && !obscured ? 'text' : type}
|
||||
name={name}
|
||||
// biome-ignore lint/a11y/noAutofocus: Who cares
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onPaste={(e) => onPaste?.(e.clipboardData.getData('Text'))}
|
||||
className={classNames(commonClassName, 'h-full')}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
/>
|
||||
</HStack>
|
||||
{type === 'password' && !hideObscureToggle && (
|
||||
<IconButton
|
||||
title={obscured ? `Show ${label}` : `Obscure ${label}`}
|
||||
size="xs"
|
||||
className="mr-0.5 group/obscure !h-auto my-0.5"
|
||||
iconClassName="group-hover/obscure:text"
|
||||
iconSize="sm"
|
||||
icon={obscured ? 'eye' : 'eye_closed'}
|
||||
onClick={() => setObscured((o) => !o)}
|
||||
/>
|
||||
)}
|
||||
{rightSlot}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function validateRequire(v: string) {
|
||||
return v.length > 0;
|
||||
}
|
||||
66
apps/yaak-client/components/core/Prompt.tsx
Normal file
66
apps/yaak-client/components/core/Prompt.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { FormInput, JsonPrimitive } from '@yaakapp-internal/plugins';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import { DynamicForm } from '../DynamicForm';
|
||||
import { Button } from './Button';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
export interface PromptProps {
|
||||
inputs: FormInput[];
|
||||
onCancel: () => void;
|
||||
onResult: (value: Record<string, JsonPrimitive> | null) => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onValuesChange?: (values: Record<string, JsonPrimitive>) => void;
|
||||
onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;
|
||||
}
|
||||
|
||||
export function Prompt({
|
||||
onCancel,
|
||||
inputs: initialInputs,
|
||||
onResult,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
onValuesChange,
|
||||
onInputsUpdated,
|
||||
}: PromptProps) {
|
||||
const [value, setValue] = useState<Record<string, JsonPrimitive>>({});
|
||||
const [inputs, setInputs] = useState<FormInput[]>(initialInputs);
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
onResult(value);
|
||||
},
|
||||
[onResult, value],
|
||||
);
|
||||
|
||||
// Register callback for external input updates (from plugin dynamic resolution)
|
||||
useEffect(() => {
|
||||
onInputsUpdated?.(setInputs);
|
||||
}, [onInputsUpdated]);
|
||||
|
||||
// Notify of value changes for dynamic resolution
|
||||
useEffect(() => {
|
||||
onValuesChange?.(value);
|
||||
}, [value, onValuesChange]);
|
||||
|
||||
const id = `prompt.form.${useRef(generateId()).current}`;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button onClick={onCancel} variant="border" color="secondary">
|
||||
{cancelText || 'Cancel'}
|
||||
</Button>
|
||||
<Button type="submit" color="primary">
|
||||
{confirmText || 'Done'}
|
||||
</Button>
|
||||
</HStack>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
66
apps/yaak-client/components/core/RadioCards.tsx
Normal file
66
apps/yaak-client/components/core/RadioCards.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface RadioCardOption<T extends string> {
|
||||
value: T;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
}
|
||||
|
||||
export interface RadioCardsProps<T extends string> {
|
||||
value: T | null;
|
||||
onChange: (value: T) => void;
|
||||
options: RadioCardOption<T>[];
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function RadioCards<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
name,
|
||||
}: RadioCardsProps<T>) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option) => {
|
||||
const selected = value === option.value;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={classNames(
|
||||
'flex items-start gap-3 p-3 rounded-lg border cursor-pointer',
|
||||
'transition-colors',
|
||||
selected
|
||||
? 'border-border-focus'
|
||||
: 'border-border-subtle hocus:border-text-subtlest',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={selected}
|
||||
onChange={() => onChange(option.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
'mt-1 w-4 h-4 flex-shrink-0 rounded-full border',
|
||||
'flex items-center justify-center',
|
||||
selected ? 'border-focus' : 'border-border',
|
||||
)}
|
||||
>
|
||||
{selected && <div className="w-2 h-2 rounded-full bg-text" />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold text-text">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-sm text-text-subtle">{option.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
apps/yaak-client/components/core/RadioDropdown.tsx
Normal file
69
apps/yaak-client/components/core/RadioDropdown.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { DropdownItem, DropdownItemSeparator, DropdownProps } from './Dropdown';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export type RadioDropdownItem<T = string | null> =
|
||||
| {
|
||||
type?: 'default';
|
||||
label: ReactNode;
|
||||
shortLabel?: ReactNode;
|
||||
value: T;
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
| DropdownItemSeparator;
|
||||
|
||||
export interface RadioDropdownProps<T = string | null> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
itemsBefore?: DropdownItem[];
|
||||
items: RadioDropdownItem<T>[];
|
||||
itemsAfter?: DropdownItem[];
|
||||
children: DropdownProps['children'];
|
||||
}
|
||||
|
||||
export function RadioDropdown<T = string | null>({
|
||||
value,
|
||||
items,
|
||||
itemsAfter,
|
||||
itemsBefore,
|
||||
onChange,
|
||||
children,
|
||||
}: RadioDropdownProps<T>) {
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
...((itemsBefore
|
||||
? [
|
||||
...itemsBefore,
|
||||
{
|
||||
type: 'separator',
|
||||
hidden: itemsBefore[itemsBefore.length - 1]?.type === 'separator',
|
||||
},
|
||||
]
|
||||
: []) as DropdownItem[]),
|
||||
...items.map((item) => {
|
||||
if (item.type === 'separator') {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
key: item.value,
|
||||
label: item.label,
|
||||
rightSlot: item.rightSlot,
|
||||
onSelect: () => onChange(item.value),
|
||||
leftSlot: <Icon icon={value === item.value ? 'check' : 'empty'} />,
|
||||
} as DropdownItem;
|
||||
}),
|
||||
...((itemsAfter
|
||||
? [{ type: 'separator', hidden: itemsAfter[0]?.type === 'separator' }, ...itemsAfter]
|
||||
: []) as DropdownItem[]),
|
||||
],
|
||||
[itemsBefore, items, itemsAfter, value, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown fullWidth items={dropdownItems}>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
124
apps/yaak-client/components/core/SegmentedControl.tsx
Normal file
124
apps/yaak-client/components/core/SegmentedControl.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import classNames from 'classnames';
|
||||
import { type ReactNode, useRef } from 'react';
|
||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import { Button } from './Button';
|
||||
import type { IconProps } from './Icon';
|
||||
import { IconButton, type IconButtonProps } from './IconButton';
|
||||
import { Label } from './Label';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
interface Props<T extends string> {
|
||||
options: { value: T; label: string; icon?: IconProps['icon'] }[];
|
||||
onChange: (value: T) => void;
|
||||
value: T;
|
||||
name: string;
|
||||
size?: IconButtonProps['size'];
|
||||
label: string;
|
||||
className?: string;
|
||||
hideLabel?: boolean;
|
||||
labelClassName?: string;
|
||||
help?: ReactNode;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
size = 'xs',
|
||||
label,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
help,
|
||||
className,
|
||||
}: Props<T>) {
|
||||
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const id = useRef(`input-${generateId()}`);
|
||||
|
||||
return (
|
||||
<div className="w-full grid">
|
||||
<Label
|
||||
htmlFor={id.current}
|
||||
help={help}
|
||||
visuallyHidden={hideLabel}
|
||||
className={classNames(labelClassName)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<HStack
|
||||
id={id.current}
|
||||
ref={containerRef}
|
||||
role="group"
|
||||
dir="ltr"
|
||||
space={1}
|
||||
className={classNames(
|
||||
className,
|
||||
'bg-surface-highlight rounded-lg mb-auto mr-auto',
|
||||
'transition-opacity transform-gpu p-1',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
const selectedIndex = options.findIndex((o) => o.value === selectedValue);
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const newIndex = Math.abs((selectedIndex + 1) % options.length);
|
||||
options[newIndex] && setSelectedValue(options[newIndex].value);
|
||||
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
|
||||
child.focus();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const newIndex = Math.abs((selectedIndex - 1) % options.length);
|
||||
options[newIndex] && setSelectedValue(options[newIndex].value);
|
||||
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
|
||||
child.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.map((o) => {
|
||||
const isSelected = selectedValue === o.value;
|
||||
const isActive = value === o.value;
|
||||
if (o.icon == null) {
|
||||
return (
|
||||
<Button
|
||||
key={o.label}
|
||||
aria-checked={isActive}
|
||||
size={size}
|
||||
variant="solid"
|
||||
color={isActive ? 'secondary' : undefined}
|
||||
role="radio"
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
className={classNames(
|
||||
isActive && '!text-text',
|
||||
'focus:ring-1 focus:ring-border-focus',
|
||||
)}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<IconButton
|
||||
key={o.label}
|
||||
aria-checked={isActive}
|
||||
size={size}
|
||||
variant="solid"
|
||||
color={isActive ? 'secondary' : undefined}
|
||||
role="radio"
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
className={classNames(
|
||||
isActive && '!text-text',
|
||||
'!px-1.5 !w-auto',
|
||||
'focus:ring-border-focus',
|
||||
)}
|
||||
title={o.label}
|
||||
icon={o.icon}
|
||||
onClick={() => onChange(o.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
apps/yaak-client/components/core/Select.tsx
Normal file
137
apps/yaak-client/components/core/Select.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { ButtonProps } from './Button';
|
||||
import { Button } from './Button';
|
||||
import { Label } from './Label';
|
||||
import type { RadioDropdownItem } from './RadioDropdown';
|
||||
import { RadioDropdown } from './RadioDropdown';
|
||||
import { HStack } from './Stacks';
|
||||
|
||||
export interface SelectProps<T extends string> {
|
||||
name: string;
|
||||
label: string;
|
||||
labelPosition?: 'top' | 'left';
|
||||
labelClassName?: string;
|
||||
hideLabel?: boolean;
|
||||
value: T;
|
||||
help?: ReactNode;
|
||||
leftSlot?: ReactNode;
|
||||
options: RadioDropdownItem<T>[];
|
||||
onChange: (value: T) => void;
|
||||
defaultValue?: T;
|
||||
size?: ButtonProps['size'];
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
filterable?: boolean;
|
||||
}
|
||||
|
||||
export function Select<T extends string>({
|
||||
labelPosition = 'top',
|
||||
name,
|
||||
help,
|
||||
labelClassName,
|
||||
disabled,
|
||||
hideLabel,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
leftSlot,
|
||||
onChange,
|
||||
className,
|
||||
defaultValue,
|
||||
filterable,
|
||||
size = 'md',
|
||||
}: SelectProps<T>) {
|
||||
const [focused, setFocused] = useState<boolean>(false);
|
||||
const id = `input-${name}`;
|
||||
const isInvalidSelection = options.find((o) => 'value' in o && o.value === value) == null;
|
||||
|
||||
const handleChange = (value: T) => {
|
||||
onChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'x-theme-input',
|
||||
'w-full',
|
||||
'pointer-events-auto', // Just in case we're placing in disabled parent
|
||||
labelPosition === 'left' && 'grid grid-cols-[auto_1fr] items-center gap-2',
|
||||
labelPosition === 'top' && 'flex-row gap-0.5',
|
||||
)}
|
||||
>
|
||||
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
|
||||
{label}
|
||||
</Label>
|
||||
{type() === 'macos' && !filterable ? (
|
||||
<HStack
|
||||
space={2}
|
||||
className={classNames(
|
||||
'w-full rounded-md text text-sm font-mono',
|
||||
'pl-2',
|
||||
'border',
|
||||
focused && !disabled ? 'border-border-focus' : 'border-border',
|
||||
disabled && 'border-dotted',
|
||||
isInvalidSelection && 'border-danger',
|
||||
size === 'xs' && 'h-xs',
|
||||
size === 'sm' && 'h-sm',
|
||||
size === 'md' && 'h-md',
|
||||
)}
|
||||
>
|
||||
{leftSlot && <div>{leftSlot}</div>}
|
||||
<select
|
||||
value={value}
|
||||
style={selectBackgroundStyles}
|
||||
onChange={(e) => handleChange(e.target.value as T)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
'pr-7 w-full outline-none bg-transparent disabled:opacity-disabled',
|
||||
'leading-[1] rounded-none', // Center the text better vertically
|
||||
)}
|
||||
>
|
||||
{isInvalidSelection && <option value={'__NONE__'}>-- Select an Option --</option>}
|
||||
{options.map((o) => {
|
||||
if (o.type === 'separator') return null;
|
||||
return (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
{o.value === defaultValue && ' (default)'}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</HStack>
|
||||
) : (
|
||||
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
|
||||
// light mode
|
||||
<RadioDropdown value={value} onChange={handleChange} items={options}>
|
||||
<Button
|
||||
className="w-full text-sm font-mono"
|
||||
justify="start"
|
||||
variant="border"
|
||||
size={size}
|
||||
leftSlot={leftSlot}
|
||||
disabled={disabled}
|
||||
forDropdown
|
||||
>
|
||||
{options.find((o) => o.type !== 'separator' && o.value === value)?.label ?? '--'}
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectBackgroundStyles: CSSProperties = {
|
||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
|
||||
backgroundPosition: 'right 0.3rem center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: '1.5em 1.5em',
|
||||
appearance: 'none',
|
||||
printColorAdjust: 'exact',
|
||||
};
|
||||
43
apps/yaak-client/components/core/Separator.tsx
Normal file
43
apps/yaak-client/components/core/Separator.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Color } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
dashed?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
color?: Color;
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
color,
|
||||
className,
|
||||
dashed,
|
||||
orientation = 'horizontal',
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<div role="presentation" className={classNames(className, 'flex items-center w-full')}>
|
||||
{children && (
|
||||
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
'h-0 border-t opacity-60',
|
||||
color == null && 'border-border',
|
||||
color === 'primary' && 'border-primary',
|
||||
color === 'secondary' && 'border-secondary',
|
||||
color === 'success' && 'border-success',
|
||||
color === 'notice' && 'border-notice',
|
||||
color === 'warning' && 'border-warning',
|
||||
color === 'danger' && 'border-danger',
|
||||
color === 'info' && 'border-info',
|
||||
dashed && 'border-dashed',
|
||||
orientation === 'horizontal' && 'w-full h-[1px]',
|
||||
orientation === 'vertical' && 'h-full w-[1px]',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/yaak-client/components/core/SizeTag.tsx
Normal file
20
apps/yaak-client/components/core/SizeTag.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { formatSize } from '@yaakapp-internal/lib/formatSize';
|
||||
|
||||
interface Props {
|
||||
contentLength: number;
|
||||
contentLengthCompressed?: number | null;
|
||||
}
|
||||
|
||||
export function SizeTag({ contentLength, contentLengthCompressed }: Props) {
|
||||
return (
|
||||
<span
|
||||
className="font-mono"
|
||||
title={
|
||||
`${contentLength} bytes` +
|
||||
(contentLengthCompressed ? `\n${contentLengthCompressed} bytes compressed` : '')
|
||||
}
|
||||
>
|
||||
{formatSize(contentLength)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
150
apps/yaak-client/components/core/SplitLayout.tsx
Normal file
150
apps/yaak-client/components/core/SplitLayout.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
||||
import { useContainerSize } from '../../hooks/useContainerQuery';
|
||||
import { clamp } from '../../lib/clamp';
|
||||
import type { ResizeHandleEvent } from '../ResizeHandle';
|
||||
import { ResizeHandle } from '../ResizeHandle';
|
||||
|
||||
export type SplitLayoutLayout = 'responsive' | 'horizontal' | 'vertical';
|
||||
|
||||
export interface SlotProps {
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
firstSlot: (props: SlotProps) => ReactNode;
|
||||
secondSlot: null | ((props: SlotProps) => ReactNode);
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
defaultRatio?: number;
|
||||
minHeightPx?: number;
|
||||
minWidthPx?: number;
|
||||
layout?: SplitLayoutLayout;
|
||||
resizeHandleClassName?: string;
|
||||
}
|
||||
|
||||
const baseProperties = { minWidth: 0 };
|
||||
const areaL = { ...baseProperties, gridArea: 'left' };
|
||||
const areaR = { ...baseProperties, gridArea: 'right' };
|
||||
const areaD = { ...baseProperties, gridArea: 'drag' };
|
||||
|
||||
const STACK_VERTICAL_WIDTH = 500;
|
||||
|
||||
export function SplitLayout({
|
||||
style,
|
||||
firstSlot,
|
||||
secondSlot,
|
||||
className,
|
||||
name,
|
||||
layout = 'responsive',
|
||||
resizeHandleClassName,
|
||||
defaultRatio = 0.5,
|
||||
minHeightPx = 10,
|
||||
minWidthPx = 10,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
||||
const [widthRaw, setWidth] = useLocalStorage<number>(
|
||||
`${name}_width::${activeWorkspace?.id ?? 'n/a'}`,
|
||||
);
|
||||
const [heightRaw, setHeight] = useLocalStorage<number>(
|
||||
`${name}_height::${activeWorkspace?.id ?? 'n/a'}`,
|
||||
);
|
||||
const width = widthRaw ?? defaultRatio;
|
||||
let height = heightRaw ?? defaultRatio;
|
||||
|
||||
if (!secondSlot) {
|
||||
height = 0;
|
||||
minHeightPx = 0;
|
||||
}
|
||||
|
||||
const size = useContainerSize(containerRef);
|
||||
const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;
|
||||
const vertical = layout !== 'horizontal' && (layout === 'vertical' || verticalBasedOnSize);
|
||||
|
||||
const styles = useMemo<CSSProperties>(() => {
|
||||
return {
|
||||
...style,
|
||||
gridTemplate: vertical
|
||||
? `
|
||||
' ${areaL.gridArea}' minmax(0,${1 - height}fr)
|
||||
' ${areaD.gridArea}' 0
|
||||
' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr)
|
||||
/ 1fr
|
||||
`
|
||||
: `
|
||||
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
|
||||
/ ${1 - width}fr 0 ${width}fr
|
||||
`,
|
||||
};
|
||||
}, [style, vertical, height, minHeightPx, width]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (vertical) setHeight(defaultRatio);
|
||||
else setWidth(defaultRatio);
|
||||
}, [vertical, setHeight, defaultRatio, setWidth]);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
(e: ResizeHandleEvent) => {
|
||||
if (containerRef.current === null) return;
|
||||
|
||||
// const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle(
|
||||
containerRef.current,
|
||||
);
|
||||
const $c = containerRef.current;
|
||||
const containerWidth =
|
||||
$c.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight);
|
||||
const containerHeight =
|
||||
$c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);
|
||||
|
||||
const mouseStartX = e.xStart;
|
||||
const mouseStartY = e.yStart;
|
||||
const startWidth = containerWidth * width;
|
||||
const startHeight = containerHeight * height;
|
||||
|
||||
if (vertical) {
|
||||
const maxHeightPx = containerHeight - minHeightPx;
|
||||
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx);
|
||||
setHeight(newHeightPx / containerHeight);
|
||||
} else {
|
||||
const maxWidthPx = containerWidth - minWidthPx;
|
||||
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx);
|
||||
setWidth(newWidthPx / containerWidth);
|
||||
}
|
||||
},
|
||||
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={styles}
|
||||
className={classNames(className, 'grid w-full h-full overflow-hidden')}
|
||||
>
|
||||
{firstSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })}
|
||||
{secondSlot && (
|
||||
<>
|
||||
<ResizeHandle
|
||||
style={areaD}
|
||||
className={classNames(
|
||||
resizeHandleClassName,
|
||||
vertical ? '-translate-y-1' : '-translate-x-1',
|
||||
)}
|
||||
onResizeMove={handleResizeMove}
|
||||
onReset={handleReset}
|
||||
side={vertical ? 'top' : 'left'}
|
||||
justify="center"
|
||||
/>
|
||||
{secondSlot({ style: areaR, orientation: vertical ? 'vertical' : 'horizontal' })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
apps/yaak-client/components/core/Stacks.tsx
Normal file
93
apps/yaak-client/components/core/Stacks.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ComponentType, ForwardedRef, HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
const gapClasses = {
|
||||
0: 'gap-0',
|
||||
0.5: 'gap-0.5',
|
||||
1: 'gap-1',
|
||||
1.5: 'gap-1.5',
|
||||
2: 'gap-2',
|
||||
3: 'gap-3',
|
||||
4: 'gap-4',
|
||||
5: 'gap-5',
|
||||
6: 'gap-6',
|
||||
};
|
||||
|
||||
interface HStackProps extends BaseStackProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const HStack = forwardRef(function HStack(
|
||||
{ className, space, children, alignItems = 'center', ...props }: HStackProps,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
ref: ForwardedRef<any>,
|
||||
) {
|
||||
return (
|
||||
<BaseStack
|
||||
ref={ref}
|
||||
className={classNames(className, 'flex-row', space != null && gapClasses[space])}
|
||||
alignItems={alignItems}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseStack>
|
||||
);
|
||||
});
|
||||
|
||||
export type VStackProps = BaseStackProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const VStack = forwardRef(function VStack(
|
||||
{ className, space, children, ...props }: VStackProps,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
ref: ForwardedRef<any>,
|
||||
) {
|
||||
return (
|
||||
<BaseStack
|
||||
ref={ref}
|
||||
className={classNames(className, 'flex-col', space != null && gapClasses[space])}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseStack>
|
||||
);
|
||||
});
|
||||
|
||||
type BaseStackProps = HTMLAttributes<HTMLElement> & {
|
||||
as?: ComponentType | 'ul' | 'label' | 'form' | 'p';
|
||||
space?: keyof typeof gapClasses;
|
||||
alignItems?: 'start' | 'center' | 'stretch' | 'end';
|
||||
justifyContent?: 'start' | 'center' | 'end' | 'between';
|
||||
wrap?: boolean;
|
||||
};
|
||||
|
||||
const BaseStack = forwardRef(function BaseStack(
|
||||
{ className, alignItems, justifyContent, wrap, children, as, ...props }: BaseStackProps,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
ref: ForwardedRef<any>,
|
||||
) {
|
||||
const Component = as ?? 'div';
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
className,
|
||||
'flex',
|
||||
wrap && 'flex-wrap',
|
||||
alignItems === 'center' && 'items-center',
|
||||
alignItems === 'start' && 'items-start',
|
||||
alignItems === 'stretch' && 'items-stretch',
|
||||
alignItems === 'end' && 'items-end',
|
||||
justifyContent === 'start' && 'justify-start',
|
||||
justifyContent === 'center' && 'justify-center',
|
||||
justifyContent === 'end' && 'justify-end',
|
||||
justifyContent === 'between' && 'justify-between',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
});
|
||||
105
apps/yaak-client/components/core/Table.tsx
Normal file
105
apps/yaak-client/components/core/Table.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function Table({
|
||||
children,
|
||||
className,
|
||||
scrollable,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
scrollable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames('w-full', scrollable && 'h-full overflow-y-auto')}>
|
||||
<table
|
||||
className={classNames(
|
||||
className,
|
||||
'w-full text-sm mb-auto min-w-full max-w-full',
|
||||
'border-separate border-spacing-0',
|
||||
scrollable && '[&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableBody({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<tbody className="[&>tr:not(:last-child)>td]:border-b [&>tr:not(:last-child)>td]:border-b-surface-highlight">
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHead({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<thead
|
||||
className={classNames(
|
||||
className,
|
||||
'bg-surface [&_th]:border-b [&_th]:border-b-surface-highlight',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRow({ children }: { children: ReactNode }) {
|
||||
return <tr>{children}</tr>;
|
||||
}
|
||||
|
||||
export function TableCell({
|
||||
children,
|
||||
className,
|
||||
align = 'left',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
className={classNames(
|
||||
className,
|
||||
'py-2 [&:not(:first-child)]:pl-4 whitespace-nowrap',
|
||||
align === 'left' ? 'text-left' : align === 'center' ? 'text-center' : 'text-right',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export function TruncatedWideTableCell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TableCell className={classNames(className, 'truncate max-w-0 w-full')}>{children}</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHeaderCell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<th
|
||||
className={classNames(
|
||||
className,
|
||||
'py-2 [&:not(:first-child)]:pl-4 text-left text-text-subtle',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
585
apps/yaak-client/components/core/Tabs/Tabs.tsx
Normal file
585
apps/yaak-client/components/core/Tabs/Tabs.tsx
Normal file
@@ -0,0 +1,585 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode, Ref } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useKeyValue } from '../../../hooks/useKeyValue';
|
||||
import { computeSideForDragMove } from '../../../lib/dnd';
|
||||
import { DropMarker } from '../../DropMarker';
|
||||
import { ErrorBoundary } from '../../ErrorBoundary';
|
||||
import type { ButtonProps } from '../Button';
|
||||
import { Button } from '../Button';
|
||||
import { Icon } from '../Icon';
|
||||
import type { RadioDropdownProps } from '../RadioDropdown';
|
||||
import { RadioDropdown } from '../RadioDropdown';
|
||||
|
||||
export type TabItem =
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
hidden?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
| {
|
||||
value: string;
|
||||
options: Omit<RadioDropdownProps, 'children'>;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
interface TabsStorage {
|
||||
order: string[];
|
||||
activeTabs: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TabsRef {
|
||||
/** Programmatically set the active tab */
|
||||
setActiveTab: (value: string) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Default tab value. If not provided, defaults to first tab. */
|
||||
defaultValue?: string;
|
||||
/** Called when active tab changes */
|
||||
onChangeValue?: (value: string) => void;
|
||||
tabs: TabItem[];
|
||||
tabListClassName?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
addBorders?: boolean;
|
||||
layout?: 'horizontal' | 'vertical';
|
||||
/** Storage key for persisting tab order and active tab. When provided, enables drag-to-reorder and active tab persistence. */
|
||||
storageKey?: string | string[];
|
||||
/** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */
|
||||
activeTabKey?: string;
|
||||
}
|
||||
|
||||
export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
{
|
||||
defaultValue,
|
||||
onChangeValue: onChangeValueProp,
|
||||
label,
|
||||
children,
|
||||
tabs: originalTabs,
|
||||
className,
|
||||
tabListClassName,
|
||||
addBorders,
|
||||
layout = 'vertical',
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
}: Props,
|
||||
forwardedRef: Ref<TabsRef>,
|
||||
) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const reorderable = !!storageKey;
|
||||
|
||||
// Use key-value storage for persistence if storageKey is provided
|
||||
// Handle migration from old format (string[]) to new format (TabsStorage)
|
||||
const { value: rawStorage, set: setStorage } = useKeyValue<TabsStorage | string[]>({
|
||||
namespace: 'no_sync',
|
||||
key: storageKey ?? ['tabs', 'default'],
|
||||
fallback: { order: [], activeTabs: {} },
|
||||
});
|
||||
|
||||
// Migrate old format (string[]) to new format (TabsStorage)
|
||||
const storage: TabsStorage = Array.isArray(rawStorage)
|
||||
? { order: rawStorage, activeTabs: {} }
|
||||
: (rawStorage ?? { order: [], activeTabs: {} });
|
||||
|
||||
const savedOrder = storage.order;
|
||||
|
||||
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
|
||||
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
|
||||
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
|
||||
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
|
||||
|
||||
// Helper to normalize storage (handle migration from old format)
|
||||
const normalizeStorage = useCallback(
|
||||
(s: TabsStorage | string[]): TabsStorage =>
|
||||
Array.isArray(s) ? { order: s, activeTabs: {} } : s,
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle tab change - update internal state, storage if we have a key, and call prop callback
|
||||
const onChangeValue = useCallback(
|
||||
async (newValue: string) => {
|
||||
setInternalValue(newValue);
|
||||
if (storageKey && activeTabKey) {
|
||||
await setStorage((s) => {
|
||||
const normalized = normalizeStorage(s);
|
||||
return {
|
||||
...normalized,
|
||||
activeTabs: { ...normalized.activeTabs, [activeTabKey]: newValue },
|
||||
};
|
||||
});
|
||||
}
|
||||
onChangeValueProp?.(newValue);
|
||||
},
|
||||
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
|
||||
);
|
||||
|
||||
// Expose imperative methods via ref
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
setActiveTab: (value: string) => {
|
||||
onChangeValue(value);
|
||||
},
|
||||
}),
|
||||
[onChangeValue],
|
||||
);
|
||||
|
||||
// Helper to save order
|
||||
const setSavedOrder = useCallback(
|
||||
async (order: string[]) => {
|
||||
await setStorage((s) => {
|
||||
const normalized = normalizeStorage(s);
|
||||
return { ...normalized, order };
|
||||
});
|
||||
},
|
||||
[setStorage, normalizeStorage],
|
||||
);
|
||||
|
||||
// State for ordered tabs
|
||||
const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);
|
||||
const [isDragging, setIsDragging] = useState<TabItem | null>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
// Reorder tabs based on saved order when tabs or savedOrder changes
|
||||
useEffect(() => {
|
||||
if (!storageKey || savedOrder == null || savedOrder.length === 0) {
|
||||
setOrderedTabs(originalTabs);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a map of tab values to tab items
|
||||
const tabMap = new Map(originalTabs.map((tab) => [tab.value, tab]));
|
||||
|
||||
// Reorder based on saved order, adding any new tabs at the end
|
||||
const reordered: TabItem[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
// Add tabs in saved order
|
||||
for (const value of savedOrder) {
|
||||
const tab = tabMap.get(value);
|
||||
if (tab) {
|
||||
reordered.push(tab);
|
||||
seenValues.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any new tabs that weren't in the saved order
|
||||
for (const tab of originalTabs) {
|
||||
if (!seenValues.has(tab.value)) {
|
||||
reordered.push(tab);
|
||||
}
|
||||
}
|
||||
|
||||
setOrderedTabs(reordered);
|
||||
}, [originalTabs, savedOrder, storageKey]);
|
||||
|
||||
const tabs = storageKey ? orderedTabs : originalTabs;
|
||||
|
||||
// Update tabs when value changes
|
||||
useEffect(() => {
|
||||
const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]');
|
||||
for (const tab of tabs ?? []) {
|
||||
const v = tab.getAttribute('data-tab');
|
||||
const parent = tab.closest('.tabs-container');
|
||||
if (parent !== ref.current) {
|
||||
// Tab is part of a nested tab container, so ignore it
|
||||
} else if (v === value) {
|
||||
tab.setAttribute('data-state', 'active');
|
||||
tab.setAttribute('aria-hidden', 'false');
|
||||
tab.style.display = 'block';
|
||||
} else {
|
||||
tab.setAttribute('data-state', 'inactive');
|
||||
tab.setAttribute('aria-hidden', 'true');
|
||||
tab.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Drag and drop handlers
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
const onDragStart = useCallback(
|
||||
(e: DragStartEvent) => {
|
||||
const tab = tabs.find((t) => t.value === e.active.id);
|
||||
setIsDragging(tab ?? null);
|
||||
},
|
||||
[tabs],
|
||||
);
|
||||
|
||||
const onDragMove = useCallback(
|
||||
(e: DragMoveEvent) => {
|
||||
const overId = e.over?.id as string | undefined;
|
||||
if (!overId) return setHoveredIndex(null);
|
||||
|
||||
const overTab = tabs.find((t) => t.value === overId);
|
||||
if (overTab == null) return setHoveredIndex(null);
|
||||
|
||||
// For vertical layout, tabs are arranged horizontally (side-by-side)
|
||||
const orientation = layout === 'vertical' ? 'horizontal' : 'vertical';
|
||||
const side = computeSideForDragMove(overTab.value, e, orientation);
|
||||
|
||||
// If computeSideForDragMove returns null (shouldn't happen but be safe), default to null
|
||||
if (side === null) return setHoveredIndex(null);
|
||||
|
||||
const overIndex = tabs.findIndex((t) => t.value === overId);
|
||||
const hoveredIndex = overIndex + (side === 'before' ? 0 : 1);
|
||||
|
||||
setHoveredIndex(hoveredIndex);
|
||||
},
|
||||
[tabs, layout],
|
||||
);
|
||||
|
||||
const onDragCancel = useCallback(() => {
|
||||
setIsDragging(null);
|
||||
setHoveredIndex(null);
|
||||
}, []);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
setIsDragging(null);
|
||||
setHoveredIndex(null);
|
||||
|
||||
const activeId = e.active.id as string | undefined;
|
||||
const overId = e.over?.id as string | undefined;
|
||||
if (!activeId || !overId || activeId === overId) return;
|
||||
|
||||
const from = tabs.findIndex((t) => t.value === activeId);
|
||||
const baseTo = tabs.findIndex((t) => t.value === overId);
|
||||
const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);
|
||||
|
||||
if (from !== -1 && to !== -1 && from !== to) {
|
||||
const newTabs = [...tabs];
|
||||
const [moved] = newTabs.splice(from, 1);
|
||||
if (moved === undefined) return;
|
||||
newTabs.splice(to > from ? to - 1 : to, 0, moved);
|
||||
|
||||
setOrderedTabs(newTabs);
|
||||
|
||||
// Save order to storage
|
||||
setSavedOrder(newTabs.map((t) => t.value)).catch(console.error);
|
||||
}
|
||||
},
|
||||
[tabs, hoveredIndex, setSavedOrder],
|
||||
);
|
||||
|
||||
const tabButtons = useMemo(() => {
|
||||
const items: ReactNode[] = [];
|
||||
tabs.forEach((t, i) => {
|
||||
if ('hidden' in t && t.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = t.value === value;
|
||||
const showDropMarkerBefore = hoveredIndex === i;
|
||||
|
||||
if (showDropMarkerBefore) {
|
||||
items.push(
|
||||
<div
|
||||
key={`marker-${t.value}`}
|
||||
className={classNames('relative', layout === 'vertical' ? 'w-0' : 'h-0')}
|
||||
>
|
||||
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
<TabButton
|
||||
key={t.value}
|
||||
tab={t}
|
||||
isActive={isActive}
|
||||
addBorders={addBorders}
|
||||
layout={layout}
|
||||
reorderable={reorderable}
|
||||
isDragging={isDragging?.value === t.value}
|
||||
onChangeValue={onChangeValue}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return items;
|
||||
}, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]);
|
||||
|
||||
const tabList = (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className={classNames(
|
||||
tabListClassName,
|
||||
addBorders && layout === 'horizontal' && 'pl-3 -ml-1',
|
||||
addBorders && layout === 'vertical' && 'ml-0 mb-2',
|
||||
'flex items-center hide-scrollbars',
|
||||
layout === 'horizontal' && 'h-full overflow-auto p-2',
|
||||
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
|
||||
// Give space for button focus states within overflow boundary.
|
||||
!addBorders && layout === 'vertical' && 'py-1 pl-3 -ml-5 pr-1',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
layout === 'horizontal' && 'flex flex-col w-full pb-3 mb-auto',
|
||||
layout === 'vertical' && 'flex flex-row flex-shrink-0 w-full',
|
||||
)}
|
||||
>
|
||||
{tabButtons}
|
||||
{hoveredIndex === tabs.length && (
|
||||
<div className={classNames('relative', layout === 'vertical' ? 'w-0' : 'h-0')}>
|
||||
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
className,
|
||||
'tabs-container',
|
||||
'h-full grid',
|
||||
layout === 'horizontal' && 'grid-rows-1 grid-cols-[auto_minmax(0,1fr)]',
|
||||
layout === 'vertical' && 'grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
|
||||
)}
|
||||
>
|
||||
{reorderable ? (
|
||||
<DndContext
|
||||
autoScroll
|
||||
sensors={sensors}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={onDragStart}
|
||||
onDragCancel={onDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
>
|
||||
{tabList}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{isDragging && (
|
||||
<TabButton
|
||||
tab={isDragging}
|
||||
isActive={isDragging.value === value}
|
||||
addBorders={addBorders}
|
||||
layout={layout}
|
||||
reorderable={false}
|
||||
isDragging={false}
|
||||
onChangeValue={onChangeValue}
|
||||
overlay
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
) : (
|
||||
tabList
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface TabButtonProps {
|
||||
tab: TabItem;
|
||||
isActive: boolean;
|
||||
addBorders?: boolean;
|
||||
layout: 'horizontal' | 'vertical';
|
||||
reorderable: boolean;
|
||||
isDragging: boolean;
|
||||
onChangeValue?: (value: string) => void;
|
||||
overlay?: boolean;
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
tab,
|
||||
isActive,
|
||||
addBorders,
|
||||
layout,
|
||||
reorderable,
|
||||
isDragging,
|
||||
onChangeValue,
|
||||
overlay = false,
|
||||
}: TabButtonProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setDraggableRef,
|
||||
} = useDraggable({
|
||||
id: tab.value,
|
||||
disabled: !reorderable,
|
||||
// The button inside handles focus
|
||||
attributes: { tabIndex: -1 },
|
||||
});
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({
|
||||
id: tab.value,
|
||||
disabled: !reorderable,
|
||||
});
|
||||
|
||||
const handleSetWrapperRef = useCallback(
|
||||
(n: HTMLDivElement | null) => {
|
||||
if (reorderable) {
|
||||
setDraggableRef(n);
|
||||
setDroppableRef(n);
|
||||
}
|
||||
},
|
||||
[reorderable, setDraggableRef, setDroppableRef],
|
||||
);
|
||||
|
||||
const btnProps: Partial<ButtonProps> = {
|
||||
color: 'custom',
|
||||
justify: layout === 'horizontal' ? 'start' : 'center',
|
||||
onClick: isActive
|
||||
? undefined
|
||||
: (e: React.MouseEvent) => {
|
||||
e.preventDefault(); // Prevent dropdown from opening on first click
|
||||
onChangeValue?.(tab.value);
|
||||
},
|
||||
className: classNames(
|
||||
'flex items-center rounded whitespace-nowrap',
|
||||
'!px-2 ml-[1px]',
|
||||
'outline-none',
|
||||
'ring-none',
|
||||
'focus-visible-or-class:outline-2',
|
||||
addBorders && 'border focus-visible:bg-surface-highlight',
|
||||
isActive ? 'text-text' : 'text-text-subtle',
|
||||
isActive && addBorders
|
||||
? 'border-surface-active bg-surface-active'
|
||||
: layout === 'vertical'
|
||||
? 'border-border-subtle'
|
||||
: 'border-transparent',
|
||||
layout === 'horizontal' && 'min-w-[10rem]',
|
||||
isDragging && 'opacity-50',
|
||||
overlay && 'opacity-80',
|
||||
),
|
||||
};
|
||||
|
||||
const buttonContent = (() => {
|
||||
if ('options' in tab) {
|
||||
const option = tab.options.items.find((i) => 'value' in i && i.value === tab.options.value);
|
||||
return (
|
||||
<RadioDropdown
|
||||
key={tab.value}
|
||||
items={tab.options.items}
|
||||
itemsAfter={tab.options.itemsAfter}
|
||||
itemsBefore={tab.options.itemsBefore}
|
||||
value={tab.options.value}
|
||||
onChange={tab.options.onChange}
|
||||
>
|
||||
<Button
|
||||
leftSlot={tab.leftSlot}
|
||||
rightSlot={
|
||||
<div className="flex items-center">
|
||||
{tab.rightSlot}
|
||||
<Icon
|
||||
size="sm"
|
||||
icon="chevron_down"
|
||||
className={classNames(
|
||||
'ml-1',
|
||||
isActive ? 'text-text-subtle' : 'text-text-subtlest',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{...btnProps}
|
||||
>
|
||||
{option && 'shortLabel' in option && option.shortLabel
|
||||
? option.shortLabel
|
||||
: (option?.label ?? 'Unknown')}
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
|
||||
{'label' in tab && tab.label ? tab.label : tab.value}
|
||||
</Button>
|
||||
);
|
||||
})();
|
||||
|
||||
// Apply drag handlers to wrapper, not button
|
||||
const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={handleSetWrapperRef}
|
||||
className={classNames('relative', layout === 'vertical' && 'mr-2')}
|
||||
{...wrapperProps}
|
||||
>
|
||||
{buttonContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabContentProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TabContent = memo(function TabContent({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
}: TabContentProps) {
|
||||
return (
|
||||
<ErrorBoundary name={`Tab ${value}`}>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
data-tab={value}
|
||||
className={classNames(className, 'tab-content', 'hidden w-full h-full pt-2')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey.
|
||||
* This is useful when you need to change the tab from outside the component (e.g., in response to an event).
|
||||
*/
|
||||
export async function setActiveTab({
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
value,
|
||||
}: {
|
||||
storageKey: string;
|
||||
activeTabKey: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
const { getKeyValue, setKeyValue } = await import('../../../lib/keyValueStore');
|
||||
const current = getKeyValue<TabsStorage>({
|
||||
namespace: 'no_sync',
|
||||
key: storageKey,
|
||||
fallback: { order: [], activeTabs: {} },
|
||||
});
|
||||
await setKeyValue({
|
||||
namespace: 'no_sync',
|
||||
key: storageKey,
|
||||
value: {
|
||||
...current,
|
||||
activeTabs: { ...current.activeTabs, [activeTabKey]: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
93
apps/yaak-client/components/core/Toast.tsx
Normal file
93
apps/yaak-client/components/core/Toast.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ShowToastRequest } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import * as m from 'motion/react-m';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useKey } from 'react-use';
|
||||
import type { IconProps } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import { VStack } from './Stacks';
|
||||
|
||||
export interface ToastProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
timeout: number | null;
|
||||
action?: (args: { hide: () => void }) => ReactNode;
|
||||
icon?: ShowToastRequest['icon'] | null;
|
||||
color?: ShowToastRequest['color'];
|
||||
}
|
||||
|
||||
const ICONS: Record<NonNullable<ToastProps['color'] | 'custom'>, IconProps['icon'] | null> = {
|
||||
custom: null,
|
||||
danger: 'alert_triangle',
|
||||
info: 'info',
|
||||
notice: 'alert_triangle',
|
||||
primary: 'info',
|
||||
secondary: 'info',
|
||||
success: 'check_circle',
|
||||
warning: 'alert_triangle',
|
||||
};
|
||||
|
||||
export function Toast({ children, open, onClose, timeout, action, icon, color }: ToastProps) {
|
||||
useKey(
|
||||
'Escape',
|
||||
() => {
|
||||
if (!open) return;
|
||||
onClose();
|
||||
},
|
||||
{},
|
||||
[open],
|
||||
);
|
||||
|
||||
const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, right: '-10%' }}
|
||||
animate={{ opacity: 100, right: 0 }}
|
||||
exit={{ opacity: 0, right: '-100%' }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={classNames('bg-surface m-2 rounded-lg')}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
`x-theme-toast x-theme-toast--${color}`,
|
||||
'pointer-events-auto overflow-hidden',
|
||||
'relative pointer-events-auto bg-surface text-text rounded-lg',
|
||||
'border border-border shadow-lg w-[25rem]',
|
||||
)}
|
||||
>
|
||||
<div className="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-[11rem] overflow-auto">
|
||||
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 flex-shrink-0" />}
|
||||
<VStack space={2} className="w-full min-w-0">
|
||||
<div className="select-auto">{children}</div>
|
||||
{action?.({ hide: onClose })}
|
||||
</VStack>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
color={color}
|
||||
variant="border"
|
||||
className="opacity-60 border-0 !absolute top-2 right-2"
|
||||
title="Dismiss"
|
||||
icon="x"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{timeout != null && (
|
||||
<div className="w-full absolute bottom-0 left-0 right-0">
|
||||
<m.div
|
||||
className="bg-surface-highlight h-[3px]"
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: '0%', opacity: 0.2 }}
|
||||
transition={{ duration: timeout / 1000, ease: 'linear' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
165
apps/yaak-client/components/core/Tooltip.tsx
Normal file
165
apps/yaak-client/components/core/Tooltip.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties, KeyboardEvent, ReactNode } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import { Portal } from '../Portal';
|
||||
|
||||
export interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
tabIndex?: number;
|
||||
size?: 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const hiddenStyles: CSSProperties = {
|
||||
left: -99999,
|
||||
top: -99999,
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
type TooltipPosition = 'top' | 'bottom';
|
||||
|
||||
interface TooltipOpenState {
|
||||
styles: CSSProperties;
|
||||
position: TooltipPosition;
|
||||
}
|
||||
|
||||
export function Tooltip({ children, className, content, tabIndex, size = 'md' }: TooltipProps) {
|
||||
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const showTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
const handleOpenImmediate = () => {
|
||||
if (triggerRef.current == null || tooltipRef.current == null) return;
|
||||
clearTimeout(showTimeout.current);
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||
const viewportHeight = document.documentElement.clientHeight;
|
||||
|
||||
const margin = 8;
|
||||
const spaceAbove = Math.max(0, triggerRect.top - margin);
|
||||
const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin);
|
||||
const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove;
|
||||
const position: TooltipPosition = preferBottom ? 'bottom' : 'top';
|
||||
|
||||
const styles: CSSProperties = {
|
||||
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
|
||||
maxHeight: position === 'top' ? spaceAbove : spaceBelow,
|
||||
...(position === 'top'
|
||||
? { bottom: viewportHeight - triggerRect.top }
|
||||
: { top: triggerRect.bottom }),
|
||||
};
|
||||
|
||||
setOpenState({ styles, position });
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
clearTimeout(showTimeout.current);
|
||||
showTimeout.current = setTimeout(handleOpenImmediate, 500);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
clearTimeout(showTimeout.current);
|
||||
setOpenState(null);
|
||||
};
|
||||
|
||||
const handleToggleImmediate = () => {
|
||||
if (openState) handleClose();
|
||||
else handleOpenImmediate();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (openState && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const id = useRef(`tooltip-${generateId()}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Portal name="tooltip">
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={openState?.styles ?? hiddenStyles}
|
||||
id={id.current}
|
||||
role="tooltip"
|
||||
aria-hidden={openState == null}
|
||||
onMouseEnter={handleOpenImmediate}
|
||||
onMouseLeave={handleClose}
|
||||
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-surface-highlight rounded-md px-3 py-2 z-50 border border-border overflow-auto',
|
||||
size === 'md' && 'max-w-sm',
|
||||
size === 'lg' && 'max-w-md',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
<Triangle
|
||||
className="text-border"
|
||||
position={openState?.position === 'bottom' ? 'top' : 'bottom'}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
{/** biome-ignore lint/a11y/useSemanticElements: Needs to be usable in other buttons */}
|
||||
<span
|
||||
ref={triggerRef}
|
||||
role="button"
|
||||
aria-describedby={openState ? id.current : undefined}
|
||||
tabIndex={tabIndex ?? -1}
|
||||
className={classNames(className, 'flex-grow-0 flex items-center')}
|
||||
onClick={handleToggleImmediate}
|
||||
onMouseEnter={handleOpen}
|
||||
onMouseLeave={handleClose}
|
||||
onFocus={handleOpenImmediate}
|
||||
onBlur={handleClose}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Triangle({ className, position }: { className?: string; position: 'top' | 'bottom' }) {
|
||||
const isBottom = position === 'bottom';
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 30 10"
|
||||
preserveAspectRatio="none"
|
||||
shapeRendering="crispEdges"
|
||||
className={classNames(
|
||||
className,
|
||||
'absolute z-50 left-[calc(50%-0.4rem)] h-[0.5rem] w-[0.8rem]',
|
||||
isBottom
|
||||
? 'border-t-[2px] border-surface-highlight -bottom-[calc(0.5rem-3px)] mb-2'
|
||||
: 'border-b-[2px] border-surface-highlight -top-[calc(0.5rem-3px)] mt-2',
|
||||
)}
|
||||
>
|
||||
<title>Triangle</title>
|
||||
<polygon
|
||||
className="fill-surface-highlight"
|
||||
points={isBottom ? '0,0 30,0 15,10' : '0,10 30,10 15,0'}
|
||||
/>
|
||||
<path
|
||||
d={isBottom ? 'M0 0 L15 9 L30 0' : 'M0 10 L15 1 L30 10'}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinejoin="miter"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
31
apps/yaak-client/components/core/WebsocketStatusTag.tsx
Normal file
31
apps/yaak-client/components/core/WebsocketStatusTag.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { WebsocketConnection } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
connection: WebsocketConnection;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WebsocketStatusTag({ connection, className }: Props) {
|
||||
const { state, error } = connection;
|
||||
|
||||
let label: string;
|
||||
let colorClass = 'text-text-subtle';
|
||||
|
||||
if (error) {
|
||||
label = 'ERROR';
|
||||
colorClass = 'text-danger';
|
||||
} else if (state === 'connected') {
|
||||
label = 'CONNECTED';
|
||||
colorClass = 'text-success';
|
||||
} else if (state === 'closing') {
|
||||
label = 'CLOSING';
|
||||
} else if (state === 'closed') {
|
||||
label = 'CLOSED';
|
||||
colorClass = 'text-warning';
|
||||
} else {
|
||||
label = 'CONNECTING';
|
||||
}
|
||||
|
||||
return <span className={classNames(className, 'font-mono', colorClass)}>{label}</span>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user