From 902d419026b3d255bb28ab41378191e3500109e1 Mon Sep 17 00:00:00 2001 From: Mathew Pareles Date: Sun, 20 Apr 2025 02:05:52 -0700 Subject: [PATCH] at to mention draft --- .../void/browser/react/src/util/inputs.tsx | 379 +++++++++++++++++- 1 file changed, 374 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx index 1b8df220..91281214 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx @@ -48,6 +48,80 @@ export const WidgetComponent = ({ ctor, prop return
{children}
} +type GenerateNextOptions = (newPathText: string) => Option[] + +type Option = { + name: string, + displayName: string, +} & ( + | { nextOptions: Option[], generateNextOptions?: undefined } + | { nextOptions?: undefined, generateNextOptions: GenerateNextOptions } + | { nextOptions?: undefined, generateNextOptions?: undefined } + ) + + +const getOptionsAtPath = (accessor: ReturnType, path: string[], newPathText: string) => { + + + const allOptions: Option[] = [ + { + name: 'files', + displayName: 'files', + generateNextOptions: () => [ + { name: 'a.txt', displayName: 'a.txt', }, + { name: 'b.txt', displayName: 'b.txt', }, + { name: 'c.txt', displayName: 'c.txt', }, + { name: 'd.txt', displayName: 'd.txt', }, + { name: 'e.txt', displayName: 'e.txt', }, + { name: 'f.txt', displayName: 'f.txt', }, + { name: 'g.txt', displayName: 'g.txt', }, + { name: '!a.txt', displayName: '!a.txt', }, + { name: '!b.txt', displayName: '!b.txt', }, + { name: '!c.txt', displayName: '!c.txt', }, + { name: '!d.txt', displayName: '!d.txt', }, + { name: '!e.txt', displayName: '!e.txt', }, + { name: '!f.txt', displayName: '!f.txt', }, + { name: '!g.txt', displayName: '!g.txt', }, + ] + }, + { + name: 'folders', + displayName: 'folders', + nextOptions: [ + { name: 'FOLDER', displayName: 'FOLDER', }, + ] + }, + ] + + // follow the path in the optionsTree (until the last path element) + + let nextOptionsAtPath = allOptions + let generateNextOptionsAtPath: GenerateNextOptions | undefined = undefined + + for (const pn of path) { + + const selectedOption = nextOptionsAtPath.find(o => o.name.toLowerCase() === pn.toLowerCase()) + + if (!selectedOption) return; + + nextOptionsAtPath = selectedOption.nextOptions! // assume nextOptions exists until we hit the very last option (the path will never contain the last possible option) + generateNextOptionsAtPath = selectedOption.generateNextOptions + + } + + + if (generateNextOptionsAtPath) { + nextOptionsAtPath = generateNextOptionsAtPath(newPathText) + } + + const optionsAtPath = nextOptionsAtPath.filter(o => o.name.includes(newPathText)) + + + return optionsAtPath + +} + + export type TextAreaFns = { setValue: (v: string) => void, enable: () => void, disable: () => void } type InputBox2Props = { @@ -64,8 +138,235 @@ type InputBox2Props = { } export const VoidInputBox2 = forwardRef(function X({ initValue, placeholder, multiline, fnsRef, className, onKeyDown, onFocus, onBlur, onChangeText }, ref) { + // mirrors whatever is in ref + const accessor = useAccessor() + const toolsService = accessor.get('IToolsService') + + + + + + + + + + + + const textAreaRef = useRef(null) + const selectedOptionRef = useRef(null); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const [path, setPath] = useState([]); + const [optionIdx, setOptionIdx] = useState(0); + const [options, setOptions] = useState([]); + const [newPathText, setNewPathText] = useState(''); + + + const insertTextAtCursor = (text: string) => { + const textarea = textAreaRef.current; + if (!textarea) return; + + // Focus the textarea first + textarea.focus(); + + // The most reliable way to simulate typing is to use execCommand + // which will trigger all the appropriate native events + document.execCommand('insertText', false, text); + + // React's onChange relies on a SyntheticEvent system + // The best way to ensure it runs is to call callbacks directly + if (onChangeText) { + onChangeText(textarea.value); + } + adjustHeight(); + }; + + + + const onSelectOption = () => { + + if (!options.length) { return; } + + const option = options[optionIdx]; + const newPath = [...path, option.name] + const isLastOption = !option.generateNextOptions && !option.nextOptions + + setPath(newPath) + setNewPathText('') + setOptionIdx(0) + if (isLastOption) { + setIsMenuOpen(false) + insertTextAtCursor(`TODO-${option.displayName}`) + } + else { + setOptions(getOptionsAtPath(accessor, newPath, '') || []) + } + } + + const onRemoveOption = () => { + const newPath = [...path.slice(0, path.length - 1)] + setPath(newPath) + setNewPathText('') + setOptionIdx(0) + setOptions(getOptionsAtPath(accessor, newPath, '') || []) + } + + const onOpenOptionMenu = () => { + setPath([]) + setNewPathText('') + setIsMenuOpen(true); + setOptionIdx(0); + setOptions(getOptionsAtPath(accessor, [], '') || []); + } + const onCloseOptionMenu = () => { + setIsMenuOpen(false); + } + + const onNavigateUp = () => { + if (options.length === 0) return; + setOptionIdx((prevIdx) => (prevIdx - 1 + options.length) % options.length); + } + const onNavigateDown = () => { + if (options.length === 0) return; + setOptionIdx((prevIdx) => (prevIdx + 1) % options.length); + } + + const onPathTextChange = (newStr: string) => { + setNewPathText(newStr); + setOptions(getOptionsAtPath(accessor, path, newStr) || []); + + } + + const onMenuKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + onNavigateUp(); + } else if (e.key === 'ArrowDown') { + onNavigateDown(); + } else if (e.key === 'ArrowLeft') { + onSelectOption(); + } else if (e.key === 'ArrowRight') { + onSelectOption(); + } else if (e.key === 'Enter') { + onSelectOption(); + } else if (e.key === 'Escape') { + onCloseOptionMenu() + } else if (e.key === 'Backspace') { + + if (!newPathText) { // No text remaining + if (path.length === 0) { + onCloseOptionMenu() + } else { + onRemoveOption(); + } + } + else if (e.altKey || e.ctrlKey || e.metaKey) { // Ctrl+Backspace + onPathTextChange('') + } + else { // Backspace + onPathTextChange(newPathText.slice(0, -1)) + } + } else if (e.key.length === 1) { + if (e.altKey || e.ctrlKey || e.metaKey) { // Ctrl+letter + // do nothing + } + else { // letter + onPathTextChange(newPathText + e.key) + } + } + + e.preventDefault(); + e.stopPropagation(); + + }; + + // scroll the selected optionIdx into view on optionIdx and newPathText changes + useEffect(() => { + if (isMenuOpen && selectedOptionRef.current) { + selectedOptionRef.current.scrollIntoView({ + behavior: 'instant', + block: 'nearest', + inline: 'nearest', + }); + } + }, [optionIdx, isMenuOpen, newPathText, selectedOptionRef]); + + + + const measureRef = useRef(null); + const gapPx = 2 + const offsetPx = 2 + const { + x, + y, + strategy, + refs, + middlewareData, + update + } = useFloating({ + open: isMenuOpen, + onOpenChange: setIsMenuOpen, + placement: 'top', + + middleware: [ + offset({ mainAxis: gapPx, crossAxis: offsetPx }), + flip({ + boundary: document.body, + padding: 8 + }), + shift({ + boundary: document.body, + padding: 8, + }), + size({ + apply({ availableHeight, elements, rects }) { + const maxHeight = Math.min(availableHeight) + + Object.assign(elements.floating.style, { + maxHeight: `${maxHeight}px`, + overflowY: 'auto', + // Ensure the width isn't constrained by the parent + width: `${Math.max( + rects.reference.width, + measureRef.current?.offsetWidth ?? 0 + )}px` + }); + }, + padding: 8, + // Use viewport as boundary instead of any parent element + boundary: document.body, + }), + ], + whileElementsMounted: autoUpdate, + strategy: 'fixed', + }); + useEffect(() => { + if (!isMenuOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + const floating = refs.floating.current; + const reference = refs.reference.current; + + // Check if reference is an HTML element before using contains + const isReferenceHTMLElement = reference && 'contains' in reference; + + if ( + floating && + (!isReferenceHTMLElement || !reference.contains(target)) && + !floating.contains(target) + ) { + setIsMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isMenuOpen, refs.floating, refs.reference]); + + + const [isEnabled, setEnabled] = useState(true) const adjustHeight = useCallback(() => { @@ -104,18 +405,20 @@ export const VoidInputBox2 = forwardRef(fun - return ( + return <>