Run oxfmt across repo, add format script and docs

Add .oxfmtignore to skip generated bindings and wasm-pack output.
Add npm format script, update DEVELOPMENT.md for Vite+ toolchain,
and format all non-generated files with oxfmt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-03-13 10:15:49 -07:00
parent 45262edfbd
commit b4a1c418bb
664 changed files with 13638 additions and 13492 deletions

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { Button } from './Button';
import { HStack, VStack } from './Stacks';
import type { ReactNode } from "react";
import { Button } from "./Button";
import { HStack, VStack } from "./Stacks";
export interface AlertProps {
onHide: () => void;

View File

@@ -1,7 +1,7 @@
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';
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[];
@@ -87,8 +87,8 @@ export function AutoScroller<T>({
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
width: "100%",
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
@@ -98,10 +98,10 @@ export function AutoScroller<T>({
<div
key={virtualItem.key}
style={{
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
width: '100%',
width: "100%",
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}

View File

@@ -1,10 +1,10 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import classNames from "classnames";
import type { ReactNode } from "react";
export interface BannerProps {
children: ReactNode;
className?: string;
color?: 'primary' | 'secondary' | 'success' | 'notice' | 'warning' | 'danger' | 'info';
color?: "primary" | "secondary" | "success" | "notice" | "warning" | "danger" | "info";
}
export function Banner({ children, className, color }: BannerProps) {
@@ -13,12 +13,12 @@ export function Banner({ children, className, color }: BannerProps) {
<div
className={classNames(
className,
color && 'bg-surface',
color && "bg-surface",
`x-theme-banner--${color}`,
'border border-border border-dashed',
'px-4 py-2 rounded-lg select-auto cursor-auto',
'overflow-auto text-text',
'mb-auto', // Don't stretch all the way down if the parent is in grid or flexbox
"border border-border border-dashed",
"px-4 py-2 rounded-lg select-auto cursor-auto",
"overflow-auto text-text",
"mb-auto", // Don't stretch all the way down if the parent is in grid or flexbox
)}
>
{children}

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react';
import { generateId } from '../../lib/generateId';
import { Editor } from './Editor/LazyEditor';
import type { Pair, PairEditorProps, PairWithId } from './PairEditor';
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;
@@ -16,15 +16,15 @@ export function BulkPairEditor({
}: Props) {
const pairsText = useMemo(() => {
return pairs
.filter((p) => !(p.name.trim() === '' && p.value.trim() === ''))
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
.map(pairToLine)
.join('\n');
.join("\n");
}, [pairs]);
const handleChange = useCallback(
(text: string) => {
const pairs = text
.split('\n')
.split("\n")
.filter((l: string) => l.trim())
.map(lineToPair);
onChange(pairs);
@@ -39,7 +39,7 @@ export function BulkPairEditor({
stateKey={`bulk_pair.${stateKey}`}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
placeholder={`${namePlaceholder ?? 'name'}: ${valuePlaceholder ?? 'value'}`}
placeholder={`${namePlaceholder ?? "name"}: ${valuePlaceholder ?? "value"}`}
defaultValue={pairsText}
language="pairs"
onChange={handleChange}
@@ -48,7 +48,7 @@ export function BulkPairEditor({
}
function pairToLine(pair: Pair) {
const value = pair.value.replaceAll('\n', '\\n');
const value = pair.value.replaceAll("\n", "\\n");
return `${pair.name}: ${value}`;
}
@@ -56,8 +56,8 @@ function lineToPair(line: string): PairWithId {
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? [];
return {
enabled: true,
name: (name ?? '').trim(),
value: (value ?? '').replaceAll('\\n', '\n').trim(),
name: (name ?? "").trim(),
value: (value ?? "").replaceAll("\\n", "\n").trim(),
id: generateId(),
};
}

View File

@@ -1,20 +1,20 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey';
import { Icon } from './Icon';
import { LoadingIcon } from './LoadingIcon';
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { HTMLAttributes, ReactNode } from "react";
import { forwardRef, useImperativeHandle, useRef } from "react";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
import { Icon } from "./Icon";
import { LoadingIcon } from "./LoadingIcon";
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onChange'> & {
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onChange"> & {
innerClassName?: string;
color?: Color | 'custom' | 'default';
variant?: 'border' | 'solid';
color?: Color | "custom" | "default";
variant?: "border" | "solid";
isLoading?: boolean;
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
size?: "2xs" | "xs" | "sm" | "md" | "auto";
justify?: "start" | "center";
type?: "button" | "submit";
forDropdown?: boolean;
disabled?: boolean;
title?: string;
@@ -32,11 +32,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
innerClassName,
children,
forDropdown,
color = 'default',
type = 'button',
justify = 'center',
size = 'md',
variant = 'solid',
color = "default",
type = "button",
justify = "center",
size = "md",
variant = "solid",
leftSlot,
rightSlot,
disabled,
@@ -49,8 +49,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
}: ButtonProps,
ref,
) {
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join('');
const fullTitle = hotkeyTrigger ? `${title ?? ''} ${hotkeyTrigger}`.trim() : title;
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
if (isLoading) {
disabled = true;
@@ -58,37 +58,37 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
const classes = classNames(
className,
'x-theme-button',
"x-theme-button",
`x-theme-button--${variant}`,
`x-theme-button--${variant}--${color}`,
'border', // They all have borders to ensure the same width
'max-w-full min-w-0', // Help with truncation
'hocus:opacity-100', // Force opacity for certain hover effects
'whitespace-nowrap outline-none',
'flex-shrink-0 flex items-center',
'outline-0',
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
size === 'md' && 'h-md px-3 rounded-md',
size === 'sm' && 'h-sm px-2.5 rounded-md',
size === 'xs' && 'h-xs px-2 text-sm rounded-md',
size === '2xs' && 'h-2xs px-2 text-xs rounded',
"border", // They all have borders to ensure the same width
"max-w-full min-w-0", // Help with truncation
"hocus:opacity-100", // Force opacity for certain hover effects
"whitespace-nowrap outline-none",
"flex-shrink-0 flex items-center",
"outline-0",
disabled ? "pointer-events-none opacity-disabled" : "pointer-events-auto",
justify === "start" && "justify-start",
justify === "center" && "justify-center",
size === "md" && "h-md px-3 rounded-md",
size === "sm" && "h-sm px-2.5 rounded-md",
size === "xs" && "h-xs px-2 text-sm rounded-md",
size === "2xs" && "h-2xs px-2 text-xs rounded",
// Solids
variant === 'solid' && 'border-transparent',
variant === 'solid' && color === 'custom' && 'focus-visible:outline-2 outline-border-focus',
variant === 'solid' &&
color !== 'custom' &&
'text-text enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle',
variant === 'solid' && color !== 'custom' && color !== 'default' && 'bg-surface',
variant === "solid" && "border-transparent",
variant === "solid" && color === "custom" && "focus-visible:outline-2 outline-border-focus",
variant === "solid" &&
color !== "custom" &&
"text-text enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle",
variant === "solid" && color !== "custom" && color !== "default" && "bg-surface",
// Borders
variant === 'border' && 'border',
variant === 'border' &&
color !== 'custom' &&
'border-border-subtle text-text-subtle enabled:hocus:border-border ' +
'enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler',
variant === "border" && "border",
variant === "border" &&
color !== "custom" &&
"border-border-subtle text-text-subtle enabled:hocus:border-border " +
"enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler",
);
const buttonRef = useRef<HTMLButtonElement>(null);
@@ -121,14 +121,14 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{...props}
>
{isLoading ? (
<LoadingIcon size={size === 'auto' ? 'md' : size} className="mr-1" />
<LoadingIcon size={size === "auto" ? "md" : size} className="mr-1" />
) : leftSlot ? (
<div className="mr-2">{leftSlot}</div>
) : null}
<div
className={classNames(
'truncate w-full',
justify === 'start' ? 'text-left' : 'text-center',
"truncate w-full",
justify === "start" ? "text-left" : "text-center",
innerClassName,
)}
>
@@ -138,7 +138,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{forDropdown && (
<Icon
icon="chevron_down"
size={size === 'auto' ? 'md' : size}
size={size === "auto" ? "md" : size}
className="ml-1 -mr-1 relative top-[0.1em]"
/>
)}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import { useState } from "react";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
export function ButtonInfiniteLoading({
onClick,

View File

@@ -1,11 +1,11 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { Icon } from './Icon';
import { IconTooltip } from './IconTooltip';
import { HStack } from './Stacks';
import classNames from "classnames";
import type { ReactNode } from "react";
import { Icon } from "./Icon";
import { IconTooltip } from "./IconTooltip";
import { HStack } from "./Stacks";
export interface CheckboxProps {
checked: boolean | 'indeterminate';
checked: boolean | "indeterminate";
title: ReactNode;
onChange: (checked: boolean) => void;
className?: string;
@@ -32,34 +32,34 @@ export function Checkbox({
as="label"
alignItems="center"
space={2}
className={classNames(className, 'text-text mr-auto')}
className={classNames(className, "text-text mr-auto")}
>
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex mr-0.5')}>
<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',
"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);
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'}
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')}
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
>
{title}
</div>

View File

@@ -1,9 +1,9 @@
import classNames from 'classnames';
import { useState } from 'react';
import { HexColorPicker } from 'react-colorful';
import { useRandomKey } from '../../hooks/useRandomKey';
import { Icon } from './Icon';
import { PlainInput } from './PlainInput';
import classNames from "classnames";
import { useState } from "react";
import { HexColorPicker } from "react-colorful";
import { useRandomKey } from "../../hooks/useRandomKey";
import { Icon } from "./Icon";
import { PlainInput } from "./PlainInput";
interface Props {
onChange: (value: string | null) => void;
@@ -27,7 +27,7 @@ export function ColorPicker({ onChange, color, className }: Props) {
hideLabel
label="Plain Color"
forceUpdateKey={updateKey}
defaultValue={color ?? ''}
defaultValue={color ?? ""}
onChange={onChange}
validate={(color) => color.match(/#[0-9a-fA-F]{6}$/) !== null}
/>
@@ -37,14 +37,14 @@ export function ColorPicker({ onChange, color, className }: Props) {
const colors = [
null,
'danger',
'warning',
'notice',
'success',
'primary',
'info',
'secondary',
'custom',
"danger",
"warning",
"notice",
"success",
"primary",
"info",
"secondary",
"custom",
] as const;
export function ColorPickerWithThemeColors({ onChange, color, className }: Props) {
@@ -52,10 +52,10 @@ export function ColorPickerWithThemeColors({ onChange, color, className }: Props
const [selectedColor, setSelectedColor] = useState<string | null>(() => {
if (color == null) return null;
const c = color?.match(/var\(--([a-z]+)\)/)?.[1];
return c ?? 'custom';
return c ?? "custom";
});
return (
<div className={classNames(className, 'flex flex-col gap-3')}>
<div className={classNames(className, "flex flex-col gap-3")}>
<div className="flex items-center gap-2.5">
{colors.map((color) => (
<button
@@ -65,34 +65,34 @@ export function ColorPickerWithThemeColors({ onChange, color, className }: Props
setSelectedColor(color);
if (color == null) {
onChange(null);
} else if (color === 'custom') {
onChange('#ffffff');
} 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))]',
"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' && (
{selectedColor === "custom" && (
<>
<HexColorPicker
color={color ?? undefined}
@@ -106,7 +106,7 @@ export function ColorPickerWithThemeColors({ onChange, color, className }: Props
hideLabel
label="Plain Color"
forceUpdateKey={updateKey}
defaultValue={color ?? ''}
defaultValue={color ?? ""}
onChange={onChange}
validate={(color) => color.match(/#[0-9a-fA-F]{6}$/) !== null}
/>

View File

@@ -1,10 +1,10 @@
import type { Color } from '@yaakapp-internal/plugins';
import type { FormEvent } from 'react';
import { useState } from 'react';
import { CopyIconButton } from '../CopyIconButton';
import { Button } from './Button';
import { PlainInput } from './PlainInput';
import { HStack } from './Stacks';
import type { Color } from "@yaakapp-internal/plugins";
import type { FormEvent } from "react";
import { useState } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Button } from "./Button";
import { PlainInput } from "./PlainInput";
import { HStack } from "./Stacks";
export interface ConfirmProps {
onHide: () => void;
@@ -19,9 +19,9 @@ export function Confirm({
onResult,
confirmText,
requireTyping,
color = 'primary',
color = "primary",
}: ConfirmProps) {
const [confirm, setConfirm] = useState<string>('');
const [confirm, setConfirm] = useState<string>("");
const handleHide = () => {
onResult(false);
onHide();
@@ -63,7 +63,7 @@ export function Confirm({
)}
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
<Button type="submit" color={color} disabled={!didConfirm}>
{confirmText ?? 'Confirm'}
{confirmText ?? "Confirm"}
</Button>
<Button onClick={handleHide} variant="border">
Cancel

View File

@@ -1,5 +1,5 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
interface Props {
count: number | true;
@@ -17,15 +17,15 @@ export function CountBadge({ count, count2, className, color, showZero }: Props)
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',
"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 ? (

View File

@@ -1,14 +1,14 @@
import classNames from 'classnames';
import { atom, useAtom } from 'jotai';
import type { HTMLAttributes, ReactNode } from 'react';
import { useMemo } from 'react';
import { atomWithKVStorage } from '../../lib/atoms/atomWithKVStorage';
import type { BannerProps } from './Banner';
import { Banner } from './Banner';
import classNames from "classnames";
import { atom, useAtom } from "jotai";
import type { HTMLAttributes, ReactNode } from "react";
import { useMemo } from "react";
import { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage";
import type { BannerProps } from "./Banner";
import { Banner } from "./Banner";
interface Props extends HTMLAttributes<HTMLDetailsElement> {
summary: ReactNode;
color?: BannerProps['color'];
color?: BannerProps["color"];
defaultOpen?: boolean;
storageKey?: string;
}
@@ -26,7 +26,7 @@ export function DetailsBanner({
const openAtom = useMemo(
() =>
storageKey
? atomWithKVStorage<boolean>(['details_banner', storageKey], defaultOpen ?? false)
? atomWithKVStorage<boolean>(["details_banner", storageKey], defaultOpen ?? false)
: atom(defaultOpen ?? false),
[storageKey],
);
@@ -45,10 +45,10 @@ export function DetailsBanner({
<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',
"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}

View File

@@ -1,11 +1,11 @@
import classNames from 'classnames';
import * as m from 'motion/react-m';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { Overlay } from '../Overlay';
import { Heading } from './Heading';
import { IconButton } from './IconButton';
import type { DialogSize } from '@yaakapp-internal/plugins';
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { Overlay } from "../Overlay";
import { Heading } from "./Heading";
import { IconButton } from "./IconButton";
import type { DialogSize } from "@yaakapp-internal/plugins";
export interface DialogProps {
children: ReactNode;
@@ -19,13 +19,13 @@ export interface DialogProps {
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
vAlign?: 'top' | 'center';
vAlign?: "top" | "center";
}
export function Dialog({
children,
className,
size = 'full',
size = "full",
open,
onClose,
disableBackdropClose,
@@ -34,7 +34,7 @@ export function Dialog({
hideX,
noPadding,
noScroll,
vAlign = 'center',
vAlign = "center",
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const descriptionId = useMemo(
@@ -47,10 +47,10 @@ export function 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',
"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}
@@ -58,7 +58,7 @@ export function Dialog({
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') {
if (e.key === "Escape") {
onClose?.();
e.stopPropagation();
e.preventDefault();
@@ -70,18 +70,18 @@ export function Dialog({
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]',
"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 ? (
@@ -102,9 +102,9 @@ export function Dialog({
<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',
"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}

View File

@@ -1,10 +1,10 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import { useKeyValue } from '../../hooks/useKeyValue';
import type { BannerProps } from './Banner';
import { Banner } from './Banner';
import { Button } from './Button';
import { HStack } from './Stacks';
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import { useKeyValue } from "../../hooks/useKeyValue";
import type { BannerProps } from "./Banner";
import { Banner } from "./Banner";
import { Button } from "./Button";
import { HStack } from "./Stacks";
export function DismissibleBanner({
children,
@@ -17,8 +17,8 @@ export function DismissibleBanner({
actions?: { label: string; onClick: () => void; color?: Color }[];
}) {
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
namespace: 'global',
key: ['dismiss-banner', id],
namespace: "global",
key: ["dismiss-banner", id],
fallback: false,
});
@@ -26,7 +26,7 @@ export function DismissibleBanner({
return (
<Banner
className={classNames(className, 'relative grid grid-cols-[1fr_auto] gap-3')}
className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")}
{...props}
>
{children}

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import { atom } from 'jotai';
import * as m from 'motion/react-m';
import classNames from "classnames";
import { atom } from "jotai";
import * as m from "motion/react-m";
import type {
CSSProperties,
HTMLAttributes,
@@ -11,7 +11,7 @@ import type {
ReactNode,
RefObject,
SetStateAction,
} from 'react';
} from "react";
import {
Children,
cloneElement,
@@ -22,43 +22,43 @@ import {
useMemo,
useRef,
useState,
} from 'react';
import { useKey, useWindowSize } from 'react-use';
import { useClickOutside } from '../../hooks/useClickOutside';
import { fireAndForget } from '../../lib/fireAndForget';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useHotKey } from '../../hooks/useHotKey';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { generateId } from '../../lib/generateId';
import { getNodeText } from '../../lib/getNodeText';
import { jotaiStore } from '../../lib/jotai';
import { ErrorBoundary } from '../ErrorBoundary';
import { Overlay } from '../Overlay';
import { Button } from './Button';
import { Hotkey } from './Hotkey';
import { Icon, type IconProps } from './Icon';
import { LoadingIcon } from './LoadingIcon';
import { Separator } from './Separator';
import { HStack, VStack } from './Stacks';
} from "react";
import { useKey, useWindowSize } from "react-use";
import { useClickOutside } from "../../hooks/useClickOutside";
import { fireAndForget } from "../../lib/fireAndForget";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useHotKey } from "../../hooks/useHotKey";
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { generateId } from "../../lib/generateId";
import { getNodeText } from "../../lib/getNodeText";
import { jotaiStore } from "../../lib/jotai";
import { ErrorBoundary } from "../ErrorBoundary";
import { Overlay } from "../Overlay";
import { Button } from "./Button";
import { Hotkey } from "./Hotkey";
import { Icon, type IconProps } from "./Icon";
import { LoadingIcon } from "./LoadingIcon";
import { Separator } from "./Separator";
import { HStack, VStack } from "./Stacks";
export type DropdownItemSeparator = {
type: 'separator';
type: "separator";
label?: ReactNode;
hidden?: boolean;
};
export type DropdownItemContent = {
type: 'content';
type: "content";
label?: ReactNode;
hidden?: boolean;
};
export type DropdownItemDefault = {
type?: 'default';
type?: "default";
label: ReactNode;
hotKeyAction?: HotkeyAction;
hotKeyLabelOnly?: boolean;
color?: 'default' | 'primary' | 'danger' | 'info' | 'warning' | 'notice' | 'success';
color?: "default" | "primary" | "danger" | "info" | "warning" | "notice" | "success";
disabled?: boolean;
hidden?: boolean;
leftSlot?: ReactNode;
@@ -69,7 +69,7 @@ export type DropdownItemDefault = {
submenu?: DropdownItem[];
/** If true, submenu opens on click instead of hover */
submenuOpenOnClick?: boolean;
icon?: IconProps['icon'];
icon?: IconProps["icon"];
};
export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent;
@@ -117,20 +117,20 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
// 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 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;
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');
.getPropertyValue("background-color");
}
}
return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
@@ -144,7 +144,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
useEffect(() => {
if (!isOpen) {
// Clear persisted BG
if (buttonRef.current) buttonRef.current.style.backgroundColor = '';
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);
@@ -187,7 +187,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
{
...existingChild.props,
ref: buttonRef,
'aria-haspopup': 'true',
"aria-haspopup": "true",
onClick: (e: MouseEvent<HTMLButtonElement>) => {
// Call original onClick first if it exists
originalOnClick?.(e);
@@ -204,7 +204,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
}, [children, handleSetIsOpen]);
useEffect(() => {
buttonRef.current?.setAttribute('aria-expanded', isOpen.toString());
buttonRef.current?.setAttribute("aria-expanded", isOpen.toString());
}, [isOpen]);
const windowSize = useWindowSize();
@@ -217,7 +217,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
return (
<>
{child}
<ErrorBoundary name={'Dropdown Menu'}>
<ErrorBoundary name={"Dropdown Menu"}>
<Menu
ref={menuRef}
showTriangle
@@ -237,7 +237,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
export interface ContextMenuProps {
triggerPosition: { x: number; y: number } | null;
className?: string;
items: DropdownProps['items'];
items: DropdownProps["items"];
onClose: () => void;
}
@@ -273,7 +273,7 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
interface MenuProps {
className?: string;
defaultSelectedIndex: number | null;
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
triggerShape: Pick<DOMRect, "top" | "bottom" | "left" | "right"> | null;
onClose: () => void;
onCloseAll?: () => void;
showTriangle?: boolean;
@@ -284,7 +284,7 @@ interface MenuProps {
isSubmenu?: boolean;
}
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'>, MenuProps>(
const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items">, MenuProps>(
(
{
className,
@@ -306,12 +306,12 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
[defaultSelectedIndex],
);
const [filter, setFilter] = useState<string>('');
const [filter, setFilter] = useState<string>("");
// Clear filter when menu opens
useEffect(() => {
if (isOpen) {
setFilter('');
setFilter("");
}
}, [isOpen]);
@@ -357,18 +357,18 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
e.preventDefault();
setFilter((f) => f + e.key);
setSelectedIndex(0);
} else if (e.key === 'Backspace' && !isSpecial) {
} else if (e.key === "Backspace" && !isSpecial) {
e.preventDefault();
setFilter((f) => f.slice(0, -1));
}
};
useKey(
'Escape',
"Escape",
() => {
if (!isOpen) return;
if (activeSubmenu) setActiveSubmenu(null);
else if (filter !== '') setFilter('');
else if (filter !== "") setFilter("");
else handleClose();
},
{},
@@ -381,7 +381,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
let nextIndex = (currIndex ?? 0) - incrBy;
const maxTries = items.length;
for (let i = 0; i < maxTries; i++) {
if (items[nextIndex]?.hidden || items[nextIndex]?.type === 'separator') {
if (items[nextIndex]?.hidden || items[nextIndex]?.type === "separator") {
nextIndex--;
} else if (nextIndex < 0) {
nextIndex = items.length - 1;
@@ -401,7 +401,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
let nextIndex = (currIndex ?? -1) + incrBy;
const maxTries = items.length;
for (let i = 0; i < maxTries; i++) {
if (items[nextIndex]?.hidden || items[nextIndex]?.type === 'separator') {
if (items[nextIndex]?.hidden || items[nextIndex]?.type === "separator") {
nextIndex++;
} else if (nextIndex >= items.length) {
nextIndex = 0;
@@ -418,13 +418,13 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
// 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') {
if (item?.hidden || item?.type === "separator" || item?.type === "content") {
handleNext();
}
}, [selectedIndex, items, handleNext]);
useKey(
'ArrowUp',
"ArrowUp",
(e) => {
if (!isOpen || activeSubmenu) return;
e.preventDefault();
@@ -435,7 +435,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
);
useKey(
'ArrowDown',
"ArrowDown",
(e) => {
if (!isOpen || activeSubmenu) return;
e.preventDefault();
@@ -446,7 +446,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
);
useKey(
'ArrowLeft',
"ArrowLeft",
(e) => {
if (!isOpen) return;
// Only handle if this menu doesn't have an open submenu
@@ -465,12 +465,12 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
const handleSelect = useCallback(
async (item: DropdownItem, parentEl?: HTMLButtonElement) => {
// Handle click-to-open submenu
if ('submenu' in item && item.submenu && item.submenuOpenOnClick && parentEl) {
if ("submenu" in item && item.submenu && item.submenuOpenOnClick && parentEl) {
setActiveSubmenu({ item, parent: parentEl });
return;
}
if (!('onSelect' in item) || !item.onSelect) return;
if (!("onSelect" in item) || !item.onSelect) return;
setSelectedIndex(null);
const promise = item.onSelect();
@@ -554,17 +554,17 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
right: onRight ? docRect.width - triggerShape.right : undefined,
left: !onRight ? triggerShape.left : undefined,
minWidth: fullWidth ? triggerWidth : undefined,
maxWidth: '40rem',
maxWidth: "40rem",
},
triangle: {
width: '0.4rem',
height: '0.4rem',
width: "0.4rem",
height: "0.4rem",
...(onRight
? { right: width / 2, marginRight: '-0.2rem' }
: { left: width / 2, marginLeft: '-0.2rem' }),
? { right: width / 2, marginRight: "-0.2rem" }
: { left: width / 2, marginLeft: "-0.2rem" }),
...(upsideDown
? { bottom: '-0.2rem', rotate: '225deg' }
: { top: '-0.2rem', rotate: '45deg' }),
? { bottom: "-0.2rem", rotate: "225deg" }
: { top: "-0.2rem", rotate: "45deg" }),
},
menu: {
maxHeight: `${(upsideDown ? heightAbove : heightBelow) - 15}px`,
@@ -586,11 +586,11 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
);
useKey(
'ArrowRight',
"ArrowRight",
(e) => {
if (!isOpen || activeSubmenu) return;
const item = filteredItems[selectedIndex ?? -1];
if (item?.type !== 'separator' && item?.type !== 'content' && item?.submenu) {
if (item?.type !== "separator" && item?.type !== "content" && item?.submenu) {
e.preventDefault();
const parent = document.activeElement as HTMLButtonElement;
if (parent) {
@@ -603,11 +603,11 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
);
useKey(
'Enter',
"Enter",
(e) => {
if (!isOpen || activeSubmenu) return;
const item = filteredItems[selectedIndex ?? -1];
if (!item || item.type === 'separator' || item.type === 'content') return;
if (!item || item.type === "separator" || item.type === "content") return;
e.preventDefault();
if (item.submenu) {
const parent = document.activeElement as HTMLButtonElement;
@@ -714,9 +714,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
style={styles.container}
className={classNames(
className,
'x-theme-menu',
'outline-none my-1 pointer-events-auto z-40',
'fixed',
"x-theme-menu",
"outline-none my-1 pointer-events-auto z-40",
"fixed",
)}
>
{showTriangle && !isSubmenu && (
@@ -730,8 +730,8 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
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',
"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 && (
@@ -750,22 +750,22 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
if (item.hidden) {
return null;
}
if (item.type === 'separator') {
if (item.type === "separator") {
return (
<Separator
// oxlint-disable-next-line react/no-array-index-key -- Nothing else available
key={i}
className={classNames('my-1.5', item.label ? 'ml-2' : null)}
className={classNames("my-1.5", item.label ? "ml-2" : null)}
>
{item.label}
</Separator>
);
}
if (item.type === 'content') {
if (item.type === "content") {
return (
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions
// oxlint-disable-next-line react/no-array-index-key
<div key={i} className={classNames('my-1 mx-2 max-w-xs')} onClick={onClose}>
<div key={i} className={classNames("my-1 mx-2 max-w-xs")} onClick={onClose}>
{item.label}
</div>
);
@@ -812,8 +812,8 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
// 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.type !== "separator" &&
item.type !== "content" &&
!item.hotKeyLabelOnly &&
item.hotKeyAction && (
<MenuItemHotKey
@@ -915,7 +915,7 @@ function MenuItem({
justify="start"
leftSlot={
(isLoading || item.leftSlot || item.icon) && (
<div className={classNames('pr-2 flex justify-start [&_svg]:opacity-70')}>
<div className={classNames("pr-2 flex justify-start [&_svg]:opacity-70")}>
{isLoading ? <LoadingIcon /> : item.icon ? <Icon icon={item.icon} /> : item.leftSlot}
</div>
)
@@ -925,28 +925,28 @@ function MenuItem({
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',
"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>
<div className={classNames("truncate min-w-[5rem]")}>{item.label}</div>
</Button>
);
}
interface MenuItemHotKeyProps {
action: HotkeyAction | undefined;
onSelect: MenuItemProps['onSelect'];
item: MenuItemProps['item'];
onSelect: MenuItemProps["onSelect"];
item: MenuItemProps["item"];
}
function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) {

View File

@@ -1,4 +1,4 @@
import { type DecorationSet, MatchDecorator, type ViewUpdate } from '@codemirror/view';
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

View File

@@ -1,11 +1,11 @@
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';
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) */
@@ -45,7 +45,7 @@ export function DiffViewer({ original, modified, className }: Props) {
collapseUnchanged: { margin: 2, minSize: 3 },
highlightChanges: false,
gutter: true,
orientation: 'a-b',
orientation: "a-b",
revertControls: undefined,
});
@@ -58,7 +58,7 @@ export function DiffViewer({ original, modified, className }: Props) {
return (
<div
ref={containerRef}
className={classNames('cm-wrapper cm-multiline h-full w-full', className)}
className={classNames("cm-wrapper cm-multiline h-full w-full", className)}
/>
);
}

View File

@@ -438,7 +438,7 @@
}
input.cm-textfield {
@apply cursor-text;
@apply cursor-text;
}
.cm-search label {

View File

@@ -1,21 +1,21 @@
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 { startCompletion } from "@codemirror/autocomplete";
import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands";
import { foldState, forceParsing } from "@codemirror/language";
import type { EditorStateConfig, Extension } from "@codemirror/state";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView, keymap, placeholder as placeholderExt, tooltips } from "@codemirror/view";
import { emacs } from "@replit/codemirror-emacs";
import { vim } from "@replit/codemirror-vim";
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import type { EditorKeymap } from '@yaakapp-internal/models';
import { settingsAtom } from '@yaakapp-internal/models';
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { GraphQLSchema } from 'graphql';
import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5';
import type { ReactNode, RefObject } from 'react';
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
import type { EditorKeymap } from "@yaakapp-internal/models";
import { settingsAtom } from "@yaakapp-internal/models";
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { GraphQLSchema } from "graphql";
import { useAtomValue } from "jotai";
import { md5 } from "js-md5";
import type { ReactNode, RefObject } from "react";
import {
Children,
cloneElement,
@@ -25,32 +25,32 @@ import {
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment';
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
import { eventMatchesHotkey } from '../../../hooks/useHotKey';
import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
import { editEnvironment } from '../../../lib/editEnvironment';
import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
import { jotaiStore } from '../../../lib/jotai';
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { IconButton } from '../IconButton';
import { HStack } from '../Stacks';
import './Editor.css';
} from "react";
import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment";
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables";
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
import { useRequestEditor } from "../../../hooks/useRequestEditor";
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
import { editEnvironment } from "../../../lib/editEnvironment";
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
import { jotaiStore } from "../../../lib/jotai";
import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption";
import { TemplateFunctionDialog } from "../../TemplateFunctionDialog";
import { IconButton } from "../IconButton";
import { HStack } from "../Stacks";
import "./Editor.css";
import {
baseExtensions,
getLanguageExtension,
multiLineExtensions,
readonlyExtensions,
} from './extensions';
import type { GenericCompletionConfig } from './genericCompletion';
import { singleLineExtensions } from './singleLine';
} 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 vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== "Tab");
const keymapExtensions: Record<EditorKeymap, Extension> = {
vim: vim(),
@@ -74,10 +74,10 @@ export interface EditorProps {
forcedEnvironmentId?: string;
forceUpdateKey?: string | number;
format?: (v: string) => Promise<string>;
heightMode?: 'auto' | 'full';
heightMode?: "auto" | "full";
hideGutter?: boolean;
id?: string;
language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null;
language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
lintExtension?: Extension;
graphQLSchema?: GraphQLSchema | null;
onBlur?: () => void;
@@ -92,7 +92,7 @@ export interface EditorProps {
containerOnly?: boolean;
stateKey: string | null;
tooltipContainer?: HTMLElement;
type?: 'text' | 'password';
type?: "text" | "password";
wrapLines?: boolean;
setRef?: (view: EditorView | null) => void;
}
@@ -147,7 +147,7 @@ function EditorInner({
const useTemplating = !!(autocompleteFunctions || autocompleteVariables || autocomplete);
const environmentVariables = useMemo(() => {
if (!autocompleteVariables) return emptyVariables;
return typeof autocompleteVariables === 'function'
return typeof autocompleteVariables === "function"
? allEnvironmentVariables.filter(autocompleteVariables)
: allEnvironmentVariables;
}, [allEnvironmentVariables, autocompleteVariables]);
@@ -163,18 +163,18 @@ function EditorInner({
if (
singleLine ||
language == null ||
language === 'text' ||
language === 'url' ||
language === 'pairs'
language === "text" ||
language === "url" ||
language === "pairs"
) {
disableTabIndent = true;
}
if (format == null && !readOnly) {
format =
language === 'json'
language === "json"
? tryFormatJson
: language === 'xml' || language === 'html'
: language === "xml" || language === "html"
? tryFormatXml
: undefined;
}
@@ -182,37 +182,37 @@ function EditorInner({
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);
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);
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);
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);
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);
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);
const handleKeyDown = useRef<EditorProps["onKeyDown"]>(onKeyDown);
useEffect(() => {
handleKeyDown.current = onKeyDown;
}, [onKeyDown]);
@@ -236,10 +236,10 @@ function EditorInner({
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
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);
@@ -289,7 +289,7 @@ function EditorInner({
TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view);
};
if (fn.name === 'secure') {
if (fn.name === "secure") {
withEncryptionEnabled(show);
} else {
show();
@@ -309,7 +309,7 @@ function EditorInner({
const onClickMissingVariable = useCallback(async (name: string) => {
const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);
await editEnvironment(activeEnvironment, {
addOrFocusVariable: { name, value: '', enabled: true },
addOrFocusVariable: { name, value: "", enabled: true },
});
}, []);
@@ -414,9 +414,9 @@ function EditorInner({
: []),
];
const cachedJsonState = getCachedEditorState(defaultValue ?? '', stateKey);
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey);
const doc = `${defaultValue ?? ''}`;
const doc = `${defaultValue ?? ""}`;
const config: EditorStateConfig = { extensions, doc };
const state = cachedJsonState
@@ -439,7 +439,7 @@ function EditorInner({
}
setRef?.(view);
} catch (e) {
console.log('Failed to initialize Codemirror', e);
console.log("Failed to initialize Codemirror", e);
}
},
[forceUpdateKey],
@@ -449,7 +449,7 @@ function EditorInner({
useEffect(
function updateReadOnlyEditor() {
if (readOnly && cm.current?.view != null) {
updateContents(cm.current.view, defaultValue || '');
updateContents(cm.current.view, defaultValue || "");
}
},
[defaultValue, readOnly],
@@ -460,7 +460,7 @@ function EditorInner({
function updateNonFocusedEditor() {
const notFocused = !cm.current?.view.hasFocus;
if (notFocused && cm.current != null) {
updateContents(cm.current.view, defaultValue || '');
updateContents(cm.current.view, defaultValue || "");
}
},
[defaultValue],
@@ -470,7 +470,7 @@ function EditorInner({
const decoratedActions = useMemo(() => {
const results = [];
const actionClassName = classNames(
'bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow',
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow",
);
if (format) {
@@ -517,12 +517,12 @@ function EditorInner({
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',
"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",
)}
/>
);
@@ -539,8 +539,8 @@ function EditorInner({
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
"absolute bottom-2 left-0 right-0",
"pointer-events-none", // No pointer events, so we don't block the editor
)}
>
{decoratedActions}
@@ -562,20 +562,20 @@ function getExtensions({
onFocus,
onBlur,
onKeyDown,
}: Pick<EditorProps, 'singleLine' | 'readOnly' | 'hideGutter'> & {
stateKey: EditorProps['stateKey'];
}: 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']>;
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') ??
document.querySelector<HTMLDivElement>("#cm-portal") ??
undefined;
return [
@@ -589,7 +589,7 @@ function getExtensions({
},
keydown: (e, view) => {
// Check if the hotkey matches the editor.autocomplete action
if (eventMatchesHotkey(e, 'editor.autocomplete')) {
if (eventMatchesHotkey(e, "editor.autocomplete")) {
e.preventDefault();
startCompletion(view);
return true;
@@ -597,7 +597,7 @@ function getExtensions({
onKeyDown.current?.(e);
},
paste: (e, v) => {
const textData = e.clipboardData?.getData('text/plain') ?? '';
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);
@@ -605,7 +605,7 @@ function getExtensions({
},
}),
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
...(readOnly ? readonlyExtensions : []),
@@ -627,10 +627,10 @@ function getExtensions({
}
const placeholderElFromText = (text: string | undefined) => {
const el = document.createElement('div');
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/>') : ' ';
el.innerHTML = text ? text.replaceAll("\n", "<br/>") : " ";
return el;
};
@@ -646,7 +646,7 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
try {
sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj));
} catch (err) {
console.log('Failed to save to editor state', stateKey, err);
console.log("Failed to save to editor state", stateKey, err);
}
}
@@ -667,7 +667,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
state.doc = doc;
return state;
} catch (err) {
console.log('Failed to restore editor storage', stateKey, err);
console.log("Failed to restore editor storage", stateKey, err);
}
return null;

View File

@@ -1,7 +1,7 @@
import { lazy, Suspense } from 'react';
import type { EditorProps } from './Editor';
import { lazy, Suspense } from "react";
import type { EditorProps } from "./Editor";
const Editor_ = lazy(() => import('./Editor').then((m) => ({ default: m.Editor })));
const Editor_ = lazy(() => import("./Editor").then((m) => ({ default: m.Editor })));
export function Editor(props: EditorProps) {
return (

View File

@@ -3,15 +3,15 @@ import {
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';
} 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,
@@ -22,20 +22,20 @@ import {
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';
} 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,
@@ -46,51 +46,51 @@ import {
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';
} 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)',
color: "var(--textSubtlest)",
},
{
tag: [t.emphasis],
textDecoration: 'underline',
textDecoration: "underline",
},
{
tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation],
color: 'var(--textSubtle)',
color: "var(--textSubtle)",
},
{
tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number],
color: 'var(--info)',
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)' },
{ 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 });
@@ -102,7 +102,7 @@ const legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {
};
const syntaxExtensions: Record<
NonNullable<EditorProps['language']>,
NonNullable<EditorProps["language"]>,
null | (() => LanguageSupport)
> = {
graphql: null,
@@ -134,11 +134,11 @@ const syntaxExtensions: Record<
swift: legacyLang(swift),
};
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript', 'graphql'];
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ["json", "javascript", "graphql"];
export function getLanguageExtension({
useTemplating,
language = 'text',
language = "text",
lintExtension,
environmentVariables,
autocomplete,
@@ -156,10 +156,10 @@ export function getLanguageExtension({
onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[];
graphQLSchema: GraphQLSchema | null;
} & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter' | 'lintExtension'>) {
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">) {
const extraExtensions: Extension[] = [];
if (language === 'url') {
if (language === "url") {
extraExtensions.push(pathParametersPlugin(onClickPathParameter));
}
@@ -169,13 +169,13 @@ export function getLanguageExtension({
}
// GraphQL is a special exception
if (language === 'graphql') {
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');
const span = document.createElement("span");
span.innerHTML = innerHTML;
return span;
},
@@ -192,11 +192,11 @@ export function getLanguageExtension({
];
}
if (language === 'json') {
if (language === "json") {
extraExtensions.push(lintExtension ?? linter(jsonParseLinter()));
extraExtensions.push(
jsoncLanguage.data.of({
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
commentTokens: { line: "//", block: { open: "/*", close: "*/" } },
}),
);
if (!hideGutter) {
@@ -205,7 +205,7 @@ export function getLanguageExtension({
}
const maybeBase = language ? syntaxExtensions[language] : null;
const base = typeof maybeBase === 'function' ? maybeBase() : null;
const base = typeof maybeBase === "function" ? maybeBase() : null;
if (base == null) {
return [];
}
@@ -229,10 +229,10 @@ export function getLanguageExtension({
// 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() ?? '';
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('`');
const isStartTrigger = key.includes("space") || mac.includes("alt-") || mac.includes("`");
return !isStartTrigger;
});
@@ -242,7 +242,7 @@ export const baseExtensions = [
dropCursor(),
drawSelection(),
autocompletion({
tooltipClass: () => 'x-theme-menu',
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) => {
@@ -257,7 +257,7 @@ export const baseExtensions = [
export const readonlyExtensions = [
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({ tabindex: '-1' }),
EditorView.contentAttributes.of({ tabindex: "-1" }),
];
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
@@ -269,11 +269,11 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
lineNumbers(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement('div');
el.classList.add('fold-gutter-icon');
const el = document.createElement("div");
el.classList.add("fold-gutter-icon");
el.tabIndex = -1;
if (open) {
el.setAttribute('data-open', '');
el.setAttribute("data-open", "");
}
return el;
},
@@ -281,12 +281,12 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
],
codeFolding({
placeholderDOM(_view, onclick, prepared) {
const el = document.createElement('span');
const el = document.createElement("span");
el.onclick = onclick;
el.className = 'cm-foldPlaceholder';
el.innerText = prepared || '…';
el.title = 'unfold';
el.ariaLabel = 'folded code';
el.className = "cm-foldPlaceholder";
el.innerText = prepared || "…";
el.title = "unfold";
el.ariaLabel = "folded code";
return el;
},
/**
@@ -295,15 +295,15 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
*/
preparePlaceholder(state, range) {
let count: number | undefined;
let startToken = '{';
let endToken = '}';
let startToken = "{";
let endToken = "}";
const prevLine = state.doc.lineAt(range.from).text;
const isArray = prevLine.lastIndexOf('[') > prevLine.lastIndexOf('{');
const isArray = prevLine.lastIndexOf("[") > prevLine.lastIndexOf("{");
if (isArray) {
startToken = '[';
endToken = ']';
startToken = "[";
endToken = "]";
}
const internal = state.sliceDoc(range.from, range.to);
@@ -317,7 +317,7 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
}
if (count !== undefined) {
const label = isArray ? 'item' : 'key';
const label = isArray ? "item" : "key";
return pluralizeCount(label, count);
}
},

View File

@@ -1,8 +1,8 @@
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';
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;
@@ -43,7 +43,7 @@ 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;
if (n.name === "Phrase") return true;
n = n.parent;
}
return false;
@@ -53,7 +53,7 @@ function inPhrase(ctx: CompletionContext): boolean {
function inUnclosedQuote(doc: string, pos: number): boolean {
let quotes = 0;
for (let i = 0; i < pos; i++) {
if (doc[i] === '"' && doc[i - 1] !== '\\') quotes++;
if (doc[i] === '"' && doc[i - 1] !== "\\") quotes++;
}
return quotes % 2 === 1; // odd = inside an open quote
}
@@ -64,13 +64,13 @@ function inUnclosedQuote(doc: string, pos: number): boolean {
* - Otherwise, we're in a field name or bare term position.
*/
function contextInfo(stateDoc: string, pos: number) {
const lastColon = stateDoc.lastIndexOf(':', pos - 1);
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),
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;
@@ -96,7 +96,7 @@ function contextInfo(stateDoc: string, pos: number) {
function fieldNameCompletions(fieldNames: string[]): Completion[] {
return fieldNames.map((name) => ({
label: name,
type: 'property',
type: "property",
apply: (view, _completion, from, to) => {
// Insert "name:" (leave cursor right after colon)
view.dispatch({
@@ -117,7 +117,7 @@ function fieldValueCompletions(
return vals.map((v) => ({
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
displayLabel: v,
type: 'constant',
type: "constant",
}));
}
@@ -169,7 +169,7 @@ function makeCompletionSource(opts: FilterOptions) {
}
const language = LRLanguage.define({
name: 'filter',
name: "filter",
parser,
languageData: {
autocompletion: {},

View File

@@ -1,20 +1,20 @@
/* oxlint-disable */
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from '@lezer/lr';
import { highlight } from './highlight';
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',
"$]~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',
"⚠ 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'],
["openedBy", 8, "LParen"],
["closedBy", 9, "RParen"],
],
propSources: [highlight],
skippedNodes: [0, 20],

View File

@@ -1,4 +1,4 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
// Boolean operators
@@ -16,6 +16,6 @@ export const highlight = styleTags({
Phrase: t.string, // "quoted string"
// Fields
'FieldName/Word': t.attributeName,
'FieldValue/Term/Word': t.attributeValue,
"FieldName/Word": t.attributeName,
"FieldValue/Term/Word": t.attributeValue,
});

View File

@@ -1,33 +1,33 @@
// query.ts
// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value.
import { fuzzyMatch } from 'fuzzbunny';
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
| { 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' };
| { 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);
@@ -37,11 +37,11 @@ export function tokenize(input: string): Tok[] {
let i = 0;
const n = input.length;
const peek = () => input[i] ?? '';
const peek = () => input[i] ?? "";
const advance = () => input[i++];
const readWord = () => {
let s = '';
let s = "";
while (i < n && isIdent(peek())) s += advance();
return s;
};
@@ -49,11 +49,11 @@ export function tokenize(input: string): Tok[] {
const readPhrase = () => {
// assumes current char is opening quote
advance(); // consume opening "
let s = '';
let s = "";
while (i < n) {
const c = advance();
if (c === `"`) break;
if (c === '\\' && i < n) {
if (c === "\\" && i < n) {
// escape \" and \\ (simple)
const next = advance();
s += next;
@@ -72,28 +72,28 @@ export function tokenize(input: string): Tok[] {
continue;
}
if (c === '(') {
toks.push({ kind: 'LPAREN' });
if (c === "(") {
toks.push({ kind: "LPAREN" });
i++;
continue;
}
if (c === ')') {
toks.push({ kind: 'RPAREN' });
if (c === ")") {
toks.push({ kind: "RPAREN" });
i++;
continue;
}
if (c === ':') {
toks.push({ kind: 'COLON' });
if (c === ":") {
toks.push({ kind: "COLON" });
i++;
continue;
}
if (c === `"`) {
const text = readPhrase();
toks.push({ kind: 'PHRASE', text });
toks.push({ kind: "PHRASE", text });
continue;
}
if (c === '-') {
toks.push({ kind: 'MINUS' });
if (c === "-") {
toks.push({ kind: "MINUS" });
i++;
continue;
}
@@ -102,10 +102,10 @@ export function tokenize(input: string): Tok[] {
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 });
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;
}
@@ -113,7 +113,7 @@ export function tokenize(input: string): Tok[] {
i++;
}
toks.push({ kind: 'EOF' });
toks.push({ kind: "EOF" });
return toks;
}
@@ -122,20 +122,20 @@ class Parser {
constructor(private toks: Tok[]) {}
private peek(): Tok {
return this.toks[this.i] ?? { kind: 'EOF' };
return this.toks[this.i] ?? { kind: "EOF" };
}
private advance(): Tok {
return this.toks[this.i++] ?? { kind: 'EOF' };
return this.toks[this.i++] ?? { kind: "EOF" };
}
private at(kind: Tok['kind']) {
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;
if (this.at("EOF")) return null;
const expr = this.parseOr();
if (!this.at('EOF')) {
if (!this.at("EOF")) {
// Optionally, consume remaining tokens or throw
}
return expr;
@@ -144,10 +144,10 @@ class Parser {
// Precedence: NOT (highest), AND, OR (lowest)
private parseOr(): Ast {
let node = this.parseAnd();
while (this.at('OR')) {
while (this.at("OR")) {
this.advance();
const rhs = this.parseAnd();
node = { type: 'Or', left: node, right: rhs };
node = { type: "Or", left: node, right: rhs };
}
return node;
}
@@ -155,31 +155,31 @@ class Parser {
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();
while (this.at("AND") || this.startsPrimary()) {
if (this.at("AND")) this.advance();
const rhs = this.parseUnary();
node = { type: 'And', left: node, right: rhs };
node = { type: "And", left: node, right: rhs };
}
return node;
}
private parseUnary(): Ast {
if (this.at('NOT') || this.at('MINUS')) {
if (this.at("NOT") || this.at("MINUS")) {
this.advance();
const node = this.parseUnary();
return { type: 'Not', node };
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';
return k === "WORD" || k === "PHRASE" || k === "LPAREN" || k === "MINUS" || k === "NOT";
}
private parsePrimaryOrField(): Ast {
// Parenthesized group
if (this.at('LPAREN')) {
if (this.at("LPAREN")) {
this.advance();
const inside = this.parseOr();
// if (!this.at('RPAREN')) throw new Error("Missing closing ')'");
@@ -188,59 +188,59 @@ class Parser {
}
// Phrase
if (this.at('PHRASE')) {
const t = this.advance() as Extract<Tok, { kind: 'PHRASE' }>;
return { type: 'Phrase', value: t.text };
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("WORD")) {
const wordTok = this.advance() as Extract<Tok, { kind: "WORD" }>;
if (this.at('COLON')) {
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' }>;
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' }>;
} 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 };
return { type: "Field", field: wordTok.text, value };
}
// plain term
return { type: 'Term', value: wordTok.text };
return { type: "Term", value: wordTok.text };
}
const w = this.advance() as Extract<Tok, { kind: 'WORD' }>;
return { type: 'Phrase', value: 'text' in w ? w.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;
if ("text" in t) return t.text;
switch (t.kind) {
case 'COLON':
return ':';
case 'LPAREN':
return '(';
case 'RPAREN':
return ')';
case "COLON":
return ":";
case "LPAREN":
return "(";
case "RPAREN":
return ")";
default:
return '';
return "";
}
}
export function parseQuery(q: string): Ast | null {
if (q.trim() === '') return null;
if (q.trim() === "") return null;
const toks = tokenize(q);
const parser = new Parser(toks);
return parser.parse();
@@ -251,45 +251,45 @@ export type Doc = {
fields?: Record<string, unknown>;
};
type Technique = 'substring' | 'fuzzy' | 'strict';
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);
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 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;
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()];
? 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':
case "Term":
return includes(text, node.value.toLowerCase(), "fuzzy");
case "Phrase":
// Quoted phrases match exactly
return includes(text, node.value.toLowerCase(), 'substring');
case 'Field': {
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'));
return vals.some((v) => includes(v, node.value.toLowerCase(), "substring"));
}
case 'Not':
case "Not":
return !evalNode(node.node);
case 'And':
case "And":
return evalNode(node.left) && evalNode(node.right);
case 'Or':
case "Or":
return evalNode(node.left) || evalNode(node.right);
}
};

View File

@@ -1,6 +1,6 @@
import type { CompletionContext } from '@codemirror/autocomplete';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import { defaultBoost } from './twig/completion';
import type { CompletionContext } from "@codemirror/autocomplete";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { defaultBoost } from "./twig/completion";
export interface GenericCompletionConfig {
minMatch?: number;

View File

@@ -1,9 +1,9 @@
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';
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;
@@ -39,26 +39,26 @@ const tooltip = hoverTooltip(
create() {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const link = text.substring(found?.start - from, found?.end - from);
const dom = document.createElement('div');
const dom = document.createElement("div");
const $open = document.createElement('a');
$open.textContent = 'Open in browser';
const $open = document.createElement("a");
$open.textContent = "Open in browser";
$open.href = link;
$open.target = '_blank';
$open.rel = 'noopener noreferrer';
$open.target = "_blank";
$open.rel = "noopener noreferrer";
const $copy = document.createElement('button');
$copy.textContent = 'Copy to clipboard';
$copy.addEventListener('click', () => {
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 () => {
const $create = document.createElement("button");
$create.textContent = "Create new request";
$create.addEventListener("click", async () => {
await createRequestAndNavigate({
model: 'http_request',
workspaceId: workspaceId ?? 'n/a',
model: "http_request",
workspaceId: workspaceId ?? "n/a",
url: link,
});
});
@@ -94,12 +94,12 @@ const decorator = () => {
const groupMatch = match[1];
if (groupMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
console.warn("Group match was empty", match);
return Decoration.replace({});
}
return Decoration.mark({
class: 'hyperlink-widget',
class: "hyperlink-widget",
});
},
});

View File

@@ -1,6 +1,6 @@
import type { Diagnostic } from '@codemirror/lint';
import type { EditorView } from '@codemirror/view';
import { parse as jsonLintParse } from '@prantlf/jsonlint';
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;
@@ -15,14 +15,14 @@ export function jsonParseLinter(options?: JsonLintOptions) {
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));
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
jsonLintParse(escapedDoc, {
mode: (options?.allowComments ?? true) ? 'cjson' : 'json',
mode: (options?.allowComments ?? true) ? "cjson" : "json",
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
});
// oxlint-disable-next-line no-explicit-any
} catch (err: any) {
if (!('location' in err)) {
if (!("location" in err)) {
return [];
}
@@ -33,7 +33,7 @@ export function jsonParseLinter(options?: JsonLintOptions) {
{
from: err.location.start.offset,
to: err.location.start.offset,
severity: 'error',
severity: "error",
message: err.message,
},
];

View File

@@ -1,8 +1,8 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './pairs';
import { LanguageSupport, LRLanguage } from "@codemirror/language";
import { parser } from "./pairs";
const language = LRLanguage.define({
name: 'pairs',
name: "pairs",
parser,
languageData: {},
});

View File

@@ -1,4 +1,4 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
Sep: t.bracket,

View File

@@ -1,12 +1,12 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from '@lezer/lr';
import { highlight } from './highlight';
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',
stateData: "f~OQPO~ORRO~OSTO~OVUO~O",
goto: "]UPPPPPVQQORSQ",
nodeNames: "⚠ pairs Key Sep Value",
maxTerm: 7,
propSources: [highlight],
skippedNodes: [0],
@@ -17,13 +17,13 @@ export const parser = LRParser.deserialize({
topRules: { pairs: [0, 1] },
tokenPrec: 0,
termNames: {
'0': '⚠',
'1': '@top',
'2': 'Key',
'3': 'Sep',
'4': 'Value',
'5': '(Key Sep Value "\\n")+',
'6': '␄',
'7': '"\\n"',
"0": "⚠",
"1": "@top",
"2": "Key",
"3": "Sep",
"4": "Value",
"5": '(Key Sep Value "\\n")+',
"6": "␄",
"7": '"\\n"',
},
});

View File

@@ -1,6 +1,6 @@
import { getSearchQuery, searchPanelOpen } from '@codemirror/search';
import type { Extension } from '@codemirror/state';
import { type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
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
@@ -41,7 +41,7 @@ export function searchMatchCount(): Extension {
if (!query.search) {
if (this.countEl) {
this.countEl.textContent = '0/0';
this.countEl.textContent = "0/0";
}
return;
}
@@ -64,7 +64,7 @@ export function searchMatchCount(): Extension {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = '0/0';
this.countEl.textContent = "0/0";
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
@@ -75,7 +75,7 @@ export function searchMatchCount(): Extension {
private ensureCountEl() {
// Find the search panel in the editor DOM
const panel = this.view.dom.querySelector('.cm-search');
const panel = this.view.dom.querySelector(".cm-search");
if (!panel) {
this.countEl = null;
return;
@@ -85,11 +85,11 @@ export function searchMatchCount(): Extension {
return; // Already attached
}
this.countEl = document.createElement('span');
this.countEl.className = 'cm-search-match-count';
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 searchInput = panel.querySelector("input");
const prevBtn = panel.querySelector('button[name="prev"]');
const nextBtn = panel.querySelector('button[name="next"]');
if (searchInput && searchInput.parentElement === panel) {

View File

@@ -1,5 +1,5 @@
import type { Extension, TransactionSpec } from '@codemirror/state';
import { EditorSelection, EditorState, Transaction } from '@codemirror/state';
import type { Extension, TransactionSpec } from "@codemirror/state";
import { EditorSelection, EditorState, Transaction } from "@codemirror/state";
/**
* A CodeMirror extension that forces single-line input by stripping
@@ -17,14 +17,14 @@ import { EditorSelection, EditorState, Transaction } from '@codemirror/state';
export function singleLineExtensions(): Extension {
return EditorState.transactionFilter.of(
(tr: Transaction): TransactionSpec | readonly TransactionSpec[] => {
if (!tr.isUserEvent('input') || tr.isUserEvent('input.type.compose')) return tr;
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 = '';
let insert = "";
for (const line of inserted.iterLines()) {
insert += line.replace(/\n/g, '');
insert += line.replace(/\n/g, "");
}
if (insert !== inserted.toString()) {

View File

@@ -1,8 +1,8 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './text';
import { LanguageSupport, LRLanguage } from "@codemirror/language";
import { parser } from "./text";
export const textLanguage = LRLanguage.define({
name: 'text',
name: "text",
parser,
languageData: {},
});

View File

@@ -1,11 +1,11 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from '@lezer/lr';
import { LRParser } from "@lezer/lr";
export const parser = LRParser.deserialize({
version: 14,
states: '[OQOPOOQOOOOO',
stateData: 'V~OQPO~O',
goto: 'QPP',
nodeNames: '⚠ Template Text',
states: "[OQOPOOQOOOOO",
stateData: "V~OQPO~O",
goto: "QPP",
nodeNames: "⚠ Template Text",
maxTerm: 3,
skippedNodes: [0],
repeatNodeCount: 0,

View File

@@ -1,8 +1,8 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './timeline';
import { LanguageSupport, LRLanguage } from "@codemirror/language";
import { parser } from "./timeline";
export const timelineLanguage = LRLanguage.define({
name: 'timeline',
name: "timeline",
parser,
languageData: {},
});

View File

@@ -1,4 +1,4 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
OutgoingText: t.propertyName, // > lines - primary color (matches timeline icons)

View File

@@ -1,6 +1,5 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Timeline = 1,
export const Timeline = 1,
OutgoingLine = 2,
OutgoingText = 3,
Newline = 4,
@@ -9,4 +8,4 @@ export const
InfoLine = 7,
InfoText = 8,
PlainLine = 9,
PlainText = 10
PlainText = 10;

View File

@@ -1,18 +1,21 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
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",
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",
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$}",
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
})
topRules: { Timeline: [0, 1] },
tokenPrec: 36,
});

View File

@@ -1,20 +1,20 @@
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
import { startCompletion } from '@codemirror/autocomplete';
import type { TemplateFunction } from '@yaakapp-internal/plugins';
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import { startCompletion } from "@codemirror/autocomplete";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
const openTag = '${[ ';
const closeTag = ' ]}';
const openTag = "${[ ";
const closeTag = " ]}";
export type TwigCompletionOptionVariable = {
type: 'variable';
type: "variable";
};
export type TwigCompletionOptionNamespace = {
type: 'namespace';
type: "namespace";
};
export type TwigCompletionOptionFunction = TemplateFunction & {
type: 'function';
type: "function";
};
export type TwigCompletionOption = (
@@ -50,17 +50,17 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
const completions: Completion[] = options
.flatMap((o): Completion[] => {
const matchSegments = toMatch.text.replace(/^\$/, '').split('.');
const optionSegments = o.name.split('.');
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('.');
const prefix = optionSegments.slice(0, matchSegments.length).join(".");
return [
{
label: `${prefix}.*`,
type: 'namespace',
detail: 'namespace',
type: "namespace",
detail: "namespace",
apply: (view, _completion, from, to) => {
const insert = `${prefix}.`;
view.dispatch({
@@ -75,13 +75,13 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
}
// If on the last segment, wrap the entire tag
const inner = o.type === 'function' ? `${o.name}()` : o.name;
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',
type: o.type === "variable" ? "variable" : "function",
apply: (view, _completion, from, to) => {
const insert = openTag + inner + closeTag;
view.dispatch({
@@ -94,7 +94,7 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
})
.filter((v) => v != null);
const uniqueCompletions = uniqueBy(completions, 'label');
const uniqueCompletions = uniqueBy(completions, "label");
const sortedCompletions = uniqueCompletions.sort((a, b) => {
const boostDiff = defaultBoost(b) - defaultBoost(a);
if (boostDiff !== 0) return boostDiff;
@@ -119,9 +119,9 @@ export function uniqueBy<T, K extends keyof T>(arr: T[], key: K): T[] {
}
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;
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

@@ -1,15 +1,15 @@
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';
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,
@@ -35,7 +35,7 @@ export function twig({
environmentVariables.map((v) => ({
name: v.variable.name,
value: v.variable.value,
type: 'variable',
type: "variable",
label: v.variable.name,
description: `Inherited from ${v.source}`,
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
@@ -74,12 +74,12 @@ function mixLanguage(base: LanguageSupport): LRLanguage {
return {
parser: base.language.parser,
overlay: (node) => node.type.name === 'Text',
overlay: (node) => node.type.name === "Text",
};
}),
});
const language = LRLanguage.define({ name: 'twig', parser });
const language = LRLanguage.define({ name: "twig", parser });
mixedLanguagesCache[base.language.name] = language;
return language;
}

View File

@@ -1,4 +1,4 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
TagOpen: t.bracket,

View File

@@ -1,7 +1,7 @@
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 { 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;
@@ -22,15 +22,15 @@ class PathPlaceholderWidget extends WidgetType {
}
toDOM() {
const elt = document.createElement('span');
elt.className = 'x-theme-templateTag x-theme-templateTag--secondary template-tag';
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);
elt.addEventListener("click", this.#clickListenerCallback);
return elt;
}
destroy(dom: HTMLElement) {
dom.removeEventListener('click', this.#clickListenerCallback);
dom.removeEventListener("click", this.#clickListenerCallback);
super.destroy(dom);
}
@@ -50,14 +50,14 @@ function pathParameters(
from,
to,
enter(node) {
if (node.name === 'Text') {
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') {
if (innerTree.node.name === "url") {
innerTree.toTree().iterate({
enter(node) {
if (node.name !== 'Placeholder') return;
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);

View File

@@ -1,13 +1,13 @@
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';
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;
@@ -34,24 +34,24 @@ class TemplateTagWidget extends WidgetType {
}
toDOM() {
const elt = document.createElement('span');
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'
? "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;
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);
elt.addEventListener("click", this.#clickListenerCallback);
return elt;
}
destroy(dom: HTMLElement) {
dom.removeEventListener('click', this.#clickListenerCallback);
dom.removeEventListener("click", this.#clickListenerCallback);
super.destroy(dom);
}
@@ -72,34 +72,34 @@ function templateTags(
from,
to,
enter(node) {
if (node.name === 'Tag') {
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*]}$/, '');
const inner = rawTag.replace(/^\$\{\[\s*/, "").replace(/\s*]}$/, "");
let name = inner.match(/([\w.]+)[(]/)?.[1] ?? inner;
if (inner.includes('\n')) {
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';
if (name === "Response") {
name = "response";
}
let option = options.find(
(o) => o.name === name || (o.type === 'function' && o.aliases?.includes(name)),
(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',
type: "variable",
invalid: true,
name: inner,
value: null,
@@ -110,7 +110,7 @@ function templateTags(
};
}
if (option.type === 'function') {
if (option.type === "function") {
const tokens = parseTemplate(rawTag);
const rawValues = collectArgumentValues(tokens, option);
const values = applyFormInputDefaults(option.args, rawValues);
@@ -175,49 +175,49 @@ function makeFunctionLabel(
): 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 = '(';
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 = '';
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] || '');
const v = String(values[name] || "");
if (!v) return;
if (all.length > 1) {
const $c = document.createElement('span');
$c.className = 'fn-arg-name';
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;
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;
if (!("name" in a)) return;
const v = values[a.name];
if (v == null) return;
if (i > 0) $inner.title += '\n';
if (i > 0) $inner.title += "\n";
$inner.title += `${a.name} = ${JSON.stringify(v)}`;
});
if ($inner.childNodes.length === 0) {
$inner.appendChild(document.createTextNode('…'));
$inner.appendChild(document.createTextNode("…"));
}
$outer.appendChild($inner);
const $bClose = document.createElement('span');
$bClose.className = 'fn-bracket';
$bClose.textContent = ')';
const $bClose = document.createElement("span");
$bClose.className = "fn-bracket";
$bClose.textContent = ")";
$outer.appendChild($bClose);
return $outer;

View File

@@ -1,14 +1,14 @@
/* oxlint-disable no-template-curly-in-string */
import { describe, expect, test } from 'vite-plus/test';
import { parser } from './twig';
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') {
if (cursor.name !== "Template") {
nodes.push(cursor.name);
}
} while (cursor.next());
@@ -16,93 +16,93 @@ function getNodeNames(input: string): string[] {
}
function hasTag(input: string): boolean {
return getNodeNames(input).includes('Tag');
return getNodeNames(input).includes("Tag");
}
function hasError(input: string): boolean {
return getNodeNames(input).includes('⚠');
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);
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 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 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);
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);
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 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', () => {
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);
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]}';
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', () => {
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);
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', () => {
test("handles ${ at end of string without crash", () => {
// Incomplete syntax may produce errors, but should not crash
expect(() => parser.parse('hello${')).not.toThrow();
expect(() => parser.parse("hello${")).not.toThrow();
});
test('handles ${[ without closing without crash', () => {
test("handles ${[ without closing without crash", () => {
// Unclosed tag may produce partial match, but should not crash
expect(() => parser.parse('${[unclosed')).not.toThrow();
expect(() => parser.parse("${[unclosed")).not.toThrow();
});
test('handles empty ${[]}', () => {
test("handles empty ${[]}", () => {
// Empty tags may or may not be valid depending on grammar
// Just ensure no crash
expect(() => parser.parse('${[]}')).not.toThrow();
expect(() => parser.parse("${[]}")).not.toThrow();
});
});
});

View File

@@ -1,20 +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';
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',
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)],
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
topRules: { Template: [0, 1] },
tokenPrec: 0,
});

View File

@@ -1,5 +1,5 @@
import type { FormInput, TemplateFunction } from '@yaakapp-internal/plugins';
import type { Tokens } from '@yaakapp-internal/templates';
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
@@ -8,21 +8,21 @@ import type { Tokens } from '@yaakapp-internal/templates';
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]?.type === "tag" && initialTokens.tokens[0]?.val.type === "fn"
? initialTokens.tokens[0]?.val.args
: [];
const processArg = (arg: FormInput) => {
if ('inputs' in arg && arg.inputs) {
if ("inputs" in arg && arg.inputs) {
arg.inputs.forEach(processArg);
}
if (!('name' in arg)) return;
if (!("name" in arg)) return;
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue =
initialArg?.value.type === 'str'
initialArg?.value.type === "str"
? initialArg?.value.text
: initialArg?.value.type === 'bool'
: initialArg?.value.type === "bool"
? initialArg.value.value
: undefined;
const value = initialArgValue ?? arg.defaultValue;

View File

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

View File

@@ -1,8 +1,8 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './url';
import { LanguageSupport, LRLanguage } from "@codemirror/language";
import { parser } from "./url";
const urlLanguage = LRLanguage.define({
name: 'url',
name: "url",
parser,
languageData: {},
});

View File

@@ -1,4 +1,4 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
Protocol: t.comment,

View File

@@ -1,13 +1,13 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from '@lezer/lr';
import { highlight } from './highlight';
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',
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],

View File

@@ -1,17 +1,17 @@
import type { Virtualizer } from '@tanstack/react-virtual';
import { format } from 'date-fns';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useEventViewerKeyboard } from '../../hooks/useEventViewerKeyboard';
import { CopyIconButton } from '../CopyIconButton';
import { AutoScroller } from './AutoScroller';
import { Banner } from './Banner';
import { Button } from './Button';
import { Separator } from './Separator';
import { SplitLayout } from './SplitLayout';
import { HStack } from './Stacks';
import { IconButton } from './IconButton';
import classNames from 'classnames';
import type { Virtualizer } from "@tanstack/react-virtual";
import { format } from "date-fns";
import type { ReactNode } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useEventViewerKeyboard } from "../../hooks/useEventViewerKeyboard";
import { CopyIconButton } from "../CopyIconButton";
import { AutoScroller } from "./AutoScroller";
import { Banner } from "./Banner";
import { Button } from "./Button";
import { Separator } from "./Separator";
import { SplitLayout } from "./SplitLayout";
import { HStack } from "./Stacks";
import { IconButton } from "./IconButton";
import classNames from "classnames";
interface EventViewerProps<T> {
/** Array of events to display */
@@ -70,8 +70,8 @@ export function EventViewer<T>({
defaultRatio = 0.4,
enableKeyboardNav = true,
isLoading = false,
loadingMessage = 'Loading events...',
emptyMessage = 'No events recorded',
loadingMessage = "Loading events...",
emptyMessage = "No events recorded",
onActiveIndexChange,
}: EventViewerProps<T>) {
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
@@ -82,7 +82,7 @@ export function EventViewer<T>({
(indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {
setActiveIndexInternal((prev) => {
const newIndex =
typeof indexOrUpdater === 'function' ? indexOrUpdater(prev) : indexOrUpdater;
typeof indexOrUpdater === "function" ? indexOrUpdater(prev) : indexOrUpdater;
onActiveIndexChange?.(newIndex);
return newIndex;
});
@@ -129,7 +129,7 @@ export function EventViewer<T>({
setIsPanelOpen(true);
// Scroll to ensure selected item is visible after panel opens
requestAnimationFrame(() => {
virtualizerRef.current?.scrollToIndex(index, { align: 'auto' });
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
});
},
[setActiveIndex],
@@ -189,7 +189,11 @@ export function EventViewer<T>({
<Separator />
</div>
<div className="mx-2 overflow-y-auto">
{renderDetail({ event: activeEvent, index: activeIndex ?? 0, onClose: handleClose })}
{renderDetail({
event: activeEvent,
index: activeIndex ?? 0,
onClose: handleClose,
})}
</div>
</div>
)
@@ -228,7 +232,7 @@ export function EventDetailHeader({
copyText,
onClose,
}: EventDetailHeaderProps) {
const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), 'HH:mm:ss.SSS') : null;
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">
@@ -249,8 +253,21 @@ export function EventDetailHeader({
{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
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

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import { format } from 'date-fns';
import type { ReactNode } from 'react';
import classNames from "classnames";
import { format } from "date-fns";
import type { ReactNode } from "react";
interface EventViewerRowProps {
isActive: boolean;
@@ -23,15 +23,15 @@ export function EventViewerRow({
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',
"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>}
{timestamp && <div className="opacity-50">{format(`${timestamp}Z`, "HH:mm:ss.SSS")}</div>}
</button>
</div>
);

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
children: ReactNode;
@@ -11,10 +11,10 @@ export function FormattedError({ children, className }: Props) {
<pre
className={classNames(
className,
'cursor-text select-auto',
'[&_*]:cursor-text [&_*]:select-auto',
'font-mono text-sm w-full bg-surface-highlight p-3 rounded',
'whitespace-pre-wrap border border-danger border-dashed overflow-x-auto',
"cursor-text select-auto",
"[&_*]:cursor-text [&_*]:select-auto",
"font-mono text-sm w-full bg-surface-highlight p-3 rounded",
"whitespace-pre-wrap border border-danger border-dashed overflow-x-auto",
)}
>
{children}

View File

@@ -1,20 +1,20 @@
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import classNames from "classnames";
import type { HTMLAttributes } from "react";
interface Props extends HTMLAttributes<HTMLHeadingElement> {
level?: 1 | 2 | 3;
}
export function Heading({ className, level = 1, ...props }: Props) {
const Component = level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3';
const Component = level === 1 ? "h1" : level === 2 ? "h2" : "h3";
return (
<Component
className={classNames(
className,
'font-semibold text-text',
level === 1 && 'text-2xl',
level === 2 && 'text-xl',
level === 3 && 'text-lg',
"font-semibold text-text",
level === 1 && "text-2xl",
level === 2 && "text-xl",
level === 3 && "text-lg",
)}
{...props}
/>

View File

@@ -1,12 +1,12 @@
import classNames from 'classnames';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey } from '../../hooks/useHotKey';
import { HStack } from './Stacks';
import classNames from "classnames";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useFormattedHotkey } from "../../hooks/useHotKey";
import { HStack } from "./Stacks";
interface Props {
action: HotkeyAction | null;
className?: string;
variant?: 'text' | 'with-bg';
variant?: "text" | "with-bg";
}
export function Hotkey({ action, className, variant }: Props) {
@@ -21,7 +21,7 @@ export function Hotkey({ action, className, variant }: Props) {
interface HotkeyRawProps {
labelParts: string[];
className?: string;
variant?: 'text' | 'with-bg';
variant?: "text" | "with-bg";
}
export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
@@ -29,9 +29,9 @@ export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
<HStack
className={classNames(
className,
variant === 'with-bg' &&
'rounded bg-surface-highlight px-1 border border-border text-text-subtle',
variant === 'text' && 'text-text-subtlest',
variant === "with-bg" &&
"rounded bg-surface-highlight px-1 border border-border text-text-subtle",
variant === "text" && "text-text-subtlest",
)}
>
{labelParts.map((char, index) => (

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useHotkeyLabel } from '../../hooks/useHotKey';
import classNames from "classnames";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useHotkeyLabel } from "../../hooks/useHotKey";
interface Props {
action: HotkeyAction;
@@ -10,6 +10,6 @@ interface Props {
export function HotkeyLabel({ action, className }: Props) {
const label = useHotkeyLabel(action);
return (
<span className={classNames(className, 'text-text-subtle whitespace-nowrap')}>{label}</span>
<span className={classNames(className, "text-text-subtle whitespace-nowrap")}>{label}</span>
);
}

View File

@@ -1,9 +1,9 @@
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';
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[];
@@ -13,7 +13,7 @@ interface Props {
export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
return (
<div className={classNames(className, 'h-full flex items-center justify-center')}>
<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}>

View File

@@ -1,8 +1,8 @@
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';
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;
@@ -12,27 +12,32 @@ interface Props {
}
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',
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) {
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.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} />;
@@ -52,7 +57,7 @@ export function HttpMethodTagRaw({
let label = method.toUpperCase();
if (short) {
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
label = label.padStart(4, ' ');
label = label.padStart(4, " ");
}
const m = method.toUpperCase();
@@ -64,20 +69,20 @@ export function HttpMethodTagRaw({
<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
!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}

View File

@@ -1,5 +1,5 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useEffect, useRef, useState } from 'react';
import type { HttpResponse } from "@yaakapp-internal/models";
import { useEffect, useRef, useState } from "react";
interface Props {
response: HttpResponse;
@@ -12,17 +12,17 @@ export function HttpResponseDurationTag({ response }: Props) {
// Calculate the duration of the response for use when the response hasn't finished yet
useEffect(() => {
clearInterval(timeout.current);
if (response.state === 'closed') return;
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 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;
const elapsed = response.state === "closed" ? response.elapsed : fallbackElapsed;
return (
<span className="font-mono" title={title}>

View File

@@ -1,5 +1,5 @@
import type { HttpResponse, HttpResponseState } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { HttpResponse, HttpResponseState } from "@yaakapp-internal/models";
import classNames from "classnames";
interface Props {
response: HttpResponse;
@@ -20,35 +20,35 @@ export function HttpStatusTagRaw({
showReason,
statusReason,
short,
}: Omit<Props, 'response'> & {
}: 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);
const statusN = typeof status === "number" ? status : parseInt(status, 10);
if (state === 'initialized') {
label = short ? 'CONN' : 'CONNECTING';
colorClass = 'text-text-subtle';
if (state === "initialized") {
label = short ? "CONN" : "CONNECTING";
colorClass = "text-text-subtle";
} else if (statusN < 100) {
label = short ? 'ERR' : 'ERROR';
colorClass = 'text-danger';
label = short ? "ERR" : "ERROR";
colorClass = "text-danger";
} else if (statusN < 200) {
colorClass = 'text-info';
colorClass = "text-info";
} else if (statusN < 300) {
colorClass = 'text-success';
colorClass = "text-success";
} else if (statusN < 400) {
colorClass = 'text-primary';
colorClass = "text-primary";
} else if (statusN < 500) {
colorClass = 'text-warning';
colorClass = "text-warning";
} else {
colorClass = 'text-danger';
colorClass = "text-danger";
}
return (
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
<span className={classNames(className, "font-mono min-w-0", colorClass)}>
{label} {showReason && statusReason}
</span>
);

View File

@@ -1,5 +1,5 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import {
AlarmClockIcon,
AlertTriangleIcon,
@@ -134,9 +134,9 @@ import {
WifiIcon,
WrenchIcon,
XIcon,
} from 'lucide-react';
import type { CSSProperties, HTMLAttributes } from 'react';
import { memo } from 'react';
} from "lucide-react";
import type { CSSProperties, HTMLAttributes } from "react";
import { memo } from "react";
const icons = {
alarm_clock: AlarmClockIcon,
@@ -281,17 +281,17 @@ export interface IconProps {
icon: keyof typeof icons;
className?: string;
style?: CSSProperties;
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
size?: "2xs" | "xs" | "sm" | "md" | "lg" | "xl";
spin?: boolean;
title?: string;
color?: Color | 'custom' | 'default';
color?: Color | "custom" | "default";
}
export const Icon = memo(function Icon({
icon,
color = 'default',
color = "default",
spin,
size = 'md',
size = "md",
style,
className,
title,
@@ -303,23 +303,23 @@ export const Icon = memo(function Icon({
title={title}
className={classNames(
className,
!spin && 'transform-gpu',
spin && 'animate-spin',
'flex-shrink-0',
size === 'xl' && 'h-6 w-6',
size === 'lg' && 'h-5 w-5',
size === 'md' && 'h-4 w-4',
size === 'sm' && 'h-3.5 w-3.5',
size === 'xs' && 'h-3 w-3',
size === '2xs' && 'h-2.5 w-2.5',
color === 'default' && 'inherit',
color === 'danger' && 'text-danger',
color === 'warning' && 'text-warning',
color === 'notice' && 'text-notice',
color === 'info' && 'text-info',
color === 'success' && 'text-success',
color === 'primary' && 'text-primary',
color === 'secondary' && 'text-secondary',
!spin && "transform-gpu",
spin && "animate-spin",
"flex-shrink-0",
size === "xl" && "h-6 w-6",
size === "lg" && "h-5 w-5",
size === "md" && "h-4 w-4",
size === "sm" && "h-3.5 w-3.5",
size === "xs" && "h-3 w-3",
size === "2xs" && "h-2.5 w-2.5",
color === "default" && "inherit",
color === "danger" && "text-danger",
color === "warning" && "text-warning",
color === "notice" && "text-notice",
color === "info" && "text-info",
color === "success" && "text-success",
color === "primary" && "text-primary",
color === "secondary" && "text-secondary",
)}
/>
);

View File

@@ -1,19 +1,19 @@
import classNames from 'classnames';
import type { MouseEvent } from 'react';
import { forwardRef, useCallback } from 'react';
import { useTimedBoolean } from '../../hooks/useTimedBoolean';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import { LoadingIcon } from './LoadingIcon';
import classNames from "classnames";
import type { MouseEvent } from "react";
import { forwardRef, useCallback } from "react";
import { useTimedBoolean } from "../../hooks/useTimedBoolean";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
import type { IconProps } from "./Icon";
import { Icon } from "./Icon";
import { LoadingIcon } from "./LoadingIcon";
export type IconButtonProps = IconProps &
ButtonProps & {
showConfirm?: boolean;
iconClassName?: string;
iconSize?: IconProps['size'];
iconColor?: IconProps['color'];
iconSize?: IconProps["size"];
iconColor?: IconProps["color"];
title: string;
showBadge?: boolean;
};
@@ -22,18 +22,18 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
{
showConfirm,
icon,
color = 'default',
color = "default",
spin,
onClick,
className,
iconClassName,
tabIndex,
size = 'md',
size = "md",
iconSize,
showBadge,
iconColor,
isLoading,
type = 'button',
type = "button",
...props
}: IconButtonProps,
ref,
@@ -50,9 +50,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
return (
<Button
ref={ref}
aria-hidden={icon === 'empty'}
disabled={icon === 'empty'}
tabIndex={(tabIndex ?? icon === 'empty') ? -1 : undefined}
aria-hidden={icon === "empty"}
disabled={icon === "empty"}
tabIndex={(tabIndex ?? icon === "empty") ? -1 : undefined}
onClick={handleClick}
innerClassName="flex items-center justify-center"
size={size}
@@ -60,12 +60,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
type={type}
className={classNames(
className,
'group/button relative flex-shrink-0',
'!px-0',
size === 'md' && 'w-md',
size === 'sm' && 'w-sm',
size === 'xs' && 'w-xs',
size === '2xs' && 'w-5',
"group/button relative flex-shrink-0",
"!px-0",
size === "md" && "w-md",
size === "sm" && "w-sm",
size === "xs" && "w-xs",
size === "2xs" && "w-5",
)}
{...props}
>
@@ -79,14 +79,14 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
) : (
<Icon
size={iconSize}
icon={confirmed ? 'check' : icon}
icon={confirmed ? "check" : icon}
spin={spin}
color={iconColor}
className={classNames(
iconClassName,
'group-hover/button:text-text',
confirmed && '!text-success', // Don't use Icon.color here because it won't override the hover color
props.disabled && 'opacity-70',
"group-hover/button:text-text",
confirmed && "!text-success", // Don't use Icon.color here because it won't override the hover color
props.disabled && "opacity-70",
)}
/>
)}

View File

@@ -1,19 +1,19 @@
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import type { TooltipProps } from './Tooltip';
import { Tooltip } from './Tooltip';
import type { IconProps } from "./Icon";
import { Icon } from "./Icon";
import type { TooltipProps } from "./Tooltip";
import { Tooltip } from "./Tooltip";
type Props = Omit<TooltipProps, 'children'> & {
icon?: IconProps['icon'];
iconSize?: IconProps['size'];
iconColor?: IconProps['color'];
type Props = Omit<TooltipProps, "children"> & {
icon?: IconProps["icon"];
iconSize?: IconProps["size"];
iconColor?: IconProps["color"];
className?: string;
tabIndex?: number;
};
export function IconTooltip({
content,
icon = 'info',
icon = "info",
iconColor,
iconSize,
...tooltipProps

View File

@@ -1,13 +1,13 @@
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import classNames from "classnames";
import type { HTMLAttributes } from "react";
export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return (
<code
className={classNames(
className,
'font-mono text-shrink bg-surface-highlight border border-border-subtle flex-grow-0',
'px-1.5 py-0.5 rounded text shadow-inner break-words',
"font-mono text-shrink bg-surface-highlight border border-border-subtle flex-grow-0",
"px-1.5 py-0.5 rounded text shadow-inner break-words",
)}
{...props}
/>

View File

@@ -1,47 +1,47 @@
import type { EditorView } from '@codemirror/view';
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createFastMutation } from '../../hooks/useFastMutation';
import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { copyToClipboard } from '../../lib/copy';
import type { EditorView } from "@codemirror/view";
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createFastMutation } from "../../hooks/useFastMutation";
import { useIsEncryptionEnabled } from "../../hooks/useIsEncryptionEnabled";
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { copyToClipboard } from "../../lib/copy";
import {
analyzeTemplate,
convertTemplateToInsecure,
convertTemplateToSecure,
} from '../../lib/encryption';
import { generateId } from '../../lib/generateId';
} from "../../lib/encryption";
import { generateId } from "../../lib/generateId";
import {
setupOrConfigureEncryption,
withEncryptionEnabled,
} from '../../lib/setupOrConfigureEncryption';
import { Button } from './Button';
import type { DropdownItem } from './Dropdown';
import { Dropdown } from './Dropdown';
import type { EditorProps } from './Editor/Editor';
import { Editor } from './Editor/LazyEditor';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { IconTooltip } from './IconTooltip';
import { Label } from './Label';
import { HStack } from './Stacks';
} from "../../lib/setupOrConfigureEncryption";
import { Button } from "./Button";
import type { DropdownItem } from "./Dropdown";
import { Dropdown } from "./Dropdown";
import type { EditorProps } from "./Editor/Editor";
import { Editor } from "./Editor/LazyEditor";
import type { IconProps } from "./Icon";
import { Icon } from "./Icon";
import { IconButton } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label";
import { HStack } from "./Stacks";
export type InputProps = Pick<
EditorProps,
| 'language'
| 'autocomplete'
| 'forcedEnvironmentId'
| 'forceUpdateKey'
| 'disabled'
| 'autoFocus'
| 'autoSelect'
| 'autocompleteVariables'
| 'autocompleteFunctions'
| 'onKeyDown'
| 'readOnly'
| "language"
| "autocomplete"
| "forcedEnvironmentId"
| "forceUpdateKey"
| "disabled"
| "autoFocus"
| "autoSelect"
| "autocompleteVariables"
| "autocompleteFunctions"
| "onKeyDown"
| "readOnly"
> & {
className?: string;
containerClassName?: string;
@@ -53,7 +53,7 @@ export type InputProps = Pick<
help?: ReactNode;
label: ReactNode;
labelClassName?: string;
labelPosition?: 'top' | 'left';
labelPosition?: "top" | "left";
leftSlot?: ReactNode;
multiLine?: boolean;
name?: string;
@@ -61,15 +61,15 @@ export type InputProps = Pick<
onChange?: (value: string) => void;
onFocus?: () => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: EditorProps['onPasteOverwrite'];
onPasteOverwrite?: EditorProps["onPasteOverwrite"];
placeholder?: string;
required?: boolean;
rightSlot?: ReactNode;
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
stateKey: EditorProps['stateKey'];
extraExtensions?: EditorProps['extraExtensions'];
size?: "2xs" | "xs" | "sm" | "md" | "auto";
stateKey: EditorProps["stateKey"];
extraExtensions?: EditorProps["extraExtensions"];
tint?: Color;
type?: 'text' | 'password';
type?: "text" | "password";
validate?: boolean | ((v: string) => boolean);
wrapLines?: boolean;
setRef?: (h: InputHandle | null) => void;
@@ -80,13 +80,13 @@ export interface InputHandle {
isFocused: () => boolean;
value: () => string;
selectAll: () => void;
dispatch: EditorView['dispatch'];
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) {
if (type === "password" && props.autocompleteFunctions) {
return <EncryptionInput {...props} />;
}
return <BaseInput type={type} {...props} />;
@@ -105,7 +105,7 @@ function BaseInput({
inputWrapperClassName,
label,
labelClassName,
labelPosition = 'top',
labelPosition = "top",
leftSlot,
multiLine,
onBlur,
@@ -117,17 +117,17 @@ function BaseInput({
readOnly,
required,
rightSlot,
size = 'md',
size = "md",
stateKey,
tint,
type = 'text',
type = "text",
validate,
wrapLines,
setRef,
...props
}: InputProps) {
const [focused, setFocused] = useState(false);
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
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);
@@ -142,7 +142,7 @@ function BaseInput({
editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true });
},
isFocused: () => editorRef.current?.hasFocus ?? false,
value: () => editorRef.current?.state.doc.toString() ?? '',
value: () => editorRef.current?.state.doc.toString() ?? "",
dispatch: (...args) => {
// oxlint-disable-next-line no-explicit-any
editorRef.current?.dispatch(...(args as any));
@@ -170,9 +170,9 @@ function BaseInput({
const fn = () => {
skipNextFocus.current = true;
};
window.addEventListener('focus', fn);
window.addEventListener("focus", fn);
return () => {
window.removeEventListener('focus', fn);
window.removeEventListener("focus", fn);
};
}, []);
@@ -203,13 +203,13 @@ function BaseInput({
const id = useRef(`input-${generateId()}`);
const editorClassName = classNames(
className,
'!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder',
"!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;
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]);
@@ -226,12 +226,12 @@ function BaseInput({
// Submit the nearest form on Enter key press
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
if (e.key !== "Enter") return;
const form = wrapperRef.current?.closest('form');
const form = wrapperRef.current?.closest("form");
if (!isValid || form == null) return;
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
form?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true }));
},
[isValid],
);
@@ -240,11 +240,11 @@ function BaseInput({
<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',
"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
@@ -260,31 +260,31 @@ function BaseInput({
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',
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',
"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",
)}
/>
)}
@@ -292,10 +292,10 @@ function BaseInput({
<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,
"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
@@ -308,7 +308,7 @@ function BaseInput({
wrapLines={wrapLines}
heightMode="auto"
onKeyDown={handleKeyDown}
type={type === 'password' && !obscured ? 'text' : type}
type={type === "password" && !obscured ? "text" : type}
defaultValue={defaultValue}
forceUpdateKey={forceUpdateKey}
placeholder={placeholder}
@@ -318,8 +318,8 @@ function BaseInput({
disabled={disabled}
className={classNames(
editorClassName,
multiLine && size === 'md' && 'py-1.5',
multiLine && size === 'sm' && 'py-1',
multiLine && size === "md" && "py-1.5",
multiLine && size === "sm" && "py-1",
)}
onFocus={handleFocus}
onBlur={handleBlur}
@@ -327,11 +327,15 @@ function BaseInput({
{...props}
/>
</HStack>
{type === 'password' && !disableObscureToggle && (
{type === "password" && !disableObscureToggle && (
<IconButton
title={obscured ? `Show ${typeof label === 'string' ? label : 'field'}` : `Obscure ${typeof label === 'string' ? label : 'field'}`}
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')}
className={classNames("mr-0.5 !h-auto my-0.5", disabled && "opacity-disabled")}
color={tint}
// iconClassName={classNames(
// tint === 'primary' && 'text-primary',
@@ -343,7 +347,7 @@ function BaseInput({
// tint === 'danger' && 'text-danger',
// )}
iconSize="sm"
icon={obscured ? 'eye' : 'eye_closed'}
icon={obscured ? "eye" : "eye_closed"}
onClick={() => setObscured((o) => !o)}
/>
)}
@@ -357,7 +361,7 @@ function validateRequire(v: string) {
return v.length > 0;
}
type PasswordFieldType = 'text' | 'encrypted';
type PasswordFieldType = "text" | "encrypted";
function EncryptionInput({
defaultValue,
@@ -377,7 +381,7 @@ function EncryptionInput({
error: string | null;
}>(
{
fieldType: isEncryptionEnabled ? 'encrypted' : 'text',
fieldType: isEncryptionEnabled ? "encrypted" : "text",
value: null,
security: null,
obscured: true,
@@ -395,19 +399,19 @@ function EncryptionInput({
return;
}
const security = analyzeTemplate(defaultValue ?? '');
if (analyzeTemplate(defaultValue ?? '') === 'global_secured') {
const security = analyzeTemplate(defaultValue ?? "");
if (analyzeTemplate(defaultValue ?? "") === "global_secured") {
// Lazily update value to decrypted representation
templateToInsecure.mutate(defaultValue ?? '', {
templateToInsecure.mutate(defaultValue ?? "", {
onSuccess: (value) => {
setState({ fieldType: 'encrypted', security, value, obscured: true, error: null });
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',
fieldType: "encrypted",
security,
value: null,
error: String(value),
@@ -417,14 +421,14 @@ function EncryptionInput({
});
} else if (isEncryptionEnabled && !defaultValue) {
// Default to encrypted field for new encrypted inputs
setState({ fieldType: 'encrypted', security, value: '', obscured: true, error: null });
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',
fieldType: "text",
security,
value: defaultValue ?? '',
value: defaultValue ?? "",
obscured: false,
error: null,
});
@@ -432,9 +436,9 @@ function EncryptionInput({
} else {
// Don't obscure plain text when encryption is disabled
setState({
fieldType: 'text',
fieldType: "text",
security,
value: defaultValue ?? '',
value: defaultValue ?? "",
obscured: true,
error: null,
});
@@ -444,16 +448,16 @@ function EncryptionInput({
const handleChange = useCallback(
(value: string, fieldType: PasswordFieldType) => {
if (fieldType === 'encrypted') {
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);
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';
const obscured = fieldType === s.fieldType ? s.obscured : fieldType !== "text";
return { fieldType, value, security, obscured, error: s.error };
});
},
@@ -491,22 +495,22 @@ function EncryptionInput({
const dropdownItems = useMemo<DropdownItem[]>(
() => [
{
label: state.obscured ? 'Show' : 'Hide',
disabled: isEncryptionEnabled && state.fieldType === 'text',
leftSlot: <Icon icon={state.obscured ? 'eye' : 'eye_closed'} />,
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',
label: "Copy",
leftSlot: <Icon icon="copy" />,
hidden: !state.value,
onSelect: () => copyToClipboard(state.value ?? ''),
onSelect: () => copyToClipboard(state.value ?? ""),
},
{ type: 'separator' },
{ 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'),
label: state.fieldType === "text" ? "Encrypt Field" : "Decrypt Field",
leftSlot: <Icon icon={state.fieldType === "text" ? "lock" : "lock_open"} />,
onSelect: () => handleFieldTypeChange(state.fieldType === "text" ? "encrypted" : "text"),
},
],
[
@@ -519,23 +523,23 @@ function EncryptionInput({
],
);
let tint: InputProps['tint'];
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';
} 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'];
let icon: IconProps["icon"];
if (isEncryptionEnabled) {
icon = state.security === 'insecure' ? 'shield_off' : 'shield_check';
icon = state.security === "insecure" ? "shield_off" : "shield_check";
} else {
icon = state.obscured ? 'eye_closed' : 'eye';
icon = state.obscured ? "eye_closed" : "eye";
}
return (
<HStack className="h-auto m-0.5">
@@ -546,9 +550,9 @@ function EncryptionInput({
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',
"flex items-center justify-center !h-full !px-1",
"opacity-70", // Makes it a bit subtler
props.disabled && "!opacity-disabled",
)}
>
<HStack space={0.5}>
@@ -561,7 +565,7 @@ function EncryptionInput({
);
}, [dropdownItems, isEncryptionEnabled, props.disabled, state.obscured, state.security, tint]);
const type = state.obscured ? 'password' : 'text';
const type = state.obscured ? "password" : "text";
if (state.error) {
return (
@@ -575,7 +579,7 @@ function EncryptionInput({
setupOrConfigureEncryption();
}}
>
{state.error.replace(/^Render Error: /i, '')}
{state.error.replace(/^Render Error: /i, "")}
</Button>
);
}
@@ -586,7 +590,7 @@ function EncryptionInput({
disableObscureToggle
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
defaultValue={state.value ?? ''}
defaultValue={state.value ?? ""}
forceUpdateKey={forceUpdateKey}
onChange={handleInputChange}
tint={tint}
@@ -600,12 +604,12 @@ function EncryptionInput({
}
const templateToSecure = createFastMutation({
mutationKey: ['template-to-secure'],
mutationKey: ["template-to-secure"],
mutationFn: convertTemplateToSecure,
});
const templateToInsecure = createFastMutation({
mutationKey: ['template-to-insecure'],
mutationKey: ["template-to-insecure"],
mutationFn: convertTemplateToInsecure,
disableToastError: true,
});

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { Icon } from './Icon';
import classNames from "classnames";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { Icon } from "./Icon";
interface Props {
depth?: number;
@@ -31,7 +31,7 @@ export const JsonAttributeTree = ({
labelClassName?: string;
}>(() => {
const jsonType = Object.prototype.toString.call(attrValue);
if (jsonType === '[object Object]') {
if (jsonType === "[object Object]") {
return {
children: isExpanded
? Object.keys(attrValue)
@@ -47,11 +47,11 @@ export const JsonAttributeTree = ({
))
: null,
isExpandable: Object.keys(attrValue).length > 0,
label: isExpanded ? `{${Object.keys(attrValue).length || ' '}}` : '{⋯}',
labelClassName: 'text-text-subtlest',
label: isExpanded ? `{${Object.keys(attrValue).length || " "}}` : "{⋯}",
labelClassName: "text-text-subtlest",
};
}
if (jsonType === '[object Array]') {
if (jsonType === "[object Array]") {
return {
children: isExpanded
? // oxlint-disable-next-line no-explicit-any
@@ -67,26 +67,26 @@ export const JsonAttributeTree = ({
))
: null,
isExpandable: attrValue.length > 0,
label: isExpanded ? `[${attrValue.length || ' '}]` : '[⋯]',
labelClassName: 'text-text-subtlest',
label: isExpanded ? `[${attrValue.length || " "}]` : "[⋯]",
labelClassName: "text-text-subtlest",
};
}
return {
children: null,
isExpandable: false,
label: jsonType === '[object String]' ? `"${attrValue}"` : `${attrValue}`,
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',
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')}
className={classNames(labelClassName, "cursor-text select-text group-hover:text-text-subtle")}
>
{label}
</span>
@@ -95,8 +95,8 @@ export const JsonAttributeTree = ({
<div
className={classNames(
className,
/*depth === 0 && '-ml-4',*/ 'font-mono text-xs',
depth === 0 && 'h-full overflow-y-auto pb-2',
/*depth === 0 && '-ml-4',*/ "font-mono text-xs",
depth === 0 && "h-full overflow-y-auto pb-2",
)}
>
<div className="flex items-center">
@@ -110,13 +110,13 @@ export const JsonAttributeTree = ({
size="xs"
icon="chevron_right"
className={classNames(
'left-0 absolute transition-transform flex items-center',
'group-hover:text-text-subtle',
isExpanded ? 'rotate-90' : '',
"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}:
{attrKey === undefined ? "$" : attrKey}:
</span>
{labelEl}
</button>
@@ -142,5 +142,5 @@ function joinObjectKey(baseKey: string | undefined, key: string): string {
}
function joinArrayKey(baseKey: string | undefined, index: number): string {
return `${baseKey ?? ''}[${index}]`;
return `${baseKey ?? ""}[${index}]`;
}

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import type { HTMLAttributes, ReactElement, ReactNode } from 'react';
import classNames from "classnames";
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
interface Props {
children:
@@ -27,7 +27,7 @@ interface KeyValueRowProps {
rightSlot?: ReactNode;
leftSlot?: ReactNode;
labelClassName?: string;
labelColor?: 'secondary' | 'primary' | 'info';
labelColor?: "secondary" | "primary" | "info";
}
export function KeyValueRow({
@@ -35,18 +35,18 @@ export function KeyValueRow({
children,
rightSlot,
leftSlot,
labelColor = 'secondary',
labelColor = "secondary",
labelClassName,
}: KeyValueRowProps) {
return (
<>
<td
className={classNames(
'select-none py-0.5 pr-2 h-full align-top max-w-[10rem]',
"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',
labelColor === "primary" && "text-primary",
labelColor === "secondary" && "text-text-subtle",
labelColor === "info" && "text-info",
)}
>
<span className="select-text cursor-text">{label}</span>

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { IconTooltip } from './IconTooltip';
import classNames from "classnames";
import type { HTMLAttributes, ReactNode } from "react";
import { IconTooltip } from "./IconTooltip";
export function Label({
htmlFor,
@@ -26,9 +26,9 @@ export function 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',
visuallyHidden && "sr-only",
"flex-shrink-0 text-sm",
"text-text-subtle whitespace-nowrap flex items-center gap-1 mb-0.5",
)}
{...props}
>

View File

@@ -1,8 +1,8 @@
import { Link as RouterLink } from '@tanstack/react-router';
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { appInfo } from '../../lib/appInfo';
import { Icon } from './Icon';
import { Link as RouterLink } from "@tanstack/react-router";
import classNames from "classnames";
import type { HTMLAttributes } from "react";
import { appInfo } from "../../lib/appInfo";
import { Icon } from "./Icon";
interface Props extends HTMLAttributes<HTMLAnchorElement> {
href: string;
@@ -14,17 +14,17 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
className = classNames(
className,
'relative',
'inline-flex items-center hover:underline group',
!noUnderline && 'underline',
"relative",
"inline-flex items-center hover:underline group",
!noUnderline && "underline",
);
if (isExternal) {
const isYaakLink = href.startsWith('https://yaak.app');
const isYaakLink = href.startsWith("https://yaak.app");
let finalHref = href;
if (isYaakLink) {
const url = new URL(href);
url.searchParams.set('ref', appInfo.identifier);
url.searchParams.set("ref", appInfo.identifier);
finalHref = url.toString();
}
return (
@@ -32,7 +32,7 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
<a
href={finalHref}
target="_blank"
rel={isYaakLink ? undefined : 'noopener noreferrer'}
rel={isYaakLink ? undefined : "noopener noreferrer"}
onClick={(e) => e.preventDefault()}
className={className}
{...other}

View File

@@ -1,34 +1,34 @@
import classNames from 'classnames';
import classNames from "classnames";
interface Props {
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
size?: "2xs" | "xs" | "sm" | "md" | "lg" | "xl";
className?: string;
}
export function LoadingIcon({ size = 'md', className }: Props) {
export function LoadingIcon({ size = "md", className }: Props) {
const classes = classNames(
className,
'text-inherit flex-shrink-0',
size === 'xl' && 'h-6 w-6',
size === 'lg' && 'h-5 w-5',
size === 'md' && 'h-4 w-4',
size === 'sm' && 'h-3.5 w-3.5',
size === 'xs' && 'h-3 w-3',
size === '2xs' && 'h-2.5 w-2.5',
'animate-spin',
"text-inherit flex-shrink-0",
size === "xl" && "h-6 w-6",
size === "lg" && "h-5 w-5",
size === "md" && "h-4 w-4",
size === "sm" && "h-3.5 w-3.5",
size === "xs" && "h-3 w-3",
size === "2xs" && "h-2.5 w-2.5",
"animate-spin",
);
return (
<div
className={classNames(
classes,
'border-[currentColor] border-b-transparent rounded-full',
size === 'xl' && 'border-[0.2rem]',
size === 'lg' && 'border-[0.16rem]',
size === 'md' && 'border-[0.13rem]',
size === 'sm' && 'border-[0.1rem]',
size === 'xs' && 'border-[0.08rem]',
size === '2xs' && 'border-[0.06rem]',
"border-[currentColor] border-b-transparent rounded-full",
size === "xl" && "border-[0.2rem]",
size === "lg" && "border-[0.16rem]",
size === "md" && "border-[0.13rem]",
size === "sm" && "border-[0.1rem]",
size === "xs" && "border-[0.08rem]",
size === "2xs" && "border-[0.06rem]",
)}
/>
);

View File

@@ -1,4 +1,4 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
import {
DndContext,
DragOverlay,
@@ -8,33 +8,33 @@ import {
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { basename } from '@tauri-apps/api/path';
import classNames from 'classnames';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { WrappedEnvironmentVariable } from '../../hooks/useEnvironmentVariables';
import { useRandomKey } from '../../hooks/useRandomKey';
import { useToggle } from '../../hooks/useToggle';
import { languageFromContentType } from '../../lib/contentType';
import { showDialog } from '../../lib/dialog';
import { computeSideForDragMove } from '../../lib/dnd';
import { showPrompt } from '../../lib/prompt';
import { DropMarker } from '../DropMarker';
import { SelectFile } from '../SelectFile';
import { Button } from './Button';
import { Checkbox } from './Checkbox';
import type { DropdownItem } from './Dropdown';
import { Dropdown } from './Dropdown';
import type { EditorProps } from './Editor/Editor';
import type { GenericCompletionConfig } from './Editor/genericCompletion';
import { Editor } from './Editor/LazyEditor';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import type { InputHandle, InputProps } from './Input';
import { Input } from './Input';
import { ensurePairId } from './PairEditor.util';
import type { RadioDropdownItem } from './RadioDropdown';
import { RadioDropdown } from './RadioDropdown';
} from "@dnd-kit/core";
import { basename } from "@tauri-apps/api/path";
import classNames from "classnames";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
import { useRandomKey } from "../../hooks/useRandomKey";
import { useToggle } from "../../hooks/useToggle";
import { languageFromContentType } from "../../lib/contentType";
import { showDialog } from "../../lib/dialog";
import { computeSideForDragMove } from "../../lib/dnd";
import { showPrompt } from "../../lib/prompt";
import { DropMarker } from "../DropMarker";
import { SelectFile } from "../SelectFile";
import { Button } from "./Button";
import { Checkbox } from "./Checkbox";
import type { DropdownItem } from "./Dropdown";
import { Dropdown } from "./Dropdown";
import type { EditorProps } from "./Editor/Editor";
import type { GenericCompletionConfig } from "./Editor/genericCompletion";
import { Editor } from "./Editor/LazyEditor";
import { Icon } from "./Icon";
import { IconButton } from "./IconButton";
import type { InputHandle, InputProps } from "./Input";
import { Input } from "./Input";
import { ensurePairId } from "./PairEditor.util";
import type { RadioDropdownItem } from "./RadioDropdown";
import { RadioDropdown } from "./RadioDropdown";
export interface PairEditorHandle {
focusName(id: string): void;
@@ -51,18 +51,18 @@ export type PairEditorProps = {
nameAutocompleteFunctions?: boolean;
nameAutocompleteVariables?: boolean;
namePlaceholder?: string;
nameValidate?: InputProps['validate'];
nameValidate?: InputProps["validate"];
noScroll?: boolean;
onChange: (pairs: PairWithId[]) => void;
pairs: Pair[];
stateKey: InputProps['stateKey'];
stateKey: InputProps["stateKey"];
setRef?: (n: PairEditorHandle) => void;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
valueAutocompleteFunctions?: boolean;
valueAutocompleteVariables?: boolean | 'environment';
valueAutocompleteVariables?: boolean | "environment";
valuePlaceholder?: string;
valueType?: InputProps['type'] | ((pair: Pair) => InputProps['type']);
valueValidate?: InputProps['validate'];
valueType?: InputProps["type"] | ((pair: Pair) => InputProps["type"]);
valueValidate?: InputProps["validate"];
};
export type Pair = {
@@ -188,7 +188,7 @@ export function PairEditor({
if (focusPrevious) {
const index = pairs.findIndex((p) => p.id === pair.id);
const id = pairs[index - 1]?.id ?? null;
rowsRef.current[id ?? 'n/a']?.focusName();
rowsRef.current[id ?? "n/a"]?.focusName();
}
return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
},
@@ -224,7 +224,7 @@ export function PairEditor({
const side = computeSideForDragMove(overPair.id, e);
const overIndex = pairs.findIndex((p) => p.id === overId);
const hoveredIndex = overIndex + (side === 'before' ? 0 : 1);
const hoveredIndex = overIndex + (side === "before" ? 0 : 1);
setHoveredIndex(hoveredIndex);
},
@@ -270,14 +270,14 @@ export function PairEditor({
<div
className={classNames(
className,
'@container relative',
'pb-2 mb-auto h-full',
!noScroll && 'overflow-y-auto max-h-full',
"@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',
"-mr-2 pr-2",
// Pad to make room for the drag divider
'pt-0.5',
'grid grid-rows-[auto_1fr]',
"pt-0.5",
"grid grid-rows-[auto_1fr]",
)}
>
<div>
@@ -379,22 +379,22 @@ type PairEditorRowProps = {
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'
| "allowFileValues"
| "allowMultilineValues"
| "forcedEnvironmentId"
| "forceUpdateKey"
| "nameAutocomplete"
| "nameAutocompleteVariables"
| "namePlaceholder"
| "nameValidate"
| "nameAutocompleteFunctions"
| "stateKey"
| "valueAutocomplete"
| "valueAutocompleteFunctions"
| "valueAutocompleteVariables"
| "valuePlaceholder"
| "valueType"
| "valueValidate"
>;
interface RowHandle {
@@ -485,7 +485,7 @@ export function PairEditorRow({
const handleChangeValueFile = useMemo(
() =>
({ filePath }: { filePath: string | null }) =>
onChange?.({ ...pair, value: filePath ?? '', isFile: true }),
onChange?.({ ...pair, value: filePath ?? "", isFile: true }),
[onChange, pair],
);
@@ -502,8 +502,8 @@ export function PairEditorRow({
const handleEditMultiLineValue = useCallback(
() =>
showDialog({
id: 'pair-edit-multiline',
size: 'dynamic',
id: "pair-edit-multiline",
size: "dynamic",
title: <>Edit {pair.name}</>,
render: ({ hide }) => (
<MultilineEditDialog
@@ -520,14 +520,14 @@ export function PairEditorRow({
const defaultItems = useMemo(
(): DropdownItem[] => [
{
label: 'Edit Multi-line',
label: "Edit Multi-line",
onSelect: handleEditMultiLineValue,
hidden: !allowMultilineValues,
},
{
label: 'Delete',
label: "Delete",
onSelect: handleDelete,
color: 'danger',
color: "danger",
},
],
[allowMultilineValues, handleDelete, handleEditMultiLineValue],
@@ -537,8 +537,8 @@ export function PairEditorRow({
const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id });
// Filter out the current pair name
const valueAutocompleteVariablesFiltered = useMemo<EditorProps['autocompleteVariables']>(() => {
if (valueAutocompleteVariables === 'environment') {
const valueAutocompleteVariablesFiltered = useMemo<EditorProps["autocompleteVariables"]>(() => {
if (valueAutocompleteVariables === "environment") {
return (v: WrappedEnvironmentVariable): boolean => v.variable.name !== pair.name;
}
return valueAutocompleteVariables;
@@ -557,17 +557,17 @@ export function PairEditorRow({
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',
"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'}
title={pair.enabled ? "Disable item" : "Enable item"}
disabled={isLast || disabled}
checked={isLast ? false : !!pair.enabled}
className={classNames(isLast && '!opacity-disabled')}
className={classNames(isLast && "!opacity-disabled")}
onChange={handleChangeEnabled}
/>
{!isLast && !disableDrag ? (
@@ -575,8 +575,8 @@ export function PairEditorRow({
{...attributes}
{...listeners}
className={classNames(
'py-2 h-7 w-4 flex items-center',
'justify-center opacity-0 group-hover/pair-row:opacity-70',
"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" />
@@ -586,9 +586,9 @@ export function PairEditorRow({
)}
<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',
"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
@@ -603,13 +603,13 @@ export function PairEditorRow({
validate={nameValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
containerClassName={classNames('bg-surface', isLast && 'border-dashed')}
containerClassName={classNames("bg-surface", isLast && "border-dashed")}
defaultValue={pair.name}
label="Name"
name={`name[${index}]`}
onChange={handleChangeName}
onFocus={handleFocusName}
placeholder={namePlaceholder ?? 'name'}
placeholder={namePlaceholder ?? "name"}
autocomplete={nameAutocomplete}
autocompleteVariables={nameAutocompleteVariables}
autocompleteFunctions={nameAutocompleteFunctions}
@@ -624,7 +624,7 @@ export function PairEditorRow({
nameOverride={pair.filename || null}
onChange={handleChangeValueFile}
/>
) : pair.value.includes('\n') ? (
) : pair.value.includes("\n") ? (
<Button
color="secondary"
size="sm"
@@ -632,7 +632,7 @@ export function PairEditorRow({
title={pair.value}
className="text-xs font-mono"
>
{pair.value.split('\n').join(' ')}
{pair.value.split("\n").join(" ")}
</Button>
) : (
<Input
@@ -643,7 +643,7 @@ export function PairEditorRow({
size="sm"
disabled={disabled}
readOnly={isDraggingGlobal}
containerClassName={classNames('bg-surface', isLast && 'border-dashed')}
containerClassName={classNames("bg-surface", isLast && "border-dashed")}
validate={valueValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
@@ -652,8 +652,8 @@ export function PairEditorRow({
name={`value[${index}]`}
onChange={handleChangeValueText}
onFocus={handleFocusValue}
type={isLast ? 'text' : typeof valueType === 'function' ? valueType(pair) : valueType}
placeholder={valuePlaceholder ?? 'value'}
type={isLast ? "text" : typeof valueType === "function" ? valueType(pair) : valueType}
placeholder={valuePlaceholder ?? "value"}
autocomplete={valueAutocomplete?.(pair.name)}
autocompleteFunctions={valueAutocompleteFunctions}
autocompleteVariables={valueAutocompleteVariablesFiltered}
@@ -676,7 +676,7 @@ export function PairEditorRow({
<IconButton
iconSize="sm"
size="xs"
icon={isLast || disabled ? 'empty' : 'chevron_down'}
icon={isLast || disabled ? "empty" : "chevron_down"}
title="Select form data type"
className="text-text-subtlest"
/>
@@ -687,8 +687,8 @@ export function PairEditorRow({
}
const fileItems: RadioDropdownItem<string>[] = [
{ label: 'Text', value: 'text' },
{ label: 'File', value: 'file' },
{ label: "Text", value: "text" },
{ label: "File", value: "file" },
];
function FileActionsDropdown({
@@ -710,8 +710,8 @@ function FileActionsDropdown({
}) {
const onChange = useCallback(
(v: string) => {
if (v === 'file') onChangeFile({ filePath: '' });
else onChangeText('');
if (v === "file") onChangeFile({ filePath: "" });
else onChangeText("");
},
[onChangeFile, onChangeText],
);
@@ -719,51 +719,51 @@ function FileActionsDropdown({
const itemsAfter = useMemo<DropdownItem[]>(
() => [
{
label: 'Edit Multi-Line',
label: "Edit Multi-Line",
leftSlot: <Icon icon="file_code" />,
hidden: pair.isFile,
onSelect: editMultiLine,
},
{
label: 'Set Content-Type',
label: "Set Content-Type",
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const contentType = await showPrompt({
id: 'content-type',
title: 'Override Content-Type',
label: 'Content-Type',
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',
placeholder: "text/plain",
defaultValue: pair.contentType ?? "",
confirmText: "Set",
description: "Leave blank to auto-detect",
});
if (contentType == null) return;
onChangeContentType(contentType);
},
},
{
label: 'Set File Name',
label: "Set File Name",
leftSlot: <Icon icon="file_code" />,
onSelect: async () => {
console.log('PAIR', pair);
const defaultFilename = await basename(pair.value ?? '');
console.log("PAIR", pair);
const defaultFilename = await basename(pair.value ?? "");
const filename = await showPrompt({
id: 'filename',
title: 'Override Filename',
label: 'Filename',
id: "filename",
title: "Override Filename",
label: "Filename",
required: false,
placeholder: defaultFilename ?? 'myfile.png',
placeholder: defaultFilename ?? "myfile.png",
defaultValue: pair.filename,
confirmText: 'Set',
description: 'Leave blank to use the name of the selected file',
confirmText: "Set",
description: "Leave blank to use the name of the selected file",
});
if (filename == null) return;
onChangeFilename(filename);
},
},
{
label: 'Unset File',
label: "Unset File",
leftSlot: <Icon icon="x" />,
hidden: pair.isFile,
onSelect: async () => {
@@ -771,11 +771,11 @@ function FileActionsDropdown({
},
},
{
label: 'Delete',
label: "Delete",
onSelect: onDelete,
variant: 'danger',
variant: "danger",
leftSlot: <Icon icon="trash" />,
color: 'danger',
color: "danger",
},
],
[
@@ -793,7 +793,7 @@ function FileActionsDropdown({
return (
<RadioDropdown
value={pair.isFile ? 'file' : 'text'}
value={pair.isFile ? "file" : "text"}
onChange={onChange}
items={fileItems}
itemsAfter={itemsAfter}
@@ -810,7 +810,7 @@ function FileActionsDropdown({
}
function emptyPair(): PairWithId {
return ensurePairId({ enabled: true, name: '', value: '' });
return ensurePairId({ enabled: true, name: "", value: "" });
}
function isPairEmpty(pair: Pair): boolean {

View File

@@ -1,8 +1,8 @@
import { generateId } from '../../lib/generateId';
import type { Pair, PairWithId } from './PairEditor';
import { generateId } from "../../lib/generateId";
import type { Pair, PairWithId } from "./PairEditor";
export function ensurePairId(p: Pair): PairWithId {
if (typeof p.id === 'string') {
if (typeof p.id === "string") {
return p as PairWithId;
}
return { ...p, id: p.id ?? generateId() };

View File

@@ -1,9 +1,9 @@
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';
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;
@@ -12,8 +12,8 @@ interface Props extends PairEditorProps {
export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
const { value: useBulk, set: setUseBulk } = useKeyValue<boolean>({
namespace: 'global',
key: ['bulk_edit', preferenceName],
namespace: "global",
key: ["bulk_edit", preferenceName],
fallback: false,
});
@@ -24,13 +24,13 @@ export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
<IconButton
size="sm"
variant="border"
title={useBulk ? 'Enable form edit' : 'Enable bulk edit'}
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',
"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'}
icon={useBulk ? "table" : "file_code"}
/>
</div>
</div>

View File

@@ -1,13 +1,13 @@
import classNames from 'classnames';
import type { ButtonProps } from './Button';
import { Button } from './Button';
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')}
className={classNames(className, "!rounded-full mx-1 !px-3")}
{...props}
/>
);

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import type { FocusEvent, HTMLAttributes, ReactNode } from 'react';
import classNames from "classnames";
import type { FocusEvent, HTMLAttributes, ReactNode } from "react";
import {
forwardRef,
useCallback,
@@ -7,30 +7,30 @@ import {
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useRandomKey } from '../../hooks/useRandomKey';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { generateId } from '../../lib/generateId';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
import { Label } from './Label';
import { HStack } from './Stacks';
} from "react";
import { useRandomKey } from "../../hooks/useRandomKey";
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { generateId } from "../../lib/generateId";
import { IconButton } from "./IconButton";
import type { InputProps } from "./Input";
import { Label } from "./Label";
import { HStack } from "./Stacks";
export type PlainInputProps = Omit<
InputProps,
| 'wrapLines'
| 'onKeyDown'
| 'type'
| 'stateKey'
| 'autocompleteVariables'
| 'autocompleteFunctions'
| 'autocomplete'
| 'extraExtensions'
| 'forcedEnvironmentId'
| "wrapLines"
| "onKeyDown"
| "type"
| "stateKey"
| "autocompleteVariables"
| "autocompleteFunctions"
| "autocomplete"
| "extraExtensions"
| "forcedEnvironmentId"
> &
Pick<HTMLAttributes<HTMLInputElement>, 'onKeyDownCapture'> & {
onFocusRaw?: HTMLAttributes<HTMLInputElement>['onFocus'];
type?: 'text' | 'password' | 'number';
Pick<HTMLAttributes<HTMLInputElement>, "onKeyDownCapture"> & {
onFocusRaw?: HTMLAttributes<HTMLInputElement>["onFocus"];
type?: "text" | "password" | "number";
step?: number;
hideObscureToggle?: boolean;
labelRightSlot?: ReactNode;
@@ -49,7 +49,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
hideObscureToggle,
label,
labelClassName,
labelPosition = 'top',
labelPosition = "top",
labelRightSlot,
leftSlot,
name,
@@ -62,9 +62,9 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
placeholder,
required,
rightSlot,
size = 'md',
size = "md",
tint,
type = 'text',
type = "text",
validate,
},
ref,
@@ -74,7 +74,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
const [focused, setFocused] = useState(false);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -114,8 +114,8 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
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',
"!bg-transparent min-w-0 w-full focus:outline-none placeholder:text-placeholder",
"px-2 text-xs font-mono cursor-text",
);
const handleChange = useCallback(
@@ -124,11 +124,11 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
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;
if (typeof validate === "boolean") return validate;
if (typeof validate === "function" && !validate(value)) return false;
return true;
};
inputRef.current?.setCustomValidity(isValid(value) ? '' : 'Invalid value');
inputRef.current?.setCustomValidity(isValid(value) ? "" : "Invalid value");
},
[onChange, required, setHasChanged, validate],
);
@@ -139,10 +139,10 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
<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',
"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
@@ -159,41 +159,41 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
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',
"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',
"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,
"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}
type={type === "password" && !obscured ? "text" : type}
name={name}
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
@@ -202,8 +202,8 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
autoCapitalize="off"
autoCorrect="off"
onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData('Text'))}
className={classNames(commonClassName, 'h-full')}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
className={classNames(commonClassName, "h-full")}
onFocus={handleFocus}
onBlur={handleBlur}
required={required}
@@ -211,14 +211,18 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
onKeyDownCapture={onKeyDownCapture}
/>
</HStack>
{type === 'password' && !hideObscureToggle && (
{type === "password" && !hideObscureToggle && (
<IconButton
title={obscured ? `Show ${typeof label === 'string' ? label : 'field'}` : `Obscure ${typeof label === 'string' ? label : 'field'}`}
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'}
icon={obscured ? "eye" : "eye_closed"}
onClick={() => setObscured((o) => !o)}
/>
)}

View File

@@ -1,10 +1,10 @@
import type { FormInput, JsonPrimitive } from '@yaakapp-internal/plugins';
import type { FormEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { generateId } from '../../lib/generateId';
import { DynamicForm } from '../DynamicForm';
import { Button } from './Button';
import { HStack } from './Stacks';
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
import type { FormEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { generateId } from "../../lib/generateId";
import { DynamicForm } from "../DynamicForm";
import { Button } from "./Button";
import { HStack } from "./Stacks";
export interface PromptProps {
inputs: FormInput[];
@@ -20,8 +20,8 @@ export function Prompt({
onCancel,
inputs: initialInputs,
onResult,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText = "Confirm",
cancelText = "Cancel",
onValuesChange,
onInputsUpdated,
}: PromptProps) {
@@ -55,10 +55,10 @@ export function Prompt({
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
{cancelText || 'Cancel'}
{cancelText || "Cancel"}
</Button>
<Button type="submit" color="primary">
{confirmText || 'Done'}
{confirmText || "Done"}
</Button>
</HStack>
</form>

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import classNames from "classnames";
import type { ReactNode } from "react";
export interface RadioCardOption<T extends string> {
value: T;
@@ -28,11 +28,9 @@ export function RadioCards<T extends string>({
<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',
"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
@@ -45,9 +43,9 @@ export function RadioCards<T extends string>({
/>
<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',
"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" />}

View File

@@ -1,12 +1,12 @@
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import type { DropdownItem, DropdownItemSeparator, DropdownProps } from './Dropdown';
import { Dropdown } from './Dropdown';
import { Icon } from './Icon';
import type { ReactNode } from "react";
import { useMemo } from "react";
import type { DropdownItem, DropdownItemSeparator, DropdownProps } from "./Dropdown";
import { Dropdown } from "./Dropdown";
import { Icon } from "./Icon";
export type RadioDropdownItem<T = string | null> =
| {
type?: 'default';
type?: "default";
label: ReactNode;
shortLabel?: ReactNode;
value: T;
@@ -20,7 +20,7 @@ export interface RadioDropdownProps<T = string | null> {
itemsBefore?: DropdownItem[];
items: RadioDropdownItem<T>[];
itemsAfter?: DropdownItem[];
children: DropdownProps['children'];
children: DropdownProps["children"];
}
export function RadioDropdown<T = string | null>({
@@ -37,13 +37,13 @@ export function RadioDropdown<T = string | null>({
? [
...itemsBefore,
{
type: 'separator',
hidden: itemsBefore[itemsBefore.length - 1]?.type === 'separator',
type: "separator",
hidden: itemsBefore[itemsBefore.length - 1]?.type === "separator",
},
]
: []) as DropdownItem[]),
...items.map((item) => {
if (item.type === 'separator') {
if (item.type === "separator") {
return item;
}
return {
@@ -51,11 +51,11 @@ export function RadioDropdown<T = string | null>({
label: item.label,
rightSlot: item.rightSlot,
onSelect: () => onChange(item.value),
leftSlot: <Icon icon={value === item.value ? 'check' : 'empty'} />,
leftSlot: <Icon icon={value === item.value ? "check" : "empty"} />,
} as DropdownItem;
}),
...((itemsAfter
? [{ type: 'separator', hidden: itemsAfter[0]?.type === 'separator' }, ...itemsAfter]
? [{ type: "separator", hidden: itemsAfter[0]?.type === "separator" }, ...itemsAfter]
: []) as DropdownItem[]),
],
[itemsBefore, items, itemsAfter, value, onChange],

View File

@@ -1,19 +1,19 @@
import classNames from 'classnames';
import { type ReactNode, useRef } from 'react';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { generateId } from '../../lib/generateId';
import { Button } from './Button';
import type { IconProps } from './Icon';
import { IconButton, type IconButtonProps } from './IconButton';
import { Label } from './Label';
import { HStack } from './Stacks';
import classNames from "classnames";
import { type ReactNode, useRef } from "react";
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { generateId } from "../../lib/generateId";
import { Button } from "./Button";
import type { IconProps } from "./Icon";
import { IconButton, type IconButtonProps } from "./IconButton";
import { Label } from "./Label";
import { HStack } from "./Stacks";
interface Props<T extends string> {
options: { value: T; label: string; icon?: IconProps['icon'] }[];
options: { value: T; label: string; icon?: IconProps["icon"] }[];
onChange: (value: T) => void;
value: T;
name: string;
size?: IconButtonProps['size'];
size?: IconButtonProps["size"];
label: string;
className?: string;
hideLabel?: boolean;
@@ -25,7 +25,7 @@ export function SegmentedControl<T extends string>({
value,
onChange,
options,
size = 'xs',
size = "xs",
label,
hideLabel,
labelClassName,
@@ -54,18 +54,18 @@ export function SegmentedControl<T extends string>({
space={1}
className={classNames(
className,
'bg-surface-highlight rounded-lg mb-auto mr-auto',
'transition-opacity transform-gpu p-1',
"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') {
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') {
} else if (e.key === "ArrowLeft") {
e.preventDefault();
const newIndex = Math.abs((selectedIndex - 1) % options.length);
if (options[newIndex]) setSelectedValue(options[newIndex].value);
@@ -84,12 +84,12 @@ export function SegmentedControl<T extends string>({
aria-checked={isActive}
size={size}
variant="solid"
color={isActive ? 'secondary' : undefined}
color={isActive ? "secondary" : undefined}
role="radio"
tabIndex={isSelected ? 0 : -1}
className={classNames(
isActive && '!text-text',
'focus:ring-1 focus:ring-border-focus',
isActive && "!text-text",
"focus:ring-1 focus:ring-border-focus",
)}
onClick={() => onChange(o.value)}
>
@@ -103,13 +103,13 @@ export function SegmentedControl<T extends string>({
aria-checked={isActive}
size={size}
variant="solid"
color={isActive ? 'secondary' : undefined}
color={isActive ? "secondary" : undefined}
role="radio"
tabIndex={isSelected ? 0 : -1}
className={classNames(
isActive && '!text-text',
'!px-1.5 !w-auto',
'focus:ring-border-focus',
isActive && "!text-text",
"!px-1.5 !w-auto",
"focus:ring-border-focus",
)}
title={o.label}
icon={o.icon}

View File

@@ -1,18 +1,18 @@
import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react';
import { useState } from 'react';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import { Label } from './Label';
import type { RadioDropdownItem } from './RadioDropdown';
import { RadioDropdown } from './RadioDropdown';
import { HStack } from './Stacks';
import { type } from "@tauri-apps/plugin-os";
import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { useState } from "react";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
import { Label } from "./Label";
import type { RadioDropdownItem } from "./RadioDropdown";
import { RadioDropdown } from "./RadioDropdown";
import { HStack } from "./Stacks";
export interface SelectProps<T extends string> {
name: string;
label: string;
labelPosition?: 'top' | 'left';
labelPosition?: "top" | "left";
labelClassName?: string;
hideLabel?: boolean;
value: T;
@@ -21,14 +21,14 @@ export interface SelectProps<T extends string> {
options: RadioDropdownItem<T>[];
onChange: (value: T) => void;
defaultValue?: T;
size?: ButtonProps['size'];
size?: ButtonProps["size"];
className?: string;
disabled?: boolean;
filterable?: boolean;
}
export function Select<T extends string>({
labelPosition = 'top',
labelPosition = "top",
name,
help,
labelClassName,
@@ -42,11 +42,11 @@ export function Select<T extends string>({
className,
defaultValue,
filterable,
size = 'md',
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 isInvalidSelection = options.find((o) => "value" in o && o.value === value) == null;
const handleChange = (value: T) => {
onChange?.(value);
@@ -56,29 +56,29 @@ export function Select<T extends string>({
<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',
"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 ? (
{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',
"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>}
@@ -90,17 +90,17 @@ export function Select<T extends string>({
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
"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>}
{isInvalidSelection && <option value={"__NONE__"}>-- Select an Option --</option>}
{options.map((o) => {
if (o.type === 'separator') return null;
if (o.type === "separator") return null;
return (
<option key={o.value} value={o.value}>
{o.label}
{o.value === defaultValue && ' (default)'}
{o.value === defaultValue && " (default)"}
</option>
);
})}
@@ -119,7 +119,7 @@ export function Select<T extends string>({
disabled={disabled}
forDropdown
>
{options.find((o) => o.type !== 'separator' && o.value === value)?.label ?? '--'}
{options.find((o) => o.type !== "separator" && o.value === value)?.label ?? "--"}
</Button>
</RadioDropdown>
)}
@@ -129,9 +129,9 @@ export function Select<T extends string>({
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',
backgroundPosition: "right 0.3rem center",
backgroundRepeat: "no-repeat",
backgroundSize: "1.5em 1.5em",
appearance: "none",
printColorAdjust: "exact",
};

View File

@@ -1,9 +1,9 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
orientation?: 'horizontal' | 'vertical';
orientation?: "horizontal" | "vertical";
dashed?: boolean;
className?: string;
children?: ReactNode;
@@ -14,28 +14,28 @@ export function Separator({
color,
className,
dashed,
orientation = 'horizontal',
orientation = "horizontal",
children,
}: Props) {
return (
<div role="presentation" className={classNames(className, 'flex items-center w-full')}>
<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]',
"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

@@ -1,4 +1,4 @@
import { formatSize } from '@yaakapp-internal/lib/formatSize';
import { formatSize } from "@yaakapp-internal/lib/formatSize";
interface Props {
contentLength: number;
@@ -11,7 +11,7 @@ export function SizeTag({ contentLength, contentLengthCompressed }: Props) {
className="font-mono"
title={
`${contentLength} bytes` +
(contentLengthCompressed ? `\n${contentLengthCompressed} bytes compressed` : '')
(contentLengthCompressed ? `\n${contentLengthCompressed} bytes compressed` : "")
}
>
{formatSize(contentLength)}

View File

@@ -1,18 +1,18 @@
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { CSSProperties, ReactNode } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { useLocalStorage } from 'react-use';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { useContainerSize } from '../../hooks/useContainerQuery';
import { clamp } from '../../lib/clamp';
import type { ResizeHandleEvent } from '../ResizeHandle';
import { ResizeHandle } from '../ResizeHandle';
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useMemo, useRef } from "react";
import { useLocalStorage } from "react-use";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useContainerSize } from "../../hooks/useContainerQuery";
import { clamp } from "../../lib/clamp";
import type { ResizeHandleEvent } from "../ResizeHandle";
import { ResizeHandle } from "../ResizeHandle";
export type SplitLayoutLayout = 'responsive' | 'horizontal' | 'vertical';
export type SplitLayoutLayout = "responsive" | "horizontal" | "vertical";
export interface SlotProps {
orientation: 'horizontal' | 'vertical';
orientation: "horizontal" | "vertical";
style: CSSProperties;
}
@@ -30,9 +30,9 @@ interface Props {
}
const baseProperties = { minWidth: 0 };
const areaL = { ...baseProperties, gridArea: 'left' };
const areaR = { ...baseProperties, gridArea: 'right' };
const areaD = { ...baseProperties, gridArea: 'drag' };
const areaL = { ...baseProperties, gridArea: "left" };
const areaR = { ...baseProperties, gridArea: "right" };
const areaD = { ...baseProperties, gridArea: "drag" };
const STACK_VERTICAL_WIDTH = 500;
@@ -42,7 +42,7 @@ export function SplitLayout({
secondSlot,
className,
name,
layout = 'responsive',
layout = "responsive",
resizeHandleClassName,
defaultRatio = 0.5,
minHeightPx = 10,
@@ -51,10 +51,10 @@ export function SplitLayout({
const containerRef = useRef<HTMLDivElement>(null);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const [widthRaw, setWidth] = useLocalStorage<number>(
`${name}_width::${activeWorkspace?.id ?? 'n/a'}`,
`${name}_width::${activeWorkspace?.id ?? "n/a"}`,
);
const [heightRaw, setHeight] = useLocalStorage<number>(
`${name}_height::${activeWorkspace?.id ?? 'n/a'}`,
`${name}_height::${activeWorkspace?.id ?? "n/a"}`,
);
const width = widthRaw ?? defaultRatio;
let height = heightRaw ?? defaultRatio;
@@ -66,7 +66,7 @@ export function SplitLayout({
const size = useContainerSize(containerRef);
const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;
const vertical = layout !== 'horizontal' && (layout === 'vertical' || verticalBasedOnSize);
const vertical = layout !== "horizontal" && (layout === "vertical" || verticalBasedOnSize);
const styles = useMemo<CSSProperties>(() => {
return {
@@ -126,23 +126,23 @@ export function SplitLayout({
<div
ref={containerRef}
style={styles}
className={classNames(className, 'grid w-full h-full overflow-hidden')}
className={classNames(className, "grid w-full h-full overflow-hidden")}
>
{firstSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })}
{firstSlot({ style: areaL, orientation: vertical ? "vertical" : "horizontal" })}
{secondSlot && (
<>
<ResizeHandle
style={areaD}
className={classNames(
resizeHandleClassName,
vertical ? '-translate-y-1' : '-translate-x-1',
vertical ? "-translate-y-1" : "-translate-x-1",
)}
onResizeMove={handleResizeMove}
onReset={handleReset}
side={vertical ? 'top' : 'left'}
side={vertical ? "top" : "left"}
justify="center"
/>
{secondSlot({ style: areaR, orientation: vertical ? 'vertical' : 'horizontal' })}
{secondSlot({ style: areaR, orientation: vertical ? "vertical" : "horizontal" })}
</>
)}
</div>

View File

@@ -1,17 +1,17 @@
import classNames from 'classnames';
import type { ComponentType, ForwardedRef, HTMLAttributes, ReactNode } from 'react';
import { forwardRef } from 'react';
import classNames from "classnames";
import type { ComponentType, ForwardedRef, HTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react";
const gapClasses = {
0: 'gap-0',
0.5: 'gap-0.5',
1: 'gap-1',
1.5: 'gap-1.5',
2: 'gap-2',
3: 'gap-3',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
0: "gap-0",
0.5: "gap-0.5",
1: "gap-1",
1.5: "gap-1.5",
2: "gap-2",
3: "gap-3",
4: "gap-4",
5: "gap-5",
6: "gap-6",
};
interface HStackProps extends BaseStackProps {
@@ -19,14 +19,14 @@ interface HStackProps extends BaseStackProps {
}
export const HStack = forwardRef(function HStack(
{ className, space, children, alignItems = 'center', ...props }: HStackProps,
{ className, space, children, alignItems = "center", ...props }: HStackProps,
// oxlint-disable-next-line no-explicit-any
ref: ForwardedRef<any>,
) {
return (
<BaseStack
ref={ref}
className={classNames(className, 'flex-row', space != null && gapClasses[space])}
className={classNames(className, "flex-row", space != null && gapClasses[space])}
alignItems={alignItems}
{...props}
>
@@ -47,7 +47,7 @@ export const VStack = forwardRef(function VStack(
return (
<BaseStack
ref={ref}
className={classNames(className, 'flex-col', space != null && gapClasses[space])}
className={classNames(className, "flex-col", space != null && gapClasses[space])}
{...props}
>
{children}
@@ -56,10 +56,10 @@ export const VStack = forwardRef(function VStack(
});
type BaseStackProps = HTMLAttributes<HTMLElement> & {
as?: ComponentType | 'ul' | 'label' | 'form' | 'p';
as?: ComponentType | "ul" | "label" | "form" | "p";
space?: keyof typeof gapClasses;
alignItems?: 'start' | 'center' | 'stretch' | 'end';
justifyContent?: 'start' | 'center' | 'end' | 'between';
alignItems?: "start" | "center" | "stretch" | "end";
justifyContent?: "start" | "center" | "end" | "between";
wrap?: boolean;
};
@@ -68,22 +68,22 @@ const BaseStack = forwardRef(function BaseStack(
// oxlint-disable-next-line no-explicit-any
ref: ForwardedRef<any>,
) {
const Component = as ?? 'div';
const Component = as ?? "div";
return (
<Component
ref={ref}
className={classNames(
className,
'flex',
wrap && 'flex-wrap',
alignItems === 'center' && 'items-center',
alignItems === 'start' && 'items-start',
alignItems === 'stretch' && 'items-stretch',
alignItems === 'end' && 'items-end',
justifyContent === 'start' && 'justify-start',
justifyContent === 'center' && 'justify-center',
justifyContent === 'end' && 'justify-end',
justifyContent === 'between' && 'justify-between',
"flex",
wrap && "flex-wrap",
alignItems === "center" && "items-center",
alignItems === "start" && "items-start",
alignItems === "stretch" && "items-stretch",
alignItems === "end" && "items-end",
justifyContent === "start" && "justify-start",
justifyContent === "center" && "justify-center",
justifyContent === "end" && "justify-end",
justifyContent === "between" && "justify-between",
)}
{...props}
>

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import classNames from "classnames";
import type { ReactNode } from "react";
export function Table({
children,
@@ -11,13 +11,13 @@ export function Table({
scrollable?: boolean;
}) {
return (
<div className={classNames('w-full', scrollable && 'h-full overflow-y-auto')}>
<div className={classNames("w-full", scrollable && "h-full overflow-y-auto")}>
<table
className={classNames(
className,
'w-full text-sm mb-auto min-w-full max-w-full',
'border-separate border-spacing-0',
scrollable && '[&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10',
"w-full text-sm mb-auto min-w-full max-w-full",
"border-separate border-spacing-0",
scrollable && "[&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10",
)}
>
{children}
@@ -39,7 +39,7 @@ export function TableHead({ children, className }: { children: ReactNode; classN
<thead
className={classNames(
className,
'bg-surface [&_th]:border-b [&_th]:border-b-surface-highlight',
"bg-surface [&_th]:border-b [&_th]:border-b-surface-highlight",
)}
>
{children}
@@ -54,18 +54,18 @@ export function TableRow({ children }: { children: ReactNode }) {
export function TableCell({
children,
className,
align = 'left',
align = "left",
}: {
children: ReactNode;
className?: string;
align?: 'left' | 'center' | 'right';
align?: "left" | "center" | "right";
}) {
return (
<td
className={classNames(
className,
'py-2 [&:not(:first-child)]:pl-4 whitespace-nowrap',
align === 'left' ? 'text-left' : align === 'center' ? 'text-center' : 'text-right',
"py-2 [&:not(:first-child)]:pl-4 whitespace-nowrap",
align === "left" ? "text-left" : align === "center" ? "text-center" : "text-right",
)}
>
{children}
@@ -81,7 +81,7 @@ export function TruncatedWideTableCell({
className?: string;
}) {
return (
<TableCell className={classNames(className, 'truncate max-w-0 w-full')}>{children}</TableCell>
<TableCell className={classNames(className, "truncate max-w-0 w-full")}>{children}</TableCell>
);
}
@@ -96,7 +96,7 @@ export function TableHeaderCell({
<th
className={classNames(
className,
'py-2 [&:not(:first-child)]:pl-4 text-left text-text-subtle',
"py-2 [&:not(:first-child)]:pl-4 text-left text-text-subtle",
)}
>
{children}

View File

@@ -1,4 +1,4 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
import {
closestCenter,
DndContext,
@@ -8,9 +8,9 @@ import {
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import classNames from 'classnames';
import type { ReactNode, Ref } from 'react';
} from "@dnd-kit/core";
import classNames from "classnames";
import type { ReactNode, Ref } from "react";
import {
forwardRef,
memo,
@@ -20,17 +20,17 @@ import {
useMemo,
useRef,
useState,
} from 'react';
import { useKeyValue } from '../../../hooks/useKeyValue';
import { fireAndForget } from '../../../lib/fireAndForget';
import { computeSideForDragMove } from '../../../lib/dnd';
import { DropMarker } from '../../DropMarker';
import { ErrorBoundary } from '../../ErrorBoundary';
import type { ButtonProps } from '../Button';
import { Button } from '../Button';
import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
} from "react";
import { useKeyValue } from "../../../hooks/useKeyValue";
import { fireAndForget } from "../../../lib/fireAndForget";
import { computeSideForDragMove } from "../../../lib/dnd";
import { DropMarker } from "../../DropMarker";
import { ErrorBoundary } from "../../ErrorBoundary";
import type { ButtonProps } from "../Button";
import { Button } from "../Button";
import { Icon } from "../Icon";
import type { RadioDropdownProps } from "../RadioDropdown";
import { RadioDropdown } from "../RadioDropdown";
export type TabItem =
| {
@@ -42,7 +42,7 @@ export type TabItem =
}
| {
value: string;
options: Omit<RadioDropdownProps, 'children'>;
options: Omit<RadioDropdownProps, "children">;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
@@ -68,7 +68,7 @@ interface Props {
className?: string;
children: ReactNode;
addBorders?: boolean;
layout?: 'horizontal' | 'vertical';
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. */
@@ -85,7 +85,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
className,
tabListClassName,
addBorders,
layout = 'vertical',
layout = "vertical",
storageKey,
activeTabKey,
}: Props,
@@ -97,8 +97,8 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
// 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'],
namespace: "no_sync",
key: storageKey ?? ["tabs", "default"],
fallback: { order: [], activeTabs: {} },
});
@@ -203,20 +203,20 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
// Update tabs when value changes
useEffect(() => {
const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]');
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');
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';
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';
tab.setAttribute("data-state", "inactive");
tab.setAttribute("aria-hidden", "true");
tab.style.display = "none";
}
}
}, [value]);
@@ -241,14 +241,14 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
if (overTab == null) return setHoveredIndex(null);
// For vertical layout, tabs are arranged horizontally (side-by-side)
const orientation = layout === 'vertical' ? 'horizontal' : 'vertical';
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);
const hoveredIndex = overIndex + (side === "before" ? 0 : 1);
setHoveredIndex(hoveredIndex);
},
@@ -291,7 +291,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
const tabButtons = useMemo(() => {
const items: ReactNode[] = [];
tabs.forEach((t, i) => {
if ('hidden' in t && t.hidden) {
if ("hidden" in t && t.hidden) {
return;
}
@@ -302,9 +302,9 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
items.push(
<div
key={`marker-${t.value}`}
className={classNames('relative', layout === 'vertical' ? 'w-0' : 'h-0')}
className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}
>
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
</div>,
);
}
@@ -331,25 +331,25 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
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 ',
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',
!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',
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 className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}>
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
</div>
)}
</div>
@@ -361,10 +361,10 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
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',
"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 ? (
@@ -405,7 +405,7 @@ interface TabButtonProps {
tab: TabItem;
isActive: boolean;
addBorders?: boolean;
layout: 'horizontal' | 'vertical';
layout: "horizontal" | "vertical";
reorderable: boolean;
isDragging: boolean;
onChangeValue?: (value: string) => void;
@@ -448,8 +448,8 @@ function TabButton({
);
const btnProps: Partial<ButtonProps> = {
color: 'custom',
justify: layout === 'horizontal' ? 'start' : 'center',
color: "custom",
justify: layout === "horizontal" ? "start" : "center",
onClick: isActive
? undefined
: (e: React.MouseEvent) => {
@@ -457,27 +457,27 @@ function TabButton({
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',
"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',
? "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);
if ("options" in tab) {
const option = tab.options.items.find((i) => "value" in i && i.value === tab.options.value);
return (
<RadioDropdown
key={tab.value}
@@ -496,24 +496,24 @@ function TabButton({
size="sm"
icon="chevron_down"
className={classNames(
'ml-1',
isActive ? 'text-text-subtle' : 'text-text-subtlest',
"ml-1",
isActive ? "text-text-subtle" : "text-text-subtlest",
)}
/>
</div>
}
{...btnProps}
>
{option && 'shortLabel' in option && option.shortLabel
{option && "shortLabel" in option && option.shortLabel
? option.shortLabel
: (option?.label ?? 'Unknown')}
: (option?.label ?? "Unknown")}
</Button>
</RadioDropdown>
);
}
return (
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
{'label' in tab && tab.label ? tab.label : tab.value}
{"label" in tab && tab.label ? tab.label : tab.value}
</Button>
);
})();
@@ -524,7 +524,7 @@ function TabButton({
return (
<div
ref={handleSetWrapperRef}
className={classNames('relative', layout === 'vertical' && 'mr-2')}
className={classNames("relative", layout === "vertical" && "mr-2")}
{...wrapperProps}
>
{buttonContent}
@@ -548,7 +548,7 @@ export const TabContent = memo(function TabContent({
<div
tabIndex={-1}
data-tab={value}
className={classNames(className, 'tab-content', 'hidden w-full h-full pt-2')}
className={classNames(className, "tab-content", "hidden w-full h-full pt-2")}
>
{children}
</div>
@@ -569,14 +569,14 @@ export async function setActiveTab({
activeTabKey: string;
value: string;
}): Promise<void> {
const { getKeyValue, setKeyValue } = await import('../../../lib/keyValueStore');
const { getKeyValue, setKeyValue } = await import("../../../lib/keyValueStore");
const current = getKeyValue<TabsStorage>({
namespace: 'no_sync',
namespace: "no_sync",
key: storageKey,
fallback: { order: [], activeTabs: {} },
});
await setKeyValue({
namespace: 'no_sync',
namespace: "no_sync",
key: storageKey,
value: {
...current,

View File

@@ -1,13 +1,13 @@
import type { ShowToastRequest } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import * as m from 'motion/react-m';
import type { ReactNode } from 'react';
import type { ShowToastRequest } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useKey } from 'react-use';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { VStack } from './Stacks';
import { useKey } from "react-use";
import type { IconProps } from "./Icon";
import { Icon } from "./Icon";
import { IconButton } from "./IconButton";
import { VStack } from "./Stacks";
export interface ToastProps {
children: ReactNode;
@@ -16,24 +16,24 @@ export interface ToastProps {
className?: string;
timeout: number | null;
action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest['icon'] | null;
color?: ShowToastRequest['color'];
icon?: ShowToastRequest["icon"] | null;
color?: ShowToastRequest["color"];
}
const ICONS: Record<NonNullable<ToastProps['color'] | 'custom'>, IconProps['icon'] | null> = {
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',
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',
"Escape",
() => {
if (!open) return;
onClose();
@@ -46,18 +46,18 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
return (
<m.div
initial={{ opacity: 0, right: '-10%' }}
initial={{ opacity: 0, right: "-10%" }}
animate={{ opacity: 100, right: 0 }}
exit={{ opacity: 0, right: '-100%' }}
exit={{ opacity: 0, right: "-100%" }}
transition={{ duration: 0.2 }}
className={classNames('bg-surface m-2 rounded-lg')}
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]',
"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">
@@ -81,9 +81,9 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
<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' }}
initial={{ width: "100%" }}
animate={{ width: "0%", opacity: 0.2 }}
transition={{ duration: timeout / 1000, ease: "linear" }}
/>
</div>
)}

View File

@@ -1,33 +1,33 @@
import classNames from 'classnames';
import type { CSSProperties, KeyboardEvent, ReactNode } from 'react';
import { useRef, useState } from 'react';
import { generateId } from '../../lib/generateId';
import { Portal } from '../Portal';
import classNames from "classnames";
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
import { useRef, useState } from "react";
import { generateId } from "../../lib/generateId";
import { Portal } from "../Portal";
export interface TooltipProps {
children: ReactNode;
content: ReactNode;
tabIndex?: number;
size?: 'md' | 'lg';
size?: "md" | "lg";
className?: string;
}
const hiddenStyles: CSSProperties = {
left: -99999,
top: -99999,
visibility: 'hidden',
pointerEvents: 'none',
visibility: "hidden",
pointerEvents: "none",
opacity: 0,
};
type TooltipPosition = 'top' | 'bottom';
type TooltipPosition = "top" | "bottom";
interface TooltipOpenState {
styles: CSSProperties;
position: TooltipPosition;
}
export function Tooltip({ children, className, content, tabIndex, size = 'md' }: TooltipProps) {
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);
@@ -44,12 +44,12 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
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 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'
maxHeight: position === "top" ? spaceAbove : spaceBelow,
...(position === "top"
? { bottom: viewportHeight - triggerRect.top }
: { top: triggerRect.bottom }),
};
@@ -73,7 +73,7 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
};
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (openState && e.key === 'Escape') {
if (openState && e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
handleClose();
@@ -97,16 +97,16 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
>
<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',
"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'}
position={openState?.position === "bottom" ? "top" : "bottom"}
/>
</div>
</Portal>
@@ -116,7 +116,7 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
role="button"
aria-describedby={openState ? id.current : undefined}
tabIndex={tabIndex ?? -1}
className={classNames(className, 'flex-grow-0 flex items-center')}
className={classNames(className, "flex-grow-0 flex items-center")}
onClick={handleToggleImmediate}
onMouseEnter={handleOpen}
onMouseLeave={handleClose}
@@ -130,8 +130,8 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
);
}
function Triangle({ className, position }: { className?: string; position: 'top' | 'bottom' }) {
const isBottom = position === 'bottom';
function Triangle({ className, position }: { className?: string; position: "top" | "bottom" }) {
const isBottom = position === "bottom";
return (
<svg
@@ -141,19 +141,19 @@ function Triangle({ className, position }: { className?: string; position: 'top'
shapeRendering="crispEdges"
className={classNames(
className,
'absolute z-50 left-[calc(50%-0.4rem)] h-[0.5rem] w-[0.8rem]',
"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',
? "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'}
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'}
d={isBottom ? "M0 0 L15 9 L30 0" : "M0 10 L15 1 L30 10"}
fill="none"
stroke="currentColor"
strokeWidth="1"

View File

@@ -1,5 +1,5 @@
import type { WebsocketConnection } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { WebsocketConnection } from "@yaakapp-internal/models";
import classNames from "classnames";
interface Props {
connection: WebsocketConnection;
@@ -10,22 +10,22 @@ export function WebsocketStatusTag({ connection, className }: Props) {
const { state, error } = connection;
let label: string;
let colorClass = 'text-text-subtle';
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';
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';
label = "CONNECTING";
}
return <span className={classNames(className, 'font-mono', colorClass)}>{label}</span>;
return <span className={classNames(className, "font-mono", colorClass)}>{label}</span>;
}

View File

@@ -1,4 +1,4 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
import {
DndContext,
MeasuringStrategy,
@@ -7,10 +7,10 @@ import {
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from 'react';
} from "@dnd-kit/core";
import { type } from "@tauri-apps/plugin-os";
import classNames from "classnames";
import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from "react";
import {
forwardRef,
memo,
@@ -20,14 +20,14 @@ import {
useMemo,
useRef,
useState,
} from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import type { HotKeyOptions, HotkeyAction } from '../../../hooks/useHotKey';
import { useHotKey } from '../../../hooks/useHotKey';
import { computeSideForDragMove } from '../../../lib/dnd';
import { jotaiStore } from '../../../lib/jotai';
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
import { ContextMenu } from '../Dropdown';
} from "react";
import { useKey, useKeyPressEvent } from "react-use";
import type { HotKeyOptions, HotkeyAction } from "../../../hooks/useHotKey";
import { useHotKey } from "../../../hooks/useHotKey";
import { computeSideForDragMove } from "../../../lib/dnd";
import { jotaiStore } from "../../../lib/jotai";
import type { ContextMenuProps, DropdownItem } from "../Dropdown";
import { ContextMenu } from "../Dropdown";
import {
collapsedFamily,
draggingIdsFamily,
@@ -35,14 +35,14 @@ import {
hoveredParentFamily,
isCollapsedFamily,
selectedIdsFamily,
} from './atoms';
import type { SelectableTreeNode, TreeNode } from './common';
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from './common';
import { TreeDragOverlay } from './TreeDragOverlay';
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
import type { TreeItemListProps } from './TreeItemList';
import { TreeItemList } from './TreeItemList';
import { useSelectableItems } from './useSelectableItems';
} from "./atoms";
import type { SelectableTreeNode, TreeNode } from "./common";
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from "./common";
import { TreeDragOverlay } from "./TreeDragOverlay";
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem";
import type { TreeItemListProps } from "./TreeItemList";
import { TreeItemList } from "./TreeItemList";
import { useSelectableItems } from "./useSelectableItems";
/** So we re-calculate after expanding a folder during drag */
const measuring = { droppable: { strategy: MeasuringStrategy.Always } };
@@ -51,7 +51,7 @@ export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>;
treeId: string;
getItemKey: (item: T) => string;
getContextMenu?: (items: T[]) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
getContextMenu?: (items: T[]) => ContextMenuProps["items"] | Promise<ContextMenuProps["items"]>;
ItemInner: ComponentType<{ treeId: string; item: T }>;
ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>;
ItemRightSlot?: ComponentType<{ treeId: string; item: T }>;
@@ -140,7 +140,7 @@ function TreeInner<T extends { id: string }>(
return false;
}
$el.focus();
$el.scrollIntoView({ block: 'nearest' });
$el.scrollIntoView({ block: "nearest" });
return true;
}, []);
@@ -241,7 +241,7 @@ function TreeInner<T extends { id: string }>(
};
}, [getContextMenu, selectableItems, setSelected, treeId]);
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
const handleSelect = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
(item, { shiftKey, metaKey, ctrlKey }) => {
const anchorSelectedId = jotaiStore.get(focusIdsFamily(treeId)).anchorId;
const selectedIdsAtom = selectedIdsFamily(treeId);
@@ -281,7 +281,7 @@ function TreeInner<T extends { id: string }>(
} else {
setSelected([item.id], true);
}
} else if (type() === 'macos' ? metaKey : ctrlKey) {
} else if (type() === "macos" ? metaKey : ctrlKey) {
const withoutCurr = selectedIds.filter((id) => id !== item.id);
if (withoutCurr.length === selectedIds.length) {
// It wasn't in there, so add it
@@ -299,7 +299,7 @@ function TreeInner<T extends { id: string }>(
[selectableItems, setSelected, treeId],
);
const handleClick = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
const handleClick = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
(item, e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
handleSelect(item, e);
@@ -350,7 +350,7 @@ function TreeInner<T extends { id: string }>(
);
useKey(
(e) => e.key === 'ArrowUp' || e.key.toLowerCase() === 'k',
(e) => e.key === "ArrowUp" || e.key.toLowerCase() === "k",
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
@@ -361,7 +361,7 @@ function TreeInner<T extends { id: string }>(
);
useKey(
(e) => e.key === 'ArrowDown' || e.key.toLowerCase() === 'j',
(e) => e.key === "ArrowDown" || e.key.toLowerCase() === "j",
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
@@ -373,7 +373,7 @@ function TreeInner<T extends { id: string }>(
// If the selected item is a collapsed folder, expand it. Otherwise, select next item
useKey(
(e) => e.key === 'ArrowRight' || e.key === 'l',
(e) => e.key === "ArrowRight" || e.key === "l",
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
@@ -399,7 +399,7 @@ function TreeInner<T extends { id: string }>(
// If the selected item is in a folder, select its parent.
// If the selected item is an expanded folder, collapse it.
useKey(
(e) => e.key === 'ArrowLeft' || e.key === 'h',
(e) => e.key === "ArrowLeft" || e.key === "h",
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
@@ -422,7 +422,7 @@ function TreeInner<T extends { id: string }>(
[selectableItems, handleSelect],
);
useKeyPressEvent('Escape', async () => {
useKeyPressEvent("Escape", async () => {
if (!treeRef.current?.contains(document.activeElement)) return;
clearDragState();
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
@@ -486,11 +486,11 @@ function TreeInner<T extends { id: string }>(
let hoveredParent = node.parent;
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === 'before' ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === 'before' ? 0 : 1);
const hoveredIndex = dragIndex + (side === "before" ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === "before" ? 0 : 1);
// Move into the folder if it's open and we're moving after it
if (hovered?.children != null && side === 'after') {
if (hovered?.children != null && side === "after") {
hoveredParent = hovered;
hoveredChildIndex = 0;
}
@@ -620,7 +620,7 @@ function TreeInner<T extends { id: string }>(
const treeItemListProps: Omit<
TreeItemListProps<T>,
'nodes' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
"nodes" | "treeId" | "activeIdAtom" | "hoveredParent" | "hoveredIndex"
> = {
getItemKey,
getContextMenu: handleGetContextMenu,
@@ -670,24 +670,24 @@ function TreeInner<T extends { id: string }>(
ref={treeRef}
className={classNames(
className,
'outline-none h-full',
'overflow-y-auto overflow-x-hidden',
'grid grid-rows-[auto_1fr]',
"outline-none h-full",
"overflow-y-auto overflow-x-hidden",
"grid grid-rows-[auto_1fr]",
)}
>
<div
className={classNames(
'[&_.tree-item.selected_.tree-item-inner]:text-text',
'[&:focus-within]:[&_.tree-item.selected]:bg-surface-active',
'[&:not(:focus-within)]:[&_.tree-item.selected:not([data-context-menu-open])]:bg-surface-highlight',
'[&_.tree-item.selected[data-context-menu-open]]:bg-surface-active',
"[&_.tree-item.selected_.tree-item-inner]:text-text",
"[&:focus-within]:[&_.tree-item.selected]:bg-surface-active",
"[&:not(:focus-within)]:[&_.tree-item.selected:not([data-context-menu-open])]:bg-surface-highlight",
"[&_.tree-item.selected[data-context-menu-open]]:bg-surface-active",
// Round the items, but only if the ends of the selection.
// Also account for the drop marker being in between items
'[&_.tree-item]:rounded-md',
'[&_.tree-item.selected+.tree-item.selected]:rounded-t-none',
'[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none',
'[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none',
'[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none',
"[&_.tree-item]:rounded-md",
"[&_.tree-item.selected+.tree-item.selected]:rounded-t-none",
"[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none",
"[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none",
"[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none",
)}
>
<TreeItemList
@@ -766,7 +766,7 @@ function TreeHotKey<T extends { id: string }>({
...options,
enable: () => {
if (enable == null) return true;
if (typeof enable === 'function') return enable();
if (typeof enable === "function") return enable();
return enable;
},
},
@@ -780,7 +780,7 @@ function TreeHotKeys<T extends { id: string }>({
selectableItems,
}: {
treeId: string;
hotkeys: TreeProps<T>['hotkeys'];
hotkeys: TreeProps<T>["hotkeys"];
selectableItems: SelectableTreeNode<T>[];
}) {
if (hotkeys == null) return null;

View File

@@ -1,9 +1,9 @@
import { DragOverlay } from '@dnd-kit/core';
import { useAtomValue } from 'jotai';
import { draggingIdsFamily } from './atoms';
import type { SelectableTreeNode } from './common';
import type { TreeProps } from './Tree';
import { TreeItemList } from './TreeItemList';
import { DragOverlay } from "@dnd-kit/core";
import { useAtomValue } from "jotai";
import { draggingIdsFamily } from "./atoms";
import type { SelectableTreeNode } from "./common";
import type { TreeProps } from "./Tree";
import { TreeItemList } from "./TreeItemList";
export function TreeDragOverlay<T extends { id: string }>({
treeId,
@@ -14,7 +14,7 @@ export function TreeDragOverlay<T extends { id: string }>({
}: {
treeId: string;
selectableItems: SelectableTreeNode<T>[];
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlotInner'>) {
} & Pick<TreeProps<T>, "getItemKey" | "ItemInner" | "ItemLeftSlotInner">) {
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
return (
<DragOverlay dropAnimation={null}>

View File

@@ -1,9 +1,9 @@
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { memo } from 'react';
import { DropMarker } from '../../DropMarker';
import { hoveredParentDepthFamily, isCollapsedFamily, isIndexHoveredFamily } from './atoms';
import type { TreeNode } from './common';
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { memo } from "react";
import { DropMarker } from "../../DropMarker";
import { hoveredParentDepthFamily, isCollapsedFamily, isIndexHoveredFamily } from "./atoms";
import type { TreeNode } from "./common";
export const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
className,

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { memo } from 'react';
import { hoveredParentDepthFamily, isAncestorHoveredFamily } from './atoms';
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { memo } from "react";
import { hoveredParentDepthFamily, isAncestorHoveredFamily } from "./atoms";
export const TreeIndentGuide = memo(function TreeIndentGuide({
treeId,
@@ -22,8 +22,8 @@ export const TreeIndentGuide = memo(function TreeIndentGuide({
// oxlint-disable-next-line react/no-array-index-key
key={i}
className={classNames(
'w-[calc(1rem+0.5px)] border-r border-r-text-subtlest',
!(parentDepth === i + 1 && isHovered) && 'opacity-30',
"w-[calc(1rem+0.5px)] border-r border-r-text-subtlest",
!(parentDepth === i + 1 && isHovered) && "opacity-30",
)}
/>
))}

View File

@@ -1,25 +1,25 @@
import type { DragMoveEvent } from '@dnd-kit/core';
import { useDndContext, useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import type { DragMoveEvent } from "@dnd-kit/core";
import { useDndContext, useDndMonitor, useDraggable, useDroppable } from "@dnd-kit/core";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
import type {
MouseEvent,
PointerEvent,
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { computeSideForDragMove } from '../../../lib/dnd';
import { jotaiStore } from '../../../lib/jotai';
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
import { ContextMenu } from '../Dropdown';
import { Icon } from '../Icon';
import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms';
import type { TreeNode } from './common';
import { getNodeKey } from './common';
import type { TreeProps } from './Tree';
import { TreeIndentGuide } from './TreeIndentGuide';
} from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { computeSideForDragMove } from "../../../lib/dnd";
import { jotaiStore } from "../../../lib/jotai";
import type { ContextMenuProps, DropdownItem } from "../Dropdown";
import { ContextMenu } from "../Dropdown";
import { Icon } from "../Icon";
import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from "./atoms";
import type { TreeNode } from "./common";
import { getNodeKey } from "./common";
import type { TreeProps } from "./Tree";
import { TreeIndentGuide } from "./TreeIndentGuide";
export interface TreeItemClickEvent {
shiftKey: boolean;
@@ -29,12 +29,12 @@ export interface TreeItemClickEvent {
export type TreeItemProps<T extends { id: string }> = Pick<
TreeProps<T>,
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
"ItemInner" | "ItemLeftSlotInner" | "ItemRightSlot" | "treeId" | "getEditOptions" | "getItemKey"
> & {
node: TreeNode<T>;
className?: string;
onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
getContextMenu?: (item: T) => ContextMenuProps["items"] | Promise<ContextMenuProps["items"]>;
depth: number;
setRef?: (item: T, n: TreeItemHandle | null) => void;
};
@@ -68,7 +68,7 @@ function TreeItem_<T extends { id: string }>({
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
const [editing, setEditing] = useState<boolean>(false);
const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
const [dropHover, setDropHover] = useState<null | "drop" | "animate">(null);
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
const handle = useMemo<TreeItemHandle>(
() => ({
@@ -88,7 +88,7 @@ function TreeItem_<T extends { id: string }>({
return listItemRef.current.getBoundingClientRect();
},
scrollIntoView: () => {
listItemRef.current?.scrollIntoView({ block: 'nearest' });
listItemRef.current?.scrollIntoView({ block: "nearest" });
},
}),
[editing, getEditOptions],
@@ -162,13 +162,13 @@ function TreeItem_<T extends { id: string }>({
async (e: ReactKeyboardEvent<HTMLInputElement>) => {
e.stopPropagation(); // Don't trigger other tree keys (like arrows)
switch (e.key) {
case 'Enter':
case "Enter":
if (editing) {
e.preventDefault();
await handleSubmitNameEdit(e.currentTarget);
}
break;
case 'Escape':
case "Escape":
if (editing) {
e.preventDefault();
setEditing(false);
@@ -208,8 +208,8 @@ function TreeItem_<T extends { id: string }>({
const isFolder = node.children != null;
const hasChildren = (node.children?.length ?? 0) > 0;
const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id }));
if (isCollapsed && isFolder && hasChildren && side === 'after') {
setDropHover('animate');
if (isCollapsed && isFolder && hasChildren && side === "after") {
setDropHover("animate");
clearTimeout(startedHoverTimeout.current);
startedHoverTimeout.current = setTimeout(() => {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false);
@@ -221,8 +221,8 @@ function TreeItem_<T extends { id: string }>({
);
});
}, HOVER_CLOSED_FOLDER_DELAY);
} else if (isFolder && !hasChildren && side === 'after') {
setDropHover('drop');
} else if (isFolder && !hasChildren && side === "after") {
setDropHover("drop");
} else {
clearDropHover();
}
@@ -238,7 +238,7 @@ function TreeItem_<T extends { id: string }>({
// Set data attribute on the list item to preserve active state
if (listItemRef.current) {
listItemRef.current.setAttribute('data-context-menu-open', 'true');
listItemRef.current.setAttribute("data-context-menu-open", "true");
}
const items = await getContextMenu(node.item);
@@ -250,7 +250,7 @@ function TreeItem_<T extends { id: string }>({
const handleCloseContextMenu = useCallback(() => {
// Remove data attribute when context menu closes
if (listItemRef.current) {
listItemRef.current.removeAttribute('data-context-menu-open');
listItemRef.current.removeAttribute("data-context-menu-open");
}
setShowContextMenu(null);
}, []);
@@ -290,20 +290,20 @@ function TreeItem_<T extends { id: string }>({
onContextMenu={handleContextMenu}
className={classNames(
className,
'tree-item',
'h-sm',
'grid grid-cols-[auto_minmax(0,1fr)]',
editing && 'ring-1 focus-within:ring-focus',
dropHover != null && 'relative z-10 ring-2 ring-primary',
dropHover === 'animate' && 'animate-blinkRing',
isSelected && 'selected',
"tree-item",
"h-sm",
"grid grid-cols-[auto_minmax(0,1fr)]",
editing && "ring-1 focus-within:ring-focus",
dropHover != null && "relative z-10 ring-2 ring-primary",
dropHover === "animate" && "animate-blinkRing",
isSelected && "selected",
)}
>
<TreeIndentGuide treeId={treeId} depth={depth} ancestorIds={ancestorIds} />
<div
className={classNames(
'text-text-subtle',
'grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md',
"text-text-subtle",
"grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md",
)}
>
{showContextMenu && (
@@ -321,12 +321,12 @@ function TreeItem_<T extends { id: string }>({
onClick={toggleCollapsed}
>
<Icon
icon={node.children.length === 0 ? 'dot' : 'chevron_right'}
icon={node.children.length === 0 ? "dot" : "chevron_right"}
className={classNames(
'transition-transform text-text-subtlest',
'ml-auto',
'w-[1rem] h-[1rem]',
!isCollapsed && node.children.length > 0 && 'rotate-90',
"transition-transform text-text-subtlest",
"ml-auto",
"w-[1rem] h-[1rem]",
!isCollapsed && node.children.length > 0 && "rotate-90",
)}
/>
</button>

View File

@@ -1,16 +1,16 @@
import type { CSSProperties } from 'react';
import { Fragment } from 'react';
import type { SelectableTreeNode } from './common';
import type { TreeProps } from './Tree';
import { TreeDropMarker } from './TreeDropMarker';
import type { TreeItemHandle, TreeItemProps } from './TreeItem';
import { TreeItem } from './TreeItem';
import type { CSSProperties } from "react";
import { Fragment } from "react";
import type { SelectableTreeNode } from "./common";
import type { TreeProps } from "./Tree";
import { TreeDropMarker } from "./TreeDropMarker";
import type { TreeItemHandle, TreeItemProps } from "./TreeItem";
import { TreeItem } from "./TreeItem";
export type TreeItemListProps<T extends { id: string }> = Pick<
TreeProps<T>,
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
"ItemInner" | "ItemLeftSlotInner" | "ItemRightSlot" | "treeId" | "getItemKey" | "getEditOptions"
> &
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
Pick<TreeItemProps<T>, "onClick" | "getContextMenu"> & {
nodes: SelectableTreeNode<T>[];
style?: CSSProperties;
className?: string;

View File

@@ -1,6 +1,6 @@
import { atom } from 'jotai';
import { atomFamily, selectAtom } from 'jotai/utils';
import { atomWithKVStorage } from '../../../lib/atoms/atomWithKVStorage';
import { atom } from "jotai";
import { atomFamily, selectAtom } from "jotai/utils";
import { atomWithKVStorage } from "../../../lib/atoms/atomWithKVStorage";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const selectedIdsFamily = atomFamily((_treeId: string) => {
@@ -58,7 +58,7 @@ export const isAncestorHoveredFamily = atomFamily(
(v) => v.parentId && ancestorIds.includes(v.parentId),
Object.is,
),
(a, b) => a.treeId === b.treeId && a.ancestorIds.join(',') === b.ancestorIds.join(','),
(a, b) => a.treeId === b.treeId && a.ancestorIds.join(",") === b.ancestorIds.join(","),
);
export const isIndexHoveredFamily = atomFamily(
@@ -76,12 +76,12 @@ export const hoveredParentDepthFamily = atomFamily((treeId: string) =>
);
export const collapsedFamily = atomFamily((workspaceId: string) => {
const key = ['sidebar_collapsed', workspaceId ?? 'n/a'];
const key = ["sidebar_collapsed", workspaceId ?? "n/a"];
return atomWithKVStorage<Record<string, boolean>>(key, {});
});
export const isCollapsedFamily = atomFamily(
({ treeId, itemId = 'n/a' }: { treeId: string; itemId: string | undefined }) =>
({ treeId, itemId = "n/a" }: { treeId: string; itemId: string | undefined }) =>
atom(
// --- getter ---
(get) => !!get(collapsedFamily(treeId))[itemId],
@@ -91,7 +91,7 @@ export const isCollapsedFamily = atomFamily(
const a = collapsedFamily(treeId);
const prevMap = get(a);
const prevValue = !!prevMap[itemId];
const value = typeof next === 'function' ? next(prevValue) : next;
const value = typeof next === "function" ? next(prevValue) : next;
if (value === prevValue) return; // no-op

View File

@@ -1,5 +1,5 @@
import { jotaiStore } from '../../../lib/jotai';
import { collapsedFamily, selectedIdsFamily } from './atoms';
import { jotaiStore } from "../../../lib/jotai";
import { collapsedFamily, selectedIdsFamily } from "./atoms";
export interface TreeNode<T extends { id: string }> {
children?: TreeNode<T>[];

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import type { SelectableTreeNode, TreeNode } from './common';
import { useMemo } from "react";
import type { SelectableTreeNode, TreeNode } from "./common";
export function useSelectableItems<T extends { id: string }>(root: TreeNode<T>) {
return useMemo(() => {