mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-17 23:13:51 +01:00
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>
616 lines
18 KiB
TypeScript
616 lines
18 KiB
TypeScript
import type { EditorView } from "@codemirror/view";
|
|
import type { Color } from "@yaakapp-internal/plugins";
|
|
import classNames from "classnames";
|
|
import type { ReactNode } from "react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { createFastMutation } from "../../hooks/useFastMutation";
|
|
import { useIsEncryptionEnabled } from "../../hooks/useIsEncryptionEnabled";
|
|
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
|
import { copyToClipboard } from "../../lib/copy";
|
|
import {
|
|
analyzeTemplate,
|
|
convertTemplateToInsecure,
|
|
convertTemplateToSecure,
|
|
} from "../../lib/encryption";
|
|
import { generateId } from "../../lib/generateId";
|
|
import {
|
|
setupOrConfigureEncryption,
|
|
withEncryptionEnabled,
|
|
} from "../../lib/setupOrConfigureEncryption";
|
|
import { Button } from "./Button";
|
|
import type { DropdownItem } from "./Dropdown";
|
|
import { Dropdown } from "./Dropdown";
|
|
import type { EditorProps } from "./Editor/Editor";
|
|
import { Editor } from "./Editor/LazyEditor";
|
|
import type { IconProps } from "./Icon";
|
|
import { Icon } from "./Icon";
|
|
import { IconButton } from "./IconButton";
|
|
import { IconTooltip } from "./IconTooltip";
|
|
import { Label } from "./Label";
|
|
import { HStack } from "./Stacks";
|
|
|
|
export type InputProps = Pick<
|
|
EditorProps,
|
|
| "language"
|
|
| "autocomplete"
|
|
| "forcedEnvironmentId"
|
|
| "forceUpdateKey"
|
|
| "disabled"
|
|
| "autoFocus"
|
|
| "autoSelect"
|
|
| "autocompleteVariables"
|
|
| "autocompleteFunctions"
|
|
| "onKeyDown"
|
|
| "readOnly"
|
|
> & {
|
|
className?: string;
|
|
containerClassName?: string;
|
|
inputWrapperClassName?: string;
|
|
defaultValue?: string | null;
|
|
disableObscureToggle?: boolean;
|
|
fullHeight?: boolean;
|
|
hideLabel?: boolean;
|
|
help?: ReactNode;
|
|
label: ReactNode;
|
|
labelClassName?: string;
|
|
labelPosition?: "top" | "left";
|
|
leftSlot?: ReactNode;
|
|
multiLine?: boolean;
|
|
name?: string;
|
|
onBlur?: () => void;
|
|
onChange?: (value: string) => void;
|
|
onFocus?: () => void;
|
|
onPaste?: (value: string) => void;
|
|
onPasteOverwrite?: EditorProps["onPasteOverwrite"];
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
rightSlot?: ReactNode;
|
|
size?: "2xs" | "xs" | "sm" | "md" | "auto";
|
|
stateKey: EditorProps["stateKey"];
|
|
extraExtensions?: EditorProps["extraExtensions"];
|
|
tint?: Color;
|
|
type?: "text" | "password";
|
|
validate?: boolean | ((v: string) => boolean);
|
|
wrapLines?: boolean;
|
|
setRef?: (h: InputHandle | null) => void;
|
|
};
|
|
|
|
export interface InputHandle {
|
|
focus: () => void;
|
|
isFocused: () => boolean;
|
|
value: () => string;
|
|
selectAll: () => void;
|
|
dispatch: EditorView["dispatch"];
|
|
}
|
|
|
|
export function Input({ type, ...props }: InputProps) {
|
|
// If it's a password and template functions are supported (ie. secure(...)) then
|
|
// use the encrypted input component.
|
|
if (type === "password" && props.autocompleteFunctions) {
|
|
return <EncryptionInput {...props} />;
|
|
}
|
|
return <BaseInput type={type} {...props} />;
|
|
}
|
|
|
|
function BaseInput({
|
|
className,
|
|
containerClassName,
|
|
defaultValue,
|
|
disableObscureToggle,
|
|
disabled,
|
|
forceUpdateKey,
|
|
fullHeight,
|
|
help,
|
|
hideLabel,
|
|
inputWrapperClassName,
|
|
label,
|
|
labelClassName,
|
|
labelPosition = "top",
|
|
leftSlot,
|
|
multiLine,
|
|
onBlur,
|
|
onChange,
|
|
onFocus,
|
|
onPaste,
|
|
onPasteOverwrite,
|
|
placeholder,
|
|
readOnly,
|
|
required,
|
|
rightSlot,
|
|
size = "md",
|
|
stateKey,
|
|
tint,
|
|
type = "text",
|
|
validate,
|
|
wrapLines,
|
|
setRef,
|
|
...props
|
|
}: InputProps) {
|
|
const [focused, setFocused] = useState(false);
|
|
const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
|
|
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
|
const editorRef = useRef<EditorView | null>(null);
|
|
const skipNextFocus = useRef<boolean>(false);
|
|
|
|
const handle = useMemo<InputHandle>(
|
|
() => ({
|
|
focus: () => {
|
|
if (editorRef.current == null) return;
|
|
const anchor = editorRef.current.state.doc.length;
|
|
skipNextFocus.current = true;
|
|
editorRef.current.focus();
|
|
editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true });
|
|
},
|
|
isFocused: () => editorRef.current?.hasFocus ?? false,
|
|
value: () => editorRef.current?.state.doc.toString() ?? "",
|
|
dispatch: (...args) => {
|
|
// oxlint-disable-next-line no-explicit-any
|
|
editorRef.current?.dispatch(...(args as any));
|
|
},
|
|
selectAll() {
|
|
if (editorRef.current == null) return;
|
|
editorRef.current.focus();
|
|
editorRef.current.dispatch({
|
|
selection: { anchor: 0, head: editorRef.current.state.doc.length },
|
|
});
|
|
},
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const setEditorRef = useCallback(
|
|
(h: EditorView | null) => {
|
|
editorRef.current = h;
|
|
setRef?.(handle);
|
|
},
|
|
[handle, setRef],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const fn = () => {
|
|
skipNextFocus.current = true;
|
|
};
|
|
window.addEventListener("focus", fn);
|
|
return () => {
|
|
window.removeEventListener("focus", fn);
|
|
};
|
|
}, []);
|
|
|
|
const handleFocus = useCallback(() => {
|
|
if (readOnly) return;
|
|
|
|
if (!skipNextFocus.current) {
|
|
editorRef.current?.dispatch({
|
|
selection: { anchor: 0, head: editorRef.current.state.doc.length },
|
|
});
|
|
}
|
|
|
|
setFocused(true);
|
|
onFocus?.();
|
|
skipNextFocus.current = false;
|
|
}, [onFocus, readOnly]);
|
|
|
|
const handleBlur = useCallback(async () => {
|
|
setFocused(false);
|
|
// Move selection to the end on blur
|
|
const anchor = editorRef.current?.state.doc.length ?? 0;
|
|
editorRef.current?.dispatch({
|
|
selection: { anchor, head: anchor },
|
|
});
|
|
onBlur?.();
|
|
}, [onBlur]);
|
|
|
|
const id = useRef(`input-${generateId()}`);
|
|
const editorClassName = classNames(
|
|
className,
|
|
"!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder",
|
|
);
|
|
|
|
const isValid = useMemo(() => {
|
|
if (required && !validateRequire(defaultValue ?? "")) return false;
|
|
if (typeof validate === "boolean") return validate;
|
|
if (typeof validate === "function" && !validate(defaultValue ?? "")) return false;
|
|
return true;
|
|
}, [required, defaultValue, validate]);
|
|
|
|
const handleChange = useCallback(
|
|
(value: string) => {
|
|
onChange?.(value);
|
|
setHasChanged(true);
|
|
},
|
|
[onChange, setHasChanged],
|
|
);
|
|
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Submit the nearest form on Enter key press
|
|
const handleKeyDown = useCallback(
|
|
(e: KeyboardEvent) => {
|
|
if (e.key !== "Enter") return;
|
|
|
|
const form = wrapperRef.current?.closest("form");
|
|
if (!isValid || form == null) return;
|
|
|
|
form?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true }));
|
|
},
|
|
[isValid],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={wrapperRef}
|
|
className={classNames(
|
|
"pointer-events-auto", // Just in case we're placing in disabled parent
|
|
"w-full",
|
|
fullHeight && "h-full",
|
|
labelPosition === "left" && "flex items-center gap-2",
|
|
labelPosition === "top" && "flex-row gap-0.5",
|
|
)}
|
|
>
|
|
<Label
|
|
htmlFor={id.current}
|
|
help={help}
|
|
required={required}
|
|
visuallyHidden={hideLabel}
|
|
className={classNames(labelClassName)}
|
|
>
|
|
{label}
|
|
</Label>
|
|
<HStack
|
|
alignItems="stretch"
|
|
className={classNames(
|
|
containerClassName,
|
|
fullHeight && "h-full",
|
|
"x-theme-input",
|
|
"relative w-full rounded-md text overflow-hidden",
|
|
"border",
|
|
focused && !disabled ? "border-border-focus" : "border-border",
|
|
disabled && "border-dotted",
|
|
!isValid && hasChanged && "!border-danger",
|
|
size === "md" && "min-h-md",
|
|
size === "sm" && "min-h-sm",
|
|
size === "xs" && "min-h-xs",
|
|
size === "2xs" && "min-h-2xs",
|
|
)}
|
|
>
|
|
{tint != null && (
|
|
<div
|
|
aria-hidden
|
|
className={classNames(
|
|
"absolute inset-0 opacity-5 pointer-events-none",
|
|
tint === "primary" && "bg-primary",
|
|
tint === "secondary" && "bg-secondary",
|
|
tint === "info" && "bg-info",
|
|
tint === "success" && "bg-success",
|
|
tint === "notice" && "bg-notice",
|
|
tint === "warning" && "bg-warning",
|
|
tint === "danger" && "bg-danger",
|
|
)}
|
|
/>
|
|
)}
|
|
{leftSlot}
|
|
<HStack
|
|
className={classNames(
|
|
inputWrapperClassName,
|
|
"w-full min-w-0 px-2",
|
|
fullHeight && "h-full",
|
|
leftSlot ? "pl-0.5 -ml-2" : null,
|
|
rightSlot ? "pr-0.5 -mr-2" : null,
|
|
)}
|
|
>
|
|
<Editor
|
|
setRef={setEditorRef}
|
|
id={id.current}
|
|
hideGutter
|
|
singleLine={!multiLine}
|
|
containerOnly
|
|
stateKey={stateKey}
|
|
wrapLines={wrapLines}
|
|
heightMode="auto"
|
|
onKeyDown={handleKeyDown}
|
|
type={type === "password" && !obscured ? "text" : type}
|
|
defaultValue={defaultValue}
|
|
forceUpdateKey={forceUpdateKey}
|
|
placeholder={placeholder}
|
|
onChange={handleChange}
|
|
onPaste={onPaste}
|
|
onPasteOverwrite={onPasteOverwrite}
|
|
disabled={disabled}
|
|
className={classNames(
|
|
editorClassName,
|
|
multiLine && size === "md" && "py-1.5",
|
|
multiLine && size === "sm" && "py-1",
|
|
)}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
readOnly={readOnly}
|
|
{...props}
|
|
/>
|
|
</HStack>
|
|
{type === "password" && !disableObscureToggle && (
|
|
<IconButton
|
|
title={
|
|
obscured
|
|
? `Show ${typeof label === "string" ? label : "field"}`
|
|
: `Obscure ${typeof label === "string" ? label : "field"}`
|
|
}
|
|
size="xs"
|
|
className={classNames("mr-0.5 !h-auto my-0.5", disabled && "opacity-disabled")}
|
|
color={tint}
|
|
// iconClassName={classNames(
|
|
// tint === 'primary' && 'text-primary',
|
|
// tint === 'secondary' && 'text-secondary',
|
|
// tint === 'info' && 'text-info',
|
|
// tint === 'success' && 'text-success',
|
|
// tint === 'notice' && 'text-notice',
|
|
// tint === 'warning' && 'text-warning',
|
|
// tint === 'danger' && 'text-danger',
|
|
// )}
|
|
iconSize="sm"
|
|
icon={obscured ? "eye" : "eye_closed"}
|
|
onClick={() => setObscured((o) => !o)}
|
|
/>
|
|
)}
|
|
{rightSlot}
|
|
</HStack>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function validateRequire(v: string) {
|
|
return v.length > 0;
|
|
}
|
|
|
|
type PasswordFieldType = "text" | "encrypted";
|
|
|
|
function EncryptionInput({
|
|
defaultValue,
|
|
onChange,
|
|
autocompleteFunctions,
|
|
autocompleteVariables,
|
|
forceUpdateKey: ogForceUpdateKey,
|
|
setRef,
|
|
...props
|
|
}: InputProps) {
|
|
const isEncryptionEnabled = useIsEncryptionEnabled();
|
|
const [state, setState] = useStateWithDeps<{
|
|
fieldType: PasswordFieldType;
|
|
value: string | null;
|
|
security: ReturnType<typeof analyzeTemplate> | null;
|
|
obscured: boolean;
|
|
error: string | null;
|
|
}>(
|
|
{
|
|
fieldType: isEncryptionEnabled ? "encrypted" : "text",
|
|
value: null,
|
|
security: null,
|
|
obscured: true,
|
|
error: null,
|
|
},
|
|
[ogForceUpdateKey],
|
|
);
|
|
|
|
const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
|
|
const inputRef = useRef<InputHandle>(null);
|
|
|
|
useEffect(() => {
|
|
if (state.value != null) {
|
|
// We already configured it
|
|
return;
|
|
}
|
|
|
|
const security = analyzeTemplate(defaultValue ?? "");
|
|
if (analyzeTemplate(defaultValue ?? "") === "global_secured") {
|
|
// Lazily update value to decrypted representation
|
|
templateToInsecure.mutate(defaultValue ?? "", {
|
|
onSuccess: (value) => {
|
|
setState({ fieldType: "encrypted", security, value, obscured: true, error: null });
|
|
// We're calling this here because we want the input to be fully initialized so the caller
|
|
// can do stuff like change the selection.
|
|
requestAnimationFrame(() => setRef?.(inputRef.current));
|
|
},
|
|
onError: (value) => {
|
|
setState({
|
|
fieldType: "encrypted",
|
|
security,
|
|
value: null,
|
|
error: String(value),
|
|
obscured: true,
|
|
});
|
|
},
|
|
});
|
|
} else if (isEncryptionEnabled && !defaultValue) {
|
|
// Default to encrypted field for new encrypted inputs
|
|
setState({ fieldType: "encrypted", security, value: "", obscured: true, error: null });
|
|
requestAnimationFrame(() => setRef?.(inputRef.current));
|
|
} else if (isEncryptionEnabled) {
|
|
// Don't obscure plain text when encryption is enabled
|
|
setState({
|
|
fieldType: "text",
|
|
security,
|
|
value: defaultValue ?? "",
|
|
obscured: false,
|
|
error: null,
|
|
});
|
|
requestAnimationFrame(() => setRef?.(inputRef.current));
|
|
} else {
|
|
// Don't obscure plain text when encryption is disabled
|
|
setState({
|
|
fieldType: "text",
|
|
security,
|
|
value: defaultValue ?? "",
|
|
obscured: true,
|
|
error: null,
|
|
});
|
|
requestAnimationFrame(() => setRef?.(inputRef.current));
|
|
}
|
|
}, [defaultValue, isEncryptionEnabled, setRef, setState, state.value]);
|
|
|
|
const handleChange = useCallback(
|
|
(value: string, fieldType: PasswordFieldType) => {
|
|
if (fieldType === "encrypted") {
|
|
templateToSecure.mutate(value, { onSuccess: (value) => onChange?.(value) });
|
|
} else {
|
|
onChange?.(value);
|
|
}
|
|
setState((s) => {
|
|
// We can't analyze when encrypted because we don't have the raw value, so assume it's secured
|
|
const security = fieldType === "encrypted" ? "global_secured" : analyzeTemplate(value);
|
|
// Reset obscured value when the field type is being changed
|
|
const obscured = fieldType === s.fieldType ? s.obscured : fieldType !== "text";
|
|
return { fieldType, value, security, obscured, error: s.error };
|
|
});
|
|
},
|
|
[onChange, setState],
|
|
);
|
|
|
|
const handleInputChange = useCallback(
|
|
(value: string) => {
|
|
if (state.fieldType != null) {
|
|
handleChange(value, state.fieldType);
|
|
}
|
|
},
|
|
[handleChange, state],
|
|
);
|
|
|
|
const setInputRef = useCallback((h: InputHandle | null) => {
|
|
inputRef.current = h;
|
|
}, []);
|
|
|
|
const handleFieldTypeChange = useCallback(
|
|
(newFieldType: PasswordFieldType) => {
|
|
const { value, fieldType } = state;
|
|
if (value == null || fieldType === newFieldType) {
|
|
return;
|
|
}
|
|
|
|
withEncryptionEnabled(async () => {
|
|
const newValue = await convertTemplateToInsecure(value);
|
|
handleChange(newValue, newFieldType);
|
|
});
|
|
},
|
|
[handleChange, state],
|
|
);
|
|
|
|
const dropdownItems = useMemo<DropdownItem[]>(
|
|
() => [
|
|
{
|
|
label: state.obscured ? "Show" : "Hide",
|
|
disabled: isEncryptionEnabled && state.fieldType === "text",
|
|
leftSlot: <Icon icon={state.obscured ? "eye" : "eye_closed"} />,
|
|
onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })),
|
|
},
|
|
{
|
|
label: "Copy",
|
|
leftSlot: <Icon icon="copy" />,
|
|
hidden: !state.value,
|
|
onSelect: () => copyToClipboard(state.value ?? ""),
|
|
},
|
|
{ type: "separator" },
|
|
{
|
|
label: state.fieldType === "text" ? "Encrypt Field" : "Decrypt Field",
|
|
leftSlot: <Icon icon={state.fieldType === "text" ? "lock" : "lock_open"} />,
|
|
onSelect: () => handleFieldTypeChange(state.fieldType === "text" ? "encrypted" : "text"),
|
|
},
|
|
],
|
|
[
|
|
handleFieldTypeChange,
|
|
isEncryptionEnabled,
|
|
setState,
|
|
state.fieldType,
|
|
state.obscured,
|
|
state.value,
|
|
],
|
|
);
|
|
|
|
let tint: InputProps["tint"];
|
|
if (!isEncryptionEnabled) {
|
|
tint = undefined;
|
|
} else if (state.fieldType === "encrypted") {
|
|
tint = "info";
|
|
} else if (state.security === "local_secured") {
|
|
tint = "secondary";
|
|
} else if (state.security === "insecure") {
|
|
tint = "notice";
|
|
}
|
|
|
|
const rightSlot = useMemo(() => {
|
|
let icon: IconProps["icon"];
|
|
if (isEncryptionEnabled) {
|
|
icon = state.security === "insecure" ? "shield_off" : "shield_check";
|
|
} else {
|
|
icon = state.obscured ? "eye_closed" : "eye";
|
|
}
|
|
return (
|
|
<HStack className="h-auto m-0.5">
|
|
<Dropdown items={dropdownItems}>
|
|
<Button
|
|
size="sm"
|
|
variant="border"
|
|
color={tint}
|
|
aria-label="Configure encryption"
|
|
className={classNames(
|
|
"flex items-center justify-center !h-full !px-1",
|
|
"opacity-70", // Makes it a bit subtler
|
|
props.disabled && "!opacity-disabled",
|
|
)}
|
|
>
|
|
<HStack space={0.5}>
|
|
<Icon size="sm" title="Configure encryption" icon={icon} />
|
|
<Icon size="xs" title="Configure encryption" icon="chevron_down" />
|
|
</HStack>
|
|
</Button>
|
|
</Dropdown>
|
|
</HStack>
|
|
);
|
|
}, [dropdownItems, isEncryptionEnabled, props.disabled, state.obscured, state.security, tint]);
|
|
|
|
const type = state.obscured ? "password" : "text";
|
|
|
|
if (state.error) {
|
|
return (
|
|
<Button
|
|
variant="border"
|
|
color="danger"
|
|
size={props.size}
|
|
className="text-sm"
|
|
rightSlot={<IconTooltip tabIndex={-1} content={state.error} icon="alert_triangle" />}
|
|
onClick={() => {
|
|
setupOrConfigureEncryption();
|
|
}}
|
|
>
|
|
{state.error.replace(/^Render Error: /i, "")}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<BaseInput
|
|
setRef={setInputRef}
|
|
disableObscureToggle
|
|
autocompleteFunctions={autocompleteFunctions}
|
|
autocompleteVariables={autocompleteVariables}
|
|
defaultValue={state.value ?? ""}
|
|
forceUpdateKey={forceUpdateKey}
|
|
onChange={handleInputChange}
|
|
tint={tint}
|
|
type={type}
|
|
rightSlot={rightSlot}
|
|
disabled={state.error != null}
|
|
className="pr-1.5" // To account for encryption dropdown
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const templateToSecure = createFastMutation({
|
|
mutationKey: ["template-to-secure"],
|
|
mutationFn: convertTemplateToSecure,
|
|
});
|
|
|
|
const templateToInsecure = createFastMutation({
|
|
mutationKey: ["template-to-insecure"],
|
|
mutationFn: convertTemplateToInsecure,
|
|
disableToastError: true,
|
|
});
|