Auth plugins (#155)

This commit is contained in:
Gregory Schier
2025-01-17 05:53:03 -08:00
committed by GitHub
parent e21df98a30
commit bd322162c8
56 changed files with 5468 additions and 1474 deletions

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react';
import { generateId } from '../../lib/generateId';
import { Editor } from './Editor/Editor';
import type { PairEditorProps } from './PairEditor';
import type { PairEditorProps, PairWithId } from './PairEditor';
type Props = PairEditorProps;
@@ -45,14 +45,12 @@ export function BulkPairEditor({
);
}
function lineToPair(line: string): PairEditorProps['pairs'][0] {
function lineToPair(line: string): PairWithId {
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? [];
const pair: PairEditorProps['pairs'][0] = {
return {
enabled: true,
name: (name ?? '').trim(),
value: (value ?? '').trim(),
id: generateId(),
};
return pair;
}

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import { motion } from 'framer-motion';
import { atom, useAtom } from 'jotai';
import { atom } from 'jotai';
import type {
CSSProperties,
FocusEvent as ReactFocusEvent,
@@ -12,11 +12,11 @@ import type {
SetStateAction,
} from 'react';
import React, {
useEffect,
Children,
cloneElement,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
@@ -29,6 +29,7 @@ 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 { Overlay } from '../Overlay';
import { Button } from './Button';
import { HotKey } from './HotKey';
@@ -62,15 +63,13 @@ export type DropdownItem = DropdownItemDefault | DropdownItemSeparator;
export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
items: DropdownItem[];
onOpen?: () => void;
onClose?: () => void;
fullWidth?: boolean;
hotKeyAction?: HotkeyAction;
}
export interface DropdownRef {
isOpen: boolean;
open: () => void;
open: (index?: number) => void;
toggle: () => void;
close?: () => void;
next?: (incrBy?: number) => void;
@@ -84,46 +83,52 @@ export interface DropdownRef {
const openAtom = atom<string | null>(null);
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items, onOpen, onClose, hotKeyAction, fullWidth }: DropdownProps,
{ children, items, hotKeyAction, fullWidth }: DropdownProps,
ref,
) {
const id = useRef(generateId()).current;
const [openId, setOpenId] = useAtom(openAtom);
const isOpen = openId === id;
const id = useRef(generateId());
const [isOpen, _setIsOpen] = useState<boolean>(false);
useEffect(() => {
return jotaiStore.sub(openAtom, () => {
const globalOpenId = jotaiStore.get(openAtom);
const newIsOpen = globalOpenId === id.current;
if (newIsOpen !== isOpen) {
_setIsOpen(newIsOpen);
}
});
}, [isOpen, _setIsOpen]);
// 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 setIsOpen = useCallback(
(o: SetStateAction<boolean>) => {
setOpenId((prevId) => {
const prevIsOpen = prevId === id;
const newIsOpen = typeof o === 'function' ? o(prevIsOpen) : o;
return newIsOpen ? id : null; // Set global atom to current ID to signify open state
});
},
[id, setOpenId],
);
useEffect(() => {
if (isOpen) {
const setIsOpen = useCallback((o: SetStateAction<boolean>) => {
jotaiStore.set(openAtom, (prevId) => {
const prevIsOpen = prevId === id.current;
const newIsOpen = typeof o === 'function' ? o(prevIsOpen) : o;
// Persist background color of button until we close the dropdown
buttonRef.current!.style.backgroundColor = window
.getComputedStyle(buttonRef.current!)
.getPropertyValue('background-color');
onOpen?.();
} else {
onClose?.();
if (newIsOpen) {
buttonRef.current!.style.backgroundColor = window
.getComputedStyle(buttonRef.current!)
.getPropertyValue('background-color');
}
return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
});
}, []);
// Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method
// we have of detecting the dropdown closed, to do cleanup.
useEffect(() => {
if (!isOpen) {
buttonRef.current?.focus(); // Focus button
buttonRef.current!.style.backgroundColor = ''; // Clear persisted BG
// 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);
}
// 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(isOpen ? -1 : null);
}, [isOpen, onClose, onOpen]);
}, [isOpen]);
// Pull into variable so linter forces us to add it as a hook dep to useImperativeHandle. If we don't,
// the ref will not update when menuRef updates, causing stale callback state to be used.
@@ -138,8 +143,9 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
if (!isOpen) this.open();
else this.close();
},
open() {
open(index?: number) {
setIsOpen(true);
setDefaultSelectedIndex(index ?? -1);
},
close() {
setIsOpen(false);
@@ -264,11 +270,18 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
ref,
) {
const [selectedIndex, setSelectedIndex] = useStateWithDeps<number | null>(
defaultSelectedIndex ?? null,
defaultSelectedIndex ?? -1,
[defaultSelectedIndex],
);
const [filter, setFilter] = useState<string>('');
// HACK: Use a ref to track selectedIndex so our closure functions (eg. select()) can
// have access to the latest value.
const selectedIndexRef = useRef(selectedIndex);
useEffect(() => {
selectedIndexRef.current = selectedIndex;
}, [selectedIndex]);
const handleClose = useCallback(() => {
onClose();
setFilter('');
@@ -380,12 +393,12 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
prev: handlePrev,
next: handleNext,
select() {
const item = items[selectedIndex ?? -1] ?? null;
const item = items[selectedIndexRef.current ?? -1] ?? null;
if (!item) return;
handleSelect(item);
},
};
}, [handleClose, handleNext, handlePrev, handleSelect, items, selectedIndex]);
}, [handleClose, handleNext, handlePrev, handleSelect, items]);
const styles = useMemo<{
container: CSSProperties;

View File

@@ -51,14 +51,8 @@
}
}
/* Don't show selection on blurred input */
.cm-selectionBackground {
@apply bg-transparent;
}
&.cm-focused .cm-selectionBackground {
@apply bg-selection;
@apply bg-selection !important;
}
/* Style gutters */

View File

@@ -26,10 +26,7 @@ import { useActiveEnvironmentVariables } from '../../../hooks/useActiveEnvironme
import { parseTemplate } from '../../../hooks/useParseTemplate';
import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useSettings } from '../../../hooks/useSettings';
import {
useTemplateFunctions,
useTwigCompletionOptions,
} from '../../../hooks/useTemplateFunctions';
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
import { showDialog } from '../../../lib/dialog';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
@@ -93,6 +90,10 @@ const stateFields = { history: historyField, folds: foldState };
const emptyVariables: EnvironmentVariable[] = [];
const emptyExtension: Extension = [];
// NOTE: For some reason, the cursor doesn't appear if the field is empty and there is no
// placeholder. So we set it to a space to force it to show.
const emptyPlaceholder = ' ';
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
readOnly,
@@ -126,7 +127,6 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
) {
const settings = useSettings();
const templateFunctions = useTemplateFunctions();
const allEnvironmentVariables = useActiveEnvironmentVariables();
const environmentVariables = autocompleteVariables ? allEnvironmentVariables : emptyVariables;
@@ -178,7 +178,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
useEffect(
function configurePlaceholder() {
if (cm.current === null) return;
const ext = placeholderExt(placeholderElFromText(placeholder ?? ''));
const ext = placeholderExt(placeholderElFromText(placeholder || emptyPlaceholder));
const effect = placeholderCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects: effect });
},
@@ -300,7 +300,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
[focusParamValue],
);
const completionOptions = useTwigCompletionOptions(onClickFunction);
const completionOptions = useTemplateFunctionCompletionOptions(onClickFunction);
// Update the language extension when the language changes
useEffect(() => {
@@ -322,7 +322,6 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
autocomplete,
useTemplating,
environmentVariables,
templateFunctions,
onClickFunction,
onClickVariable,
onClickMissingVariable,
@@ -355,7 +354,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const extensions = [
languageCompartment.of(langExt),
placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder ?? '')),
placeholderExt(placeholderElFromText(placeholder || emptyPlaceholder)),
),
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : []),
keymapCompartment.current.of(
@@ -601,13 +600,16 @@ const placeholderElFromText = (text: string) => {
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
if (!stateKey || state == null) return;
sessionStorage.setItem(stateKey, JSON.stringify(state.toJSON(stateFields)));
sessionStorage.setItem(
computeFullStateKey(stateKey),
JSON.stringify(state.toJSON(stateFields)),
);
}
function getCachedEditorState(doc: string, stateKey: string | null) {
if (stateKey == null) return;
const stateStr = sessionStorage.getItem(stateKey);
const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey));
if (stateStr == null) return null;
try {
@@ -621,3 +623,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
return null;
}
function computeFullStateKey(stateKey: string): string {
return `editor.${stateKey}`;
}

