This commit is contained in:
Gregory Schier
2025-01-26 13:13:45 -08:00
committed by GitHub
parent 82b1ad35ff
commit f678593903
99 changed files with 3492 additions and 1583 deletions

View File

@@ -7,7 +7,7 @@ interface Props {
color?: 'primary' | 'secondary' | 'success' | 'notice' | 'warning' | 'danger' | 'info';
}
export function Banner({ children, className, color = 'secondary' }: Props) {
export function Banner({ children, className, color }: Props) {
return (
<div>
<div
@@ -16,7 +16,7 @@ export function Banner({ children, className, color = 'secondary' }: Props) {
`x-theme-banner--${color}`,
'whitespace-pre-wrap',
'border border-dashed border-border bg-surface',
'px-3 py-2 rounded select-auto cursor-text',
'px-3 py-2 rounded select-auto',
'overflow-x-auto text-text',
)}
>

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { type ReactNode } from 'react';
import { trackEvent } from '../../lib/analytics';
import { Icon } from './Icon';
import { HStack } from './Stacks';
@@ -26,17 +26,15 @@ export function Checkbox({
event,
}: CheckboxProps) {
return (
<HStack
as="label"
space={2}
className={classNames(className, 'text-text mr-auto', disabled && 'opacity-disabled')}
>
<HStack as="label" space={2} className={classNames(className, 'text-text mr-auto')}>
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex')}>
<input
aria-hidden
className={classNames(
'appearance-none w-4 h-4 flex-shrink-0 border border-border',
'rounded hocus:border-border-focus hocus:bg-focus/[5%] outline-none ring-0',
'rounded outline-none ring-0',
!disabled && 'hocus:border-border-focus hocus:bg-focus/[5%] ',
disabled && 'border-dotted',
)}
type="checkbox"
disabled={disabled}
@@ -54,7 +52,7 @@ export function Checkbox({
/>
</div>
</div>
{!hideLabel && title}
<span className={classNames(disabled && 'opacity-disabled')}>{!hideLabel && title}</span>
</HStack>
);
}

View File

@@ -44,7 +44,6 @@ export type DropdownItemSeparator = {
};
export type DropdownItemDefault = {
key: string;
type?: 'default';
label: ReactNode;
keepOpen?: boolean;
@@ -465,11 +464,12 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
return (
<>
{items.map(
(item) =>
(item, i) =>
item.type !== 'separator' &&
!item.hotKeyLabelOnly && (
!item.hotKeyLabelOnly &&
item.hotKeyAction && (
<MenuItemHotKey
key={item.key}
key={`${item.hotKeyAction}::${i}`}
onSelect={handleSelect}
item={item}
action={item.hotKeyAction}
@@ -542,7 +542,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={item.key}
key={`item_${i}`}
item={item}
/>
);

View File

@@ -5,6 +5,7 @@
@apply w-full block text-base;
/* Regular cursor */
.cm-cursor {
@apply border-text !important;
/* Widen the cursor a bit */
@@ -12,6 +13,7 @@
}
/* Vim-mode cursor */
.cm-fat-cursor {
@apply outline-0 bg-text !important;
@apply text-surface !important;
@@ -181,7 +183,7 @@
@apply hidden !important;
}
&.cm-singleline .cm-line {
&.cm-singleline * {
@apply cursor-default;
}
}

View File

@@ -1,4 +1,4 @@
import { defaultKeymap, historyField } from '@codemirror/commands';
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';
@@ -34,14 +34,22 @@ import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton';
import { HStack } from '../Stacks';
import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import {
baseExtensions,
getLanguageExtension,
multiLineExtensions,
readonlyExtensions,
} from './extensions';
import type { GenericCompletionConfig } from './genericCompletion';
import { singleLineExtensions } from './singleLine';
// VSCode's Tab actions mess with the single-line editor tab actions, so remove it.
const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== 'Tab');
const keymapExtensions: Record<EditorKeymap, Extension> = {
vim: vim(),
emacs: emacs(),
vscode: keymap.of(vscodeKeymap),
vscode: keymap.of(vsCodeWithoutTab),
default: [],
};
@@ -68,6 +76,7 @@ export interface EditorProps {
onKeyDown?: (e: KeyboardEvent) => void;
singleLine?: boolean;
wrapLines?: boolean;
disableTabIndent?: boolean;
format?: (v: string) => Promise<string>;
autocomplete?: GenericCompletionConfig;
autocompleteVariables?: boolean;
@@ -85,9 +94,9 @@ const emptyExtension: Extension = [];
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
readOnly,
type = 'text',
type,
heightMode,
language = 'text',
language,
autoFocus,
autoSelect,
placeholder,
@@ -101,6 +110,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onBlur,
onKeyDown,
className,
disabled,
singleLine,
format,
autocomplete,
@@ -108,6 +118,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
autocompleteVariables,
actions,
wrapLines,
disableTabIndent,
hideGutter,
stateKey,
}: EditorProps,
@@ -122,6 +133,20 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
wrapLines = settings.editorSoftWrap;
}
if (disabled) {
readOnly = true;
}
if (
singleLine ||
language == null ||
language === 'text' ||
language === 'url' ||
language === 'pairs'
) {
disableTabIndent = true;
}
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view, []);
@@ -166,7 +191,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
useEffect(
function configurePlaceholder() {
if (cm.current === null) return;
const ext = placeholderExt(placeholderElFromText(placeholder ?? '', type));
const ext = placeholderExt(placeholderElFromText(placeholder, type));
const effects = placeholderCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
@@ -209,6 +234,23 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
[wrapLines],
);
// Update tab indent
const tabIndentCompartment = useRef(new Compartment());
useEffect(
function configureTabIndent() {
if (cm.current === null) return;
const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (disableTabIndent && current !== emptyExtension) return; // Nothing to do
if (!disableTabIndent && current === emptyExtension) return; // Nothing to do
const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension;
const effects = tabIndentCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
[disableTabIndent],
);
const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
@@ -342,9 +384,12 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const extensions = [
languageCompartment.of(langExt),
placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder ?? '', type)),
placeholderExt(placeholderElFromText(placeholder, type)),
),
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),
tabIndentCompartment.current.of(
!disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,
),
keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions['default'],
),
@@ -475,6 +520,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
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',
@@ -557,10 +603,8 @@ function getExtensions({
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? [multiLineExtensions({ hideGutter })] : []),
...(readOnly
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
...(readOnly ? readonlyExtensions : []),
// ------------------------ //
// Things that must be last //
@@ -580,13 +624,15 @@ function getExtensions({
];
}
const placeholderElFromText = (text: string, type: EditorProps['type']) => {
const placeholderElFromText = (text: string | undefined, type: EditorProps['type']) => {
const el = document.createElement('div');
if (type === 'password') {
// Will be obscured (dots) so just needs to be something to take up space
el.innerHTML = 'something-cool';
el.setAttribute('aria-hidden', 'true');
} else {
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
// to do with how Yaak "hacks" it with CSS for single line input.
el.innerHTML = text ? text.replaceAll('\n', '<br/>') : ' ';
}
return el;
@@ -596,8 +642,8 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
if (!stateKey || state == null) return;
const stateObj = state.toJSON(stateFields);
// Save state in sessionStorage by removing doc and saving the hash of it instead
// This will be checked on restore and put back in if it matches
// Save state in sessionStorage by removing doc and saving the hash of it instead.
// This will be checked on restore and put back in if it matches.
stateObj.docHash = md5(stateObj.doc);
delete stateObj.doc;

View File

@@ -4,7 +4,7 @@ import {
closeBracketsKeymap,
completionKeymap,
} from '@codemirror/autocomplete';
import { history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { history, historyKeymap } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
@@ -142,6 +142,11 @@ export const baseExtensions = [
keymap.of([...historyKeymap, ...completionKeymap]),
];
export const readonlyExtensions = [
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({ tabindex: '-1' }),
];
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
hideGutter
? []
@@ -208,5 +213,5 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
rectangularSelection(),
crosshairCursor(),
highlightActiveLineGutter(),
keymap.of([indentWithTab, ...closeBracketsKeymap, ...searchKeymap, ...foldKeymap, ...lintKeymap]),
keymap.of([...closeBracketsKeymap, ...searchKeymap, ...foldKeymap, ...lintKeymap]),
];

View File

@@ -1,16 +1,5 @@
import type { CompletionContext } from '@codemirror/autocomplete';
export interface GenericCompletionOption {
label: string;
type: 'constant' | 'variable';
detail?: string;
info?: string;
/** When given, should be a number from -99 to 99 that adjusts
* how this completion is ranked compared to other completions
* that match the input as well as this one. A negative number
* moves it down the list, a positive number moves it up. */
boost?: number;
}
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
export interface GenericCompletionConfig {
minMatch?: number;

View File

@@ -16,6 +16,7 @@ export type InputProps = Pick<
| 'useTemplating'
| 'autocomplete'
| 'forceUpdateKey'
| 'disabled'
| 'autoFocus'
| 'autoSelect'
| 'autocompleteVariables'
@@ -75,6 +76,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
readOnly,
stateKey,
multiLine,
disabled,
...props
}: InputProps,
ref,
@@ -82,18 +84,26 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [stateKey, forceUpdateKey]);
const editorRef = useRef<EditorView | null>(null);
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
const handleFocus = useCallback(() => {
if (readOnly) return;
setFocused(true);
// Select all text on focus
editorRef.current?.dispatch({
selection: { anchor: 0, head: editorRef.current.state.doc.length },
});
onFocus?.();
}, [onFocus, readOnly]);
const handleBlur = useCallback(() => {
setFocused(false);
editorRef.current?.dispatch({ selection: { anchor: 0 } });
// Move selection to the end on blur
editorRef.current?.dispatch({
selection: { anchor: editorRef.current.state.doc.length },
});
onBlur?.();
}, [onBlur]);
@@ -114,13 +124,14 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
(value: string) => {
setCurrentValue(value);
onChange?.(value);
setHasChanged(true);
},
[onChange],
[onChange, setHasChanged],
);
const wrapperRef = useRef<HTMLDivElement>(null);
// Submit nearest form on Enter key press
// Submit the nearest form on Enter key press
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
@@ -145,7 +156,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
>
<Label
htmlFor={id.current}
optional={!required}
required={required}
visuallyHidden={hideLabel}
className={classNames(labelClassName)}
>
@@ -158,8 +169,9 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
'x-theme-input',
'relative w-full rounded-md text',
'border',
focused ? 'border-border-focus' : 'border-border',
!isValid && '!border-danger',
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',
@@ -190,7 +202,12 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
onChange={handleChange}
onPaste={onPaste}
onPasteOverwrite={onPasteOverwrite}
className={classNames(editorClassName, multiLine && 'py-1.5')}
disabled={disabled}
className={classNames(
editorClassName,
multiLine && size === 'md' && 'py-1.5',
multiLine && size === 'sm' && 'py-1',
)}
onFocus={handleFocus}
onBlur={handleBlur}
readOnly={readOnly}
@@ -201,7 +218,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
<IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"
className="mr-0.5 group/obscure !h-auto my-0.5"
className={classNames("mr-0.5 group/obscure !h-auto my-0.5", disabled && 'opacity-disabled')}
iconClassName="text-text-subtle group-hover/obscure:text"
iconSize="sm"
icon={obscured ? 'eye' : 'eye_closed'}

View File

@@ -4,30 +4,32 @@ import type { HTMLAttributes } from 'react';
export function Label({
htmlFor,
className,
optional,
children,
visuallyHidden,
otherTags = [],
tags = [],
required,
...props
}: HTMLAttributes<HTMLLabelElement> & {
htmlFor: string;
optional?: boolean;
otherTags?: string[];
required?: boolean;
tags?: string[];
visuallyHidden?: boolean;
}) {
const tags = optional ? ['optional', ...otherTags] : otherTags;
return (
<label
htmlFor={htmlFor}
className={classNames(
className,
visuallyHidden && 'sr-only',
'flex-shrink-0',
'flex-shrink-0 text-sm',
'text-text-subtle whitespace-nowrap flex items-center gap-1',
)}
htmlFor={htmlFor}
{...props}
>
{children}
<span>
{children}
{required === true && <span className="text-text-subtlest">*</span>}
</span>
{tags.map((tag, i) => (
<span key={i} className="text-xs text-text-subtlest">
({tag})

View File

@@ -168,7 +168,8 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
);
const handleChange = useCallback(
(pair: PairWithId) => setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
(pair: PairWithId) =>
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
[setPairsAndSave],
);
@@ -344,7 +345,6 @@ function PairEditorRow({
const deleteItems = useMemo(
(): DropdownItem[] => [
{
key: 'delete',
label: 'Delete',
onSelect: handleDelete,
color: 'danger',
@@ -570,7 +570,6 @@ function FileActionsDropdown({
const extraItems = useMemo<DropdownItem[]>(
() => [
{
key: 'mime',
label: 'Set Content-Type',
leftSlot: <Icon icon="pencil" />,
hidden: !pair.isFile,
@@ -589,7 +588,6 @@ function FileActionsDropdown({
},
},
{
key: 'clear-file',
label: 'Unset File',
leftSlot: <Icon icon="x" />,
hidden: pair.isFile,
@@ -598,7 +596,6 @@ function FileActionsDropdown({
},
},
{
key: 'delete',
label: 'Delete',
onSelect: onDelete,
variant: 'danger',

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { HTMLAttributes, FocusEvent } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import type { FocusEvent, HTMLAttributes } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
@@ -41,8 +41,8 @@ export function PlainInput({
onFocusRaw,
}: PlainInputProps) {
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const [hasChanged, setHasChanged] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -71,19 +71,19 @@ export function PlainInput({
'px-2 text-xs font-mono cursor-text',
);
const isValid = useMemo(() => {
if (required && !validateRequire(currentValue)) return false;
if (typeof validate === 'boolean') return validate;
if (typeof validate === 'function' && !validate(currentValue)) return false;
return true;
}, [required, currentValue, validate]);
const handleChange = useCallback(
(value: string) => {
setCurrentValue(value);
onChange?.(value);
setHasChanged(true);
const isValid = (value: string) => {
if (required && !validateRequire(value)) return false;
if (typeof validate === 'boolean') return validate;
if (typeof validate === 'function' && !validate(value)) return false;
return true;
};
inputRef.current?.setCustomValidity(isValid(value) ? '' : 'Invalid value');
},
[onChange],
[onChange, required, validate],
);
const wrapperRef = useRef<HTMLDivElement>(null);
@@ -98,12 +98,7 @@ export function PlainInput({
labelPosition === 'top' && 'flex-row gap-0.5',
)}
>
<Label
htmlFor={id}
className={labelClassName}
visuallyHidden={hideLabel}
optional={!required}
>
<Label htmlFor={id} className={labelClassName} visuallyHidden={hideLabel} required={required}>
{label}
</Label>
<HStack
@@ -114,7 +109,7 @@ export function PlainInput({
'relative w-full rounded-md text',
'border',
focused ? 'border-border-focus' : 'border-border-subtle',
!isValid && '!border-danger',
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',

View File

@@ -2,7 +2,7 @@ import type { PromptTextRequest } from '@yaakapp-internal/plugins';
import type { FormEvent, ReactNode } from 'react';
import { useCallback, useState } from 'react';
import { Button } from './Button';
import { Input } from './Input';
import { PlainInput } from './PlainInput';
import { HStack } from './Stacks';
export type PromptProps = Omit<PromptTextRequest, 'id' | 'title' | 'description'> & {
@@ -35,7 +35,7 @@ export function Prompt({
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
onSubmit={handleSubmit}
>
<Input
<PlainInput
hideLabel
autoSelect
required={required}
@@ -43,7 +43,6 @@ export function Prompt({
label={label}
defaultValue={defaultValue}
onChange={setValue}
stateKey={null}
/>
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">

View File

@@ -23,12 +23,14 @@ export interface SelectProps<T extends string> {
size?: ButtonProps['size'];
className?: string;
event?: string;
disabled?: boolean;
}
export function Select<T extends string>({
labelPosition = 'top',
name,
labelClassName,
disabled,
hideLabel,
label,
value,
@@ -72,7 +74,8 @@ export function Select<T extends string>({
'w-full rounded-md text text-sm font-mono',
'pl-2',
'border',
focused ? 'border-border-focus' : 'border-border',
focused && !disabled ? 'border-border-focus' : 'border-border',
disabled && 'border-dotted',
isInvalidSelection && 'border-danger',
size === 'xs' && 'h-xs',
size === 'sm' && 'h-sm',
@@ -86,7 +89,8 @@ export function Select<T extends string>({
onChange={(e) => handleChange(e.target.value as T)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={classNames('pr-7 w-full outline-none bg-transparent')}
className={classNames('pr-7 w-full outline-none bg-transparent disabled:opacity-disabled')}
disabled={disabled}
>
{isInvalidSelection && <option value={'__NONE__'}>-- Select an Option --</option>}
{options.map((o) => {
@@ -109,6 +113,7 @@ export function Select<T extends string>({
variant="border"
size={size}
leftSlot={leftSlot}
disabled={disabled}
forDropdown
>
{options.find((o) => o.type !== 'separator' && o.value === value)?.label ?? '--'}

View File

@@ -20,9 +20,8 @@ export interface ToastProps {
color?: ShowToastRequest['color'];
}
const ICONS: Record<NonNullable<ToastProps['color']>, IconProps['icon'] | null> = {
const ICONS: Record<NonNullable<ToastProps['color'] | 'custom'>, IconProps['icon'] | null> = {
custom: null,
default: 'info',
danger: 'alert_triangle',
info: 'info',
notice: 'alert_triangle',
@@ -42,9 +41,8 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
{},
[open],
);
color = color ?? 'default';
const toastIcon = icon ?? (color in ICONS && ICONS[color]);
const toastIcon = icon ?? (color && color in ICONS && ICONS[color]);
return (
<motion.div