+
-
+
{!hideName && {environment?.name}
}
{isEncryptionEnabled ? (
!allVariableAreEncrypted ? (
@@ -146,6 +188,7 @@ export function EnvironmentEditor({
)}
(null);
+ const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
+ editorViewRef.current = h;
+ }, []);
// Find the schema for the selected service and method and update the editor
useEffect(() => {
@@ -167,6 +170,7 @@ export function GrpcEditor({
return (
- // Doing this explicitly seems to work better than the default behavior for some reason
- containerRef.current?.querySelector(
- [
- 'a[href]',
- 'input:not([disabled])',
- 'select:not([disabled])',
- 'textarea:not([disabled])',
- 'button:not([disabled])',
- '[tabindex]:not([tabindex="-1"])',
- '[contenteditable]:not([contenteditable="false"])',
- ].join(', '),
- ) ?? false,
}}
>
(null);
const treeRef = useRef(null);
const filterRef = useRef(null);
+ const setFilterRef = useCallback((h: InputHandle | null) => {
+ filterRef.current = h;
+ }, []);
const allHidden = useMemo(() => {
if (tree?.children?.length === 0) return false;
else if (filterText) return tree?.children?.every((c) => c.hidden);
@@ -434,7 +437,7 @@ function Sidebar({ className }: { className?: string }) {
<>
(null);
const [isFocused, setIsFocused] = useState(false);
+ const handleInitInputRef = useCallback((h: InputHandle | null) => {
+ inputRef.current = h;
+ }, []);
+
useHotKey('url_bar.focus', () => {
inputRef.current?.selectAll();
});
@@ -59,7 +63,7 @@ export const UrlBar = memo(function UrlBar({
return (
);
-});
+}
function getExtensions({
stateKey,
diff --git a/src-web/components/core/Editor/LazyEditor.tsx b/src-web/components/core/Editor/LazyEditor.tsx
index 5b6adf31..c2ae1ab1 100644
--- a/src-web/components/core/Editor/LazyEditor.tsx
+++ b/src-web/components/core/Editor/LazyEditor.tsx
@@ -1,13 +1,12 @@
-import type { EditorView } from '@codemirror/view';
-import { forwardRef, lazy, Suspense } from 'react';
+import { lazy, Suspense } from 'react';
import type { EditorProps } from './Editor';
const Editor_ = lazy(() => import('./Editor').then((m) => ({ default: m.Editor })));
-export const Editor = forwardRef(function LazyEditor(props, ref) {
+export function Editor(props: EditorProps) {
return (
-
+
);
-});
+}
diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx
index 67e6e81e..3a0d85bc 100644
--- a/src-web/components/core/Input.tsx
+++ b/src-web/components/core/Input.tsx
@@ -2,15 +2,7 @@ import type { EditorView } from '@codemirror/view';
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { ReactNode } from 'react';
-import {
- forwardRef,
- useCallback,
- useEffect,
- useImperativeHandle,
- useMemo,
- useRef,
- useState,
-} 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';
@@ -80,6 +72,7 @@ export type InputProps = Pick<
type?: 'text' | 'password';
validate?: boolean | ((v: string) => boolean);
wrapLines?: boolean;
+ setRef?: (h: InputHandle | null) => void;
};
export interface InputHandle {
@@ -90,80 +83,86 @@ export interface InputHandle {
dispatch: EditorView['dispatch'];
}
-export const Input = forwardRef(function Input({ type, ...props }, ref) {
+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 ;
} else {
- return ;
+ return ;
}
-});
+}
-const BaseInput = forwardRef(function InputBase(
- {
- 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,
- ...props
- }: InputProps,
- ref,
-) {
+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(false, [forceUpdateKey]);
const editorRef = useRef(null);
- const inputHandle = useMemo(
- () => ({
- focus: () => {
- editorRef.current?.focus();
- },
- isFocused: () => editorRef.current?.hasFocus ?? false,
- value: () => editorRef.current?.state.doc.toString() ?? '',
- dispatch: (...args) => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- editorRef.current?.dispatch(...(args as any));
- },
- selectAll() {
- const head = editorRef.current?.state.doc.length ?? 0;
- editorRef.current?.dispatch({
- selection: { anchor: 0, head },
- });
- editorRef.current?.focus();
- },
- }),
- [],
- );
+ const initEditorRef = useCallback(
+ (cm: EditorView | null) => {
+ editorRef.current = cm;
+ if (cm == null) {
+ setRef?.(null);
+ return;
+ }
+ const handle: InputHandle = {
+ focus: () => {
+ cm.focus();
+ cm.dispatch({ selection: { anchor: cm.state.doc.length, head: cm.state.doc.length } });
+ },
+ isFocused: () => cm.hasFocus ?? false,
+ value: () => cm.state.doc.toString() ?? '',
+ dispatch: (...args) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ cm.dispatch(...(args as any));
+ },
+ selectAll() {
+ cm.focus();
- useImperativeHandle(ref, (): InputHandle => inputHandle, [inputHandle]);
+ cm.dispatch({
+ selection: { anchor: 0, head: cm.state.doc.length },
+ });
+ },
+ };
+
+ setRef?.(handle);
+ },
+ [setRef],
+ );
const lastWindowFocus = useRef(0);
useEffect(() => {
@@ -300,7 +299,7 @@ const BaseInput = forwardRef(function InputBase(
)}
>
(function InputBase(
);
-});
+}
function validateRequire(v: string) {
return v.length > 0;
@@ -365,8 +364,9 @@ function EncryptionInput({
autocompleteFunctions,
autocompleteVariables,
forceUpdateKey: ogForceUpdateKey,
+ setRef,
...props
-}: Omit
) {
+}: InputProps) {
const isEncryptionEnabled = useIsEncryptionEnabled();
const [state, setState] = useStateWithDeps<{
fieldType: PasswordFieldType;
@@ -374,11 +374,19 @@ function EncryptionInput({
security: ReturnType | null;
obscured: boolean;
error: string | null;
- }>({ fieldType: 'text', value: null, security: null, obscured: true, error: null }, [
- ogForceUpdateKey,
- ]);
+ }>(
+ {
+ fieldType: isEncryptionEnabled ? 'encrypted' : 'text',
+ value: null,
+ security: null,
+ obscured: true,
+ error: null,
+ },
+ [ogForceUpdateKey],
+ );
const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
+ const inputRef = useRef(null);
useEffect(() => {
if (state.value != null) {
@@ -392,6 +400,9 @@ function EncryptionInput({
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.
+ setRef?.(inputRef.current);
},
onError: (value) => {
setState({
@@ -406,6 +417,7 @@ function EncryptionInput({
} else if (isEncryptionEnabled && !defaultValue) {
// Default to encrypted field for new encrypted inputs
setState({ fieldType: 'encrypted', security, value: '', obscured: true, error: null });
+ setRef?.(inputRef.current);
} else if (isEncryptionEnabled) {
// Don't obscure plain text when encryption is enabled
setState({
@@ -424,8 +436,9 @@ function EncryptionInput({
obscured: true,
error: null,
});
+ setRef?.(inputRef.current);
}
- }, [defaultValue, isEncryptionEnabled, setState, state.value]);
+ }, [defaultValue, isEncryptionEnabled, setRef, setState, state.value]);
const handleChange = useCallback(
(value: string, fieldType: PasswordFieldType) => {
@@ -454,6 +467,10 @@ function EncryptionInput({
[handleChange, state],
);
+ const handleSetInputRef = useCallback((h: InputHandle | null) => {
+ inputRef.current = h;
+ }, []);
+
const handleFieldTypeChange = useCallback(
(newFieldType: PasswordFieldType) => {
const { value, fieldType } = state;
@@ -563,6 +580,7 @@ function EncryptionInput({
return (
void;
pairs: Pair[];
stateKey: InputProps['stateKey'];
+ setRef?: (n: PairEditorHandle) => void;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
valueAutocompleteFunctions?: boolean;
valueAutocompleteVariables?: boolean | 'environment';
@@ -89,33 +82,29 @@ export type PairWithId = Pair & {
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 50;
-export const PairEditor = forwardRef(function PairEditor(
- {
- allowFileValues,
- allowMultilineValues,
- className,
- forcedEnvironmentId,
- forceUpdateKey,
- nameAutocomplete,
- nameAutocompleteFunctions,
- nameAutocompleteVariables,
- namePlaceholder,
- nameValidate,
- noScroll,
- onChange,
- pairs: originalPairs,
- stateKey,
- valueAutocomplete,
- valueAutocompleteFunctions,
- valueAutocompleteVariables,
- valuePlaceholder,
- valueType,
- valueValidate,
- }: PairEditorProps,
- ref,
-) {
- const [forceFocusNamePairId, setForceFocusNamePairId] = useState(null);
- const [forceFocusValuePairId, setForceFocusValuePairId] = useState(null);
+export function PairEditor({
+ allowFileValues,
+ allowMultilineValues,
+ className,
+ forcedEnvironmentId,
+ forceUpdateKey,
+ nameAutocomplete,
+ nameAutocompleteFunctions,
+ nameAutocompleteVariables,
+ namePlaceholder,
+ nameValidate,
+ noScroll,
+ onChange,
+ pairs: originalPairs,
+ stateKey,
+ valueAutocomplete,
+ valueAutocompleteFunctions,
+ valueAutocompleteVariables,
+ valuePlaceholder,
+ valueType,
+ valueValidate,
+ setRef,
+}: PairEditorProps) {
const [hoveredIndex, setHoveredIndex] = useState(null);
const [isDragging, setIsDragging] = useState(null);
const [pairs, setPairs] = useState([]);
@@ -124,15 +113,30 @@ export const PairEditor = forwardRef(function Pa
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey();
- useImperativeHandle(
- ref,
+ const rowsRef = useRef>({});
+
+ const handle = useMemo(
() => ({
- focusValue(index: number) {
- const id = pairs[index]?.id ?? 'n/a';
- setForceFocusValuePairId(id);
+ focusName(id: string) {
+ rowsRef.current[id]?.focusName();
+ },
+ focusValue(id: string) {
+ rowsRef.current[id]?.focusValue();
},
}),
- [pairs],
+ [],
+ );
+
+ const initPairEditorRow = useCallback(
+ (id: string, n: RowHandle | null) => {
+ rowsRef.current[id] = n;
+ const ready =
+ Object.values(rowsRef.current).filter((v) => v != null).length === pairs.length - 1; // Ignore the last placeholder pair
+ if (ready) {
+ setRef?.(handle);
+ }
+ },
+ [handle, pairs.length, setRef],
);
useEffect(() => {
@@ -179,21 +183,19 @@ export const PairEditor = forwardRef(function Pa
if (focusPrevious) {
const index = pairs.findIndex((p) => p.id === pair.id);
const id = pairs[index - 1]?.id ?? null;
- setForceFocusNamePairId(id);
+ rowsRef.current[id ?? 'n/a']?.focusName();
}
return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
},
- [setPairsAndSave, setForceFocusNamePairId, pairs],
+ [setPairsAndSave, pairs],
);
const handleFocusName = useCallback((pair: Pair) => {
- setForceFocusNamePairId(null); // Remove focus override when something focused
- setForceFocusValuePairId(null); // Remove focus override when something focused
setPairs((pairs) => {
const isLast = pair.id === pairs[pairs.length - 1]?.id;
if (isLast) {
const prevPair = pairs[pairs.length - 1];
- setTimeout(() => setForceFocusNamePairId(prevPair?.id ?? null));
+ rowsRef.current[prevPair?.id ?? 'n/a']?.focusName();
return [...pairs, emptyPair()];
} else {
return pairs;
@@ -202,13 +204,11 @@ export const PairEditor = forwardRef(function Pa
}, []);
const handleFocusValue = useCallback((pair: Pair) => {
- setForceFocusNamePairId(null); // Remove focus override when something focused
- setForceFocusValuePairId(null); // Remove focus override when something focused
setPairs((pairs) => {
const isLast = pair.id === pairs[pairs.length - 1]?.id;
if (isLast) {
const prevPair = pairs[pairs.length - 1];
- setTimeout(() => setForceFocusValuePairId(prevPair?.id ?? null));
+ rowsRef.current[prevPair?.id ?? 'n/a']?.focusValue();
return [...pairs, emptyPair()];
} else {
return pairs;
@@ -301,12 +301,11 @@ export const PairEditor = forwardRef(function Pa
{hoveredIndex === i && }
(function Pa
);
-});
+}
type PairEditorRowProps = {
className?: string;
@@ -369,6 +368,7 @@ type PairEditorRowProps = {
disableDrag?: boolean;
index: number;
isDraggingGlobal?: boolean;
+ setRef?: (id: string, n: RowHandle | null) => void;
} & Pick<
PairEditorProps,
| 'allowFileValues'
@@ -389,14 +389,17 @@ type PairEditorRowProps = {
| 'valueValidate'
>;
+interface RowHandle {
+ focusName(): void;
+ focusValue(): void;
+}
+
export function PairEditorRow({
allowFileValues,
allowMultilineValues,
className,
disableDrag,
disabled,
- forceFocusNamePairId,
- forceFocusValuePairId,
forceUpdateKey,
forcedEnvironmentId,
index,
@@ -419,21 +422,38 @@ export function PairEditorRow({
valuePlaceholder,
valueType,
valueValidate,
+ setRef,
}: PairEditorRowProps) {
const nameInputRef = useRef