Split codebase (#455)

This commit is contained in:
Gregory Schier
2026-05-07 15:50:10 -07:00
committed by GitHub
parent d2dc719cc6
commit 10559c8f4f
742 changed files with 7686 additions and 3249 deletions

View File

@@ -0,0 +1,21 @@
import { HStack, VStack } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { Button } from "./Button";
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>
);
}

View 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;
void data.length; // Trigger 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>
);
}

View 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(),
};
}

View File

@@ -0,0 +1,34 @@
import { Button as BaseButton, type ButtonProps as BaseButtonProps } from "@yaakapp-internal/ui";
import { forwardRef, useImperativeHandle, useRef } from "react";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
export type ButtonProps = BaseButtonProps & {
hotkeyAction?: HotkeyAction;
hotkeyLabelOnly?: boolean;
hotkeyPriority?: number;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: ButtonProps,
ref,
) {
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
const buttonRef = useRef<HTMLButtonElement>(null);
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
ref,
() => buttonRef.current,
);
useHotKey(
hotkeyAction ?? null,
() => {
buttonRef.current?.click();
},
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
);
return <BaseButton ref={buttonRef} title={fullTitle} {...props} />;
});

View 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>
);
}

View File

@@ -0,0 +1,69 @@
import { HStack, Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
import { IconTooltip } from "./IconTooltip";
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>
);
}

View 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 "@yaakapp-internal/ui";
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>
);
}

View File

@@ -0,0 +1,74 @@
import type { Color } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react";
import { useState } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Button } from "./Button";
import { PlainInput } from "./PlainInput";
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>
);
}

View 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>
);
}

View 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 "@yaakapp-internal/ui";
import { Banner } from "@yaakapp-internal/ui";
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) {
// oxlint-disable-next-line react-hooks/exhaustive-deps -- 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>
);
}

View File

@@ -0,0 +1,129 @@
import type { DialogSize } from "@yaakapp-internal/plugins";
import { Heading, Overlay } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { IconButton } from "./IconButton";
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>
);
}

View File

@@ -0,0 +1,57 @@
import type { Color } from "@yaakapp-internal/plugins";
import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner, HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useKeyValue } from "../../hooks/useKeyValue";
import { Button } from "./Button";
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>
);
}

View File

@@ -0,0 +1,974 @@
import { HStack, Icon, type IconProps, LoadingIcon, Overlay, VStack } from "@yaakapp-internal/ui";
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 { fireAndForget } from "../../lib/fireAndForget";
import { ErrorBoundary } from "../ErrorBoundary";
import { Button } from "./Button";
import { Hotkey } from "./Hotkey";
import { Separator } from "./Separator";
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) {
fireAndForget(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
// oxlint-disable-next-line no-array-index-key -- Nothing else available
key={i}
className={classNames("my-1.5", item.label ? "ml-2" : null)}
>
{item.label}
</Separator>
);
}
if (item.type === "content") {
return (
// oxlint-disable-next-line no-array-index-key -- 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}
// oxlint-disable-next-line no-array-index-key -- It's fine
key={i}
item={item}
/>
);
})}
</VStack>
{activeSubmenu && (
<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);
}

View File

@@ -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);
}
}

View 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;
}
}

View 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)}
/>
);
}

View File

@@ -0,0 +1,495 @@
.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;
}
/* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost
residual line below wrapped lines after deselecting (CodeMirror issue #1600, #1627).
The layer div must be hidden when empty to force a repaint. */
.cm-selectionLayer:empty {
display: none;
}
/* 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;
}
}

View File

@@ -0,0 +1,707 @@
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 { HStack } from "@yaakapp-internal/ui";
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 "./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;
lintExtension?: Extension;
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,
lintExtension,
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
// oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally limited deps
useEffect(() => {
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({
useTemplating,
language,
lintExtension,
hideGutter,
environmentVariables,
autocomplete,
completionOptions,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
graphQLSchema: graphQLSchema ?? null,
});
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [
language,
lintExtension,
autocomplete,
environmentVariables,
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
completionOptions,
useTemplating,
graphQLSchema,
hideGutter,
]);
// Initialize the editor when ref mounts
// oxlint-disable-next-line react-hooks/exhaustive-deps -- 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,
lintExtension,
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,
}),
});
}
}

View 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>
);
}

View File

@@ -0,0 +1,331 @@
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 { 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 { jsonc, jsoncLanguage } from "@shopify/lang-jsonc";
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 { searchMatchCount } from "./searchMatchCount";
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";
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: jsonc,
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",
lintExtension,
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" | "lintExtension">) {
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(lintExtension ?? linter(jsonParseLinter()));
extraExtensions.push(
jsoncLanguage.data.of({
commentTokens: { line: "//", block: { open: "/*", close: "*/" } },
}),
);
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]),
];

View 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] })]);
}

View File

@@ -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"

View File

@@ -0,0 +1,27 @@
/* oxlint-disable */
// 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,
});

View 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,
});

View 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);
}

View 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,
};
};
}

View 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;
// oxlint-disable-next-line no-cond-assign
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()];

View File

@@ -0,0 +1,44 @@
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;
interface JsonLintOptions {
allowComments?: boolean;
allowTrailingCommas?: boolean;
}
export function jsonParseLinter(options?: JsonLintOptions) {
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, {
mode: (options?.allowComments ?? true) ? "cjson" : "json",
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
});
// oxlint-disable-next-line no-explicit-any
} 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 [];
};
}

View 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, []);
}

View File

@@ -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,
});

View File

@@ -0,0 +1,9 @@
@top pairs { (Key Sep Value "\n")* }
@tokens {
Sep { ":" }
Key { ":"? ![:]+ }
Value { ![\n]+ }
}
@external propSource highlight from "./highlight"

View File

@@ -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;

View 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"',
},
});

View 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();
}
},
);
}

View 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,
};
},
);
}

View 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);
}

View File

@@ -0,0 +1,5 @@
@top Template { Text }
@tokens {
Text { ![]+ }
}

View File

@@ -0,0 +1,3 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const Template = 1,
Text = 2;

View 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,
});

View File

@@ -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);
}

View File

@@ -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)
});

View File

@@ -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"

View File

@@ -0,0 +1,11 @@
// 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;

View File

@@ -0,0 +1,21 @@
// 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,
});

View 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;
}

View 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;
}

View File

@@ -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,
});

View 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;
});
},
},
);
}

View 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;
}

View 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"

View File

@@ -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;

View File

@@ -0,0 +1,108 @@
/* oxlint-disable no-template-curly-in-string */
import { describe, expect, test } from "vite-plus/test";
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();
});
});
});

View 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,
});

View 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;
}

View File

@@ -0,0 +1,9 @@
import { genericCompletion } from "../genericCompletion";
export const completions = genericCompletion({
options: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
minMatch: 1,
});

View 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, []);
}

View 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,
});

View 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"

View 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;

View 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,
});

View File

@@ -0,0 +1,273 @@
import type { Virtualizer } from "@tanstack/react-virtual";
import { Banner, HStack, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
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 { Button } from "./Button";
import { IconButton } from "./IconButton";
import { Separator } from "./Separator";
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;
/** Key for SplitLayout state persistence */
splitLayoutStorageKey: 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,
splitLayoutStorageKey,
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"
storageKey={splitLayoutStorageKey}
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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,45 @@
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useFormattedHotkey } from "../../hooks/useHotKey";
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) => (
// oxlint-disable-next-line react/no-array-index-key
<div key={index} className="min-w-[1em] text-center">
{char}
</div>
))}
</HStack>
);
}

View 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>
);
}

View 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>
);
};

View File

@@ -0,0 +1,91 @@
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.padEnd(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>
);
}

View 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`;
}

View 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>
);
}

View File

@@ -0,0 +1,37 @@
import {
IconButton as BaseIconButton,
type IconButtonProps as BaseIconButtonProps,
} from "@yaakapp-internal/ui";
import { forwardRef, useImperativeHandle, useRef } from "react";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
export type IconButtonProps = BaseIconButtonProps & {
hotkeyAction?: HotkeyAction;
hotkeyLabelOnly?: boolean;
hotkeyPriority?: number;
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{ hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: IconButtonProps,
ref,
) {
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
const buttonRef = useRef<HTMLButtonElement>(null);
useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(
ref,
() => buttonRef.current,
);
useHotKey(
hotkeyAction ?? null,
() => {
buttonRef.current?.click();
},
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
);
return <BaseIconButton ref={buttonRef} title={fullTitle} {...props} />;
});

View File

@@ -0,0 +1,30 @@
import { Icon, type IconProps } from "@yaakapp-internal/ui";
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>
);
}

View File

@@ -0,0 +1,613 @@
import type { EditorView } from "@codemirror/view";
import type { Color } from "@yaakapp-internal/plugins";
import { HStack, Icon, type IconProps } from "@yaakapp-internal/ui";
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 { IconButton } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label";
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) => {
// oxlint-disable-next-line no-explicit-any
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 ${typeof label === "string" ? label : "field"}`
: `Obscure ${typeof label === "string" ? label : "field"}`
}
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,
});

View File

@@ -0,0 +1,146 @@
import classNames from "classnames";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { Icon } from "@yaakapp-internal/ui";
interface Props {
depth?: number;
// oxlint-disable-next-line no-explicit-any -- 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
? // oxlint-disable-next-line no-explicit-any -- none
attrValue.flatMap((v: any, i: number) => (
<JsonAttributeTree
// oxlint-disable-next-line no-array-index-key -- 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}]`;
}

View 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) => (
// oxlint-disable-next-line react/no-array-index-key
<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>
</>
);
}

View 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) => (
// oxlint-disable-next-line react/no-array-index-key
<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>
);
}

View 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 "@yaakapp-internal/ui";
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>;
}

View File

@@ -0,0 +1,857 @@
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, DropMarker } from "@yaakapp-internal/ui";
import { showPrompt } from "../../lib/prompt";
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 "@yaakapp-internal/ui";
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],
);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- 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>
);
}

View 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() };
}

View 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>
);
}

View 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}
/>
);
}

View File

@@ -0,0 +1,237 @@
import { HStack } from "@yaakapp-internal/ui";
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";
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}
// oxlint-disable-next-line jsx-a11y/no-autofocus
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 ${typeof label === "string" ? label : "field"}`
: `Obscure ${typeof label === "string" ? label : "field"}`
}
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;
}

View File

@@ -0,0 +1,66 @@
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
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";
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>
);
}

View File

@@ -0,0 +1,64 @@
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>
);
}

View 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 "@yaakapp-internal/ui";
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>
);
}

View File

@@ -0,0 +1,128 @@
import type { IconProps } from "@yaakapp-internal/ui";
import { HStack } from "@yaakapp-internal/ui";
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 { IconButton, type IconButtonProps } from "./IconButton";
import { Label } from "./Label";
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);
if (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);
if (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>
);
}

View File

@@ -0,0 +1,137 @@
import { type } from "@tauri-apps/plugin-os";
import { HStack } from "@yaakapp-internal/ui";
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";
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",
};

View 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>
);
}

View 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>
);
}

View 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, DropMarker } from "@yaakapp-internal/ui";
import { fireAndForget } from "../../../lib/fireAndForget";
import { ErrorBoundary } from "../../ErrorBoundary";
import type { ButtonProps } from "../Button";
import { Button } from "../Button";
import { Icon } from "@yaakapp-internal/ui";
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) => {
fireAndForget(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 },
},
});
}

View File

@@ -0,0 +1,90 @@
import type { ShowToastRequest } from "@yaakapp-internal/plugins";
import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useKey } from "react-use";
import { IconButton } from "./IconButton";
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>
);
}

View 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 "@yaakapp-internal/ui";
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>
{/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- 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>
);
}

View 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>;
}