View File

@@ -56,6 +56,7 @@ const icons = {
help: lucide.CircleHelpIcon,
history: lucide.HistoryIcon,
house: lucide.HomeIcon,
import: lucide.ImportIcon,
info: lucide.InfoIcon,
keyboard: lucide.KeyboardIcon,
left_panel_hidden: lucide.PanelLeftOpenIcon,

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { ReactNode } from 'react';
import { forwardRef, useCallback, useMemo, useRef, useState } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import type { EditorProps } from './Editor/Editor';
import { Editor } from './Editor/Editor';
@@ -46,7 +46,7 @@ export type InputProps = Pick<
stateKey: EditorProps['stateKey'];
};
export const Input = forwardRef<EditorView | undefined, InputProps>(function Input(
export const Input = forwardRef<EditorView, InputProps>(function Input(
{
className,
containerClassName,
@@ -79,6 +79,8 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const editorRef = useRef<EditorView | null>(null);
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
const handleFocus = useCallback(() => {
if (readOnly) return;
@@ -88,6 +90,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
const handleBlur = useCallback(() => {
setFocused(false);
editorRef.current?.dispatch({ selection: { anchor: 0 } });
onBlur?.();
}, [onBlur]);
@@ -164,7 +167,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
)}
>
<Editor
ref={ref}
ref={editorRef}
id={id}
singleLine
stateKey={stateKey}

View File

@@ -43,7 +43,7 @@ export type PairEditorProps = {
namePlaceholder?: string;
nameValidate?: InputProps['validate'];
noScroll?: boolean;
onChange: (pairs: Pair[]) => void;
onChange: (pairs: PairWithId[]) => void;
pairs: Pair[];
stateKey: InputProps['stateKey'];
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
@@ -54,7 +54,7 @@ export type PairEditorProps = {
};
export type Pair = {
id: string;
id?: string;
enabled?: boolean;
name: string;
value: string;
@@ -63,6 +63,10 @@ export type Pair = {
readOnlyName?: boolean;
};
export type PairWithId = Pair & {
id: string;
};
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 50;
@@ -90,7 +94,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
const [forceFocusNamePairId, setForceFocusNamePairId] = useState<string | null>(null);
const [forceFocusValuePairId, setForceFocusValuePairId] = useState<string | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [pairs, setPairs] = useState<Pair[]>([]);
const [pairs, setPairs] = useState<PairWithId[]>([]);
const [showAll, toggleShowAll] = useToggle(false);
useImperativeHandle(
@@ -105,14 +109,13 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
);
useEffect(() => {
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't used to have IDs)
const newPairs = [];
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
const newPairs: PairWithId[] = [];
for (let i = 0; i < originalPairs.length; i++) {
const p = originalPairs[i];
if (!p) continue; // Make TS happy
if (isPairEmpty(p)) continue;
if (!p.id) p.id = generateId();
newPairs.push(p);
newPairs.push({ ...p, id: p.id ?? generateId() });
}
// Add empty last pair if there is none
@@ -127,7 +130,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
}, [forceUpdateKey]);
const setPairsAndSave = useCallback(
(fn: (pairs: Pair[]) => Pair[]) => {
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
setPairs((oldPairs) => {
const pairs = fn(oldPairs);
onChange(pairs);
@@ -165,7 +168,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
);
const handleChange = useCallback(
(pair: Pair) => 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],
);
@@ -267,15 +270,15 @@ enum ItemTypes {
type PairEditorRowProps = {
className?: string;
pair: Pair;
pair: PairWithId;
forceFocusNamePairId?: string | null;
forceFocusValuePairId?: string | null;
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onChange: (pair: Pair) => void;
onDelete?: (pair: Pair, focusPrevious: boolean) => void;
onFocus?: (pair: Pair) => void;
onSubmit?: (pair: Pair) => void;
onChange: (pair: PairWithId) => void;
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
onFocus?: (pair: PairWithId) => void;
onSubmit?: (pair: PairWithId) => void;
isLast?: boolean;
index: number;
} & Pick<
@@ -618,7 +621,7 @@ function FileActionsDropdown({
);
}
function emptyPair(): Pair {
function emptyPair(): PairWithId {
return {
enabled: true,
name: '',