From 5269563441323a8f34edc62567d1233196e52820 Mon Sep 17 00:00:00 2001 From: "Ryan J. Shaw" <610578+ryanjshaw@users.noreply.github.com> Date: Sat, 28 Feb 2026 23:16:16 +0200 Subject: [PATCH] Predefined values for options (#163) --- src/components/OptionsSection.tsx | 1153 +++++++++++++++++++++++++---- 1 file changed, 992 insertions(+), 161 deletions(-) diff --git a/src/components/OptionsSection.tsx b/src/components/OptionsSection.tsx index e29babc..24bae12 100644 --- a/src/components/OptionsSection.tsx +++ b/src/components/OptionsSection.tsx @@ -1,8 +1,515 @@ -import { Chip, Textarea, Tooltip } from '@heroui/react' -import { LockKeyholeIcon, LockOpenIcon } from 'lucide-react' -import { startTransition, useCallback, useEffect, useState } from 'react' +import { + Chip, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownTrigger, + Textarea, + Tooltip, +} from '@heroui/react' +import { ChevronDownIcon, LockKeyholeIcon, LockOpenIcon } from 'lucide-react' +import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { replaceSmartQuotes } from '../../lib/format' +type AvailableOption = { + Name: string + FieldName: string + Help: string + DefaultStr: string + Type: string + Examples?: Array<{ Value: string; Help: string }> +} + +type SelectorPreset = { + label: string + token: string + isDefault: boolean +} + +type DecorationState = { + key: string + valueStart: number + valueEnd: number + rowTop: number +} + +const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']) +const FALSE_VALUES = new Set(['false', '0', 'no', 'off']) +const NULL_VALUES = new Set(['', 'null', 'none', 'unset', 'default', 'auto']) +const TYPE_PRESET_VALUES: Record = { + SizeSuffix: ['0', '8M', '16M', '32M', '64M', '128M', '256M', '512M', '1G'], + Duration: ['0s', '5s', '30s', '1m', '5m', '30m', '1h', '6h', '24h'], + 'mtime|atime|btime|ctime': ['mtime', 'atime', 'btime', 'ctime'], +} +const FIELD_PRESET_VALUES: Record = { + order_by: [ + 'name,ascending', + 'name,descending', + 'size,ascending', + 'size,descending', + 'modtime,ascending', + 'modtime,descending', + ], +} + +function isDigit(char: string) { + return char >= '0' && char <= '9' +} + +function parseBooleanString(value: string) { + const normalized = value.trim().toLowerCase() + if (TRUE_VALUES.has(normalized)) { + return true + } + if (FALSE_VALUES.has(normalized)) { + return false + } + return null +} + +function parseTristateString(value: string) { + const booleanValue = parseBooleanString(value) + if (booleanValue !== null) { + return booleanValue + } + if (NULL_VALUES.has(value.trim().toLowerCase())) { + return null + } + return undefined +} + +function serializePresetToken(value: string, optionType: string): string | null { + const normalizedType = optionType.toLowerCase() + + if (normalizedType === 'bool') { + const parsed = parseBooleanString(value) + return parsed === null ? null : String(parsed) + } + + if (normalizedType === 'tristate') { + const parsed = parseTristateString(value) + if (parsed === undefined) { + return null + } + if (parsed === null) { + return 'null' + } + return String(parsed) + } + + if (normalizedType.includes('int') || normalizedType.includes('float')) { + const parsedNumber = Number(value) + if (!Number.isFinite(parsedNumber)) { + return null + } + return String(parsedNumber) + } + + return JSON.stringify(value) +} + +function formatPresetLabel(value: string, token: string) { + if (token === 'true' || token === 'false' || token === 'null') { + return token + } + if (value.length === 0) { + return '""' + } + return value +} + +function buildSelectorPresets(option: AvailableOption): SelectorPreset[] { + const typePresetValues = TYPE_PRESET_VALUES[option.Type] ?? [] + const fieldPresetValues = FIELD_PRESET_VALUES[option.Name] ?? [] + + const isEligible = + option.Type === 'bool' || + option.Type === 'Tristate' || + (option.Examples?.length ?? 0) > 0 || + typePresetValues.length > 0 || + fieldPresetValues.length > 0 + + if (!isEligible) { + return [] + } + + const presets: SelectorPreset[] = [] + const seenTokens = new Set() + + const pushPreset = (value: string, isDefault: boolean) => { + const token = serializePresetToken(value, option.Type) + if (!token || seenTokens.has(token)) { + return + } + + seenTokens.add(token) + presets.push({ + label: formatPresetLabel(value, token), + token, + isDefault, + }) + } + + pushPreset(option.DefaultStr, true) + + for (const value of fieldPresetValues) { + pushPreset(value, false) + } + + for (const value of typePresetValues) { + pushPreset(value, false) + } + + for (const example of option.Examples ?? []) { + pushPreset(example.Value, false) + } + + if (option.Type === 'bool') { + pushPreset('true', false) + pushPreset('false', false) + } + + if (option.Type === 'Tristate') { + pushPreset('true', false) + pushPreset('false', false) + pushPreset('null', false) + } + + return presets +} + +function toOptionValue(value: unknown, optionType: string) { + const normalizedType = optionType.toLowerCase() + + if (normalizedType === 'bool') { + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'string') { + const parsed = parseBooleanString(value) + if (parsed !== null) { + return parsed + } + } + return Boolean(value || false) + } + + if (normalizedType === 'tristate') { + if (value === null) { + return null + } + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'string') { + const parsed = parseTristateString(value) + if (parsed !== undefined) { + return parsed + } + } + return null + } + + if (normalizedType.includes('int') || normalizedType.includes('float')) { + const parsedNumber = Number(value) + if (Number.isFinite(parsedNumber)) { + return parsedNumber + } + return 0 + } + + if (value === null || value === undefined) { + return '' + } + + return value +} + +function scanJsonString(text: string, startIndex: number) { + let index = startIndex + 1 + let escaped = false + + while (index < text.length) { + const char = text[index] + + if (escaped) { + escaped = false + index += 1 + continue + } + + if (char === '\\') { + escaped = true + index += 1 + continue + } + + if (char === '"') { + return index + 1 + } + + index += 1 + } + + return text.length +} + +function scanJsonContainer(text: string, startIndex: number, openChar: string, closeChar: string) { + let depth = 1 + let index = startIndex + 1 + + while (index < text.length) { + const char = text[index] + + if (char === '"') { + index = scanJsonString(text, index) + continue + } + + if (char === openChar) { + depth += 1 + index += 1 + continue + } + + if (char === closeChar) { + depth -= 1 + index += 1 + if (depth === 0) { + return index + } + continue + } + + index += 1 + } + + return text.length +} + +function scanJsonNumber(text: string, startIndex: number) { + let index = startIndex + + if (text[index] === '-') { + index += 1 + } + + if (text[index] === '0') { + index += 1 + } else { + while (index < text.length && isDigit(text[index])) { + index += 1 + } + } + + if (text[index] === '.') { + index += 1 + while (index < text.length && isDigit(text[index])) { + index += 1 + } + } + + if (text[index] === 'e' || text[index] === 'E') { + index += 1 + if (text[index] === '+' || text[index] === '-') { + index += 1 + } + while (index < text.length && isDigit(text[index])) { + index += 1 + } + } + + return index +} + +function scanJsonValue(text: string, startIndex: number) { + const startChar = text[startIndex] + + if (startChar === '"') { + return scanJsonString(text, startIndex) + } + + if (startChar === '{') { + return scanJsonContainer(text, startIndex, '{', '}') + } + + if (startChar === '[') { + return scanJsonContainer(text, startIndex, '[', ']') + } + + if (text.startsWith('true', startIndex)) { + return startIndex + 4 + } + + if (text.startsWith('false', startIndex)) { + return startIndex + 5 + } + + if (text.startsWith('null', startIndex)) { + return startIndex + 4 + } + + return scanJsonNumber(text, startIndex) +} + +function isWhitespace(char: string) { + return char === ' ' || char === '\n' || char === '\r' || char === '\t' +} + +function skipWhitespace(text: string, startIndex: number) { + let index = startIndex + while (index < text.length && isWhitespace(text[index])) { + index += 1 + } + return index +} + +function parseTopLevelValueRanges(text: string) { + const valueRanges: Array<{ + key: string + keyStart: number + valueStart: number + valueEnd: number + }> = [] + + let index = skipWhitespace(text, 0) + if (text[index] !== '{') { + return valueRanges + } + + index += 1 + + while (index < text.length) { + index = skipWhitespace(text, index) + + if (text[index] === ',') { + index += 1 + continue + } + + if (text[index] === '}') { + break + } + + if (text[index] !== '"') { + break + } + + const keyTokenStart = index + const keyTokenEnd = scanJsonString(text, keyTokenStart) + const keyToken = text.slice(keyTokenStart, keyTokenEnd) + let key = '' + + try { + key = JSON.parse(keyToken) as string + } catch { + break + } + + index = skipWhitespace(text, keyTokenEnd) + if (text[index] !== ':') { + break + } + index += 1 + index = skipWhitespace(text, index) + + const valueStart = index + const valueEnd = scanJsonValue(text, valueStart) + if (valueEnd <= valueStart) { + break + } + + valueRanges.push({ + key, + keyStart: keyTokenStart, + valueStart, + valueEnd, + }) + + index = skipWhitespace(text, valueEnd) + if (text[index] === ',') { + index += 1 + } + } + + return valueRanges +} + +function measureWrappedTopOffsets(textarea: HTMLTextAreaElement, text: string, indices: number[]) { + if (indices.length === 0 || typeof document === 'undefined') { + return new Map() + } + + const computedStyle = window.getComputedStyle(textarea) + const mirror = document.createElement('div') + mirror.style.position = 'absolute' + mirror.style.top = '0' + mirror.style.left = '-99999px' + mirror.style.visibility = 'hidden' + mirror.style.pointerEvents = 'none' + mirror.style.whiteSpace = 'pre-wrap' + mirror.style.overflowWrap = 'break-word' + mirror.style.wordBreak = 'break-word' + mirror.style.boxSizing = 'border-box' + mirror.style.width = `${textarea.clientWidth}px` + mirror.style.font = computedStyle.font + mirror.style.lineHeight = computedStyle.lineHeight + mirror.style.letterSpacing = computedStyle.letterSpacing + mirror.style.paddingTop = computedStyle.paddingTop + mirror.style.paddingRight = computedStyle.paddingRight + mirror.style.paddingBottom = computedStyle.paddingBottom + mirror.style.paddingLeft = computedStyle.paddingLeft + mirror.style.textIndent = computedStyle.textIndent + mirror.style.textTransform = computedStyle.textTransform + mirror.style.tabSize = computedStyle.tabSize + mirror.style.border = '0' + + const mirrorContent = text.length > 0 ? text : ' ' + const textNode = document.createTextNode(mirrorContent) + mirror.appendChild(textNode) + document.body.appendChild(mirror) + + const mirrorRect = mirror.getBoundingClientRect() + const paddingTop = Number.parseFloat(computedStyle.paddingTop) || 0 + const uniqueIndices = Array.from(new Set(indices)) + const topByIndex = new Map() + const range = document.createRange() + + for (const index of uniqueIndices) { + const clampedIndex = Math.max(0, Math.min(index, mirrorContent.length)) + range.setStart(textNode, clampedIndex) + range.setEnd(textNode, clampedIndex) + + let rect = range.getBoundingClientRect() + if (rect.width === 0 && rect.height === 0 && mirrorContent.length > 0) { + const fallbackStart = Math.max(0, Math.min(clampedIndex, mirrorContent.length - 1)) + range.setStart(textNode, fallbackStart) + range.setEnd(textNode, fallbackStart + 1) + rect = range.getBoundingClientRect() + } + + const top = rect.top - mirrorRect.top - paddingTop + topByIndex.set(index, Math.max(0, top)) + } + + document.body.removeChild(mirror) + + return topByIndex +} + +function isSameDecoration(left: DecorationState | null, right: DecorationState | null) { + if (!left || !right) { + return left === right + } + + return ( + left.key === right.key && + left.valueStart === right.valueStart && + left.valueEnd === right.valueEnd && + left.rowTop === right.rowTop + ) +} + export default function OptionsSection({ label, optionsJson, @@ -16,18 +523,35 @@ export default function OptionsSection({ optionsJson: string setOptionsJson: (value: string) => void globalOptions: Record - availableOptions: any[] + availableOptions: Array<{ + Name: string + FieldName: string + Help: string + DefaultStr: string + Type: string + Examples?: Array<{ Value: string; Help: string }> + }> isLocked?: boolean setIsLocked?: (value: boolean) => void }) { - const [options, setOptions] = useState({}) + const [options, setOptions] = useState>({}) const [isJsonValid, setIsJsonValid] = useState(true) + const [scrollTop, setScrollTop] = useState(0) + const [textareaLayout, setTextareaLayout] = useState({ + lineHeight: 20, + paddingTop: 0, + textareaOffsetTop: 0, + }) + const [activeDecoration, setActiveDecoration] = useState(null) + const [isSelectorOpen, setIsSelectorOpen] = useState(false) + const textareaRef = useRef(null) + const textareaBaseRef = useRef(null) useEffect(() => { startTransition(() => { try { const parsedOptions = JSON.parse(optionsJson) - setOptions(parsedOptions) + setOptions(parsedOptions as Record) setIsJsonValid(true) } catch { setIsJsonValid(false) @@ -35,6 +559,61 @@ export default function OptionsSection({ }) }, [optionsJson]) + const refreshTextareaLayout = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) { + return + } + + const computedStyle = window.getComputedStyle(textarea) + const lineHeightFromStyle = Number.parseFloat(computedStyle.lineHeight) + const fontSize = Number.parseFloat(computedStyle.fontSize) + const lineHeight = + Number.isFinite(lineHeightFromStyle) && lineHeightFromStyle > 0 + ? lineHeightFromStyle + : fontSize * 1.4 + const paddingTop = Number.parseFloat(computedStyle.paddingTop) || 0 + const baseRect = textareaBaseRef.current?.getBoundingClientRect() + const textareaRect = textarea.getBoundingClientRect() + const textareaOffsetTop = baseRect ? textareaRect.top - baseRect.top : 0 + + setTextareaLayout((previous) => { + if ( + previous.lineHeight === lineHeight && + previous.paddingTop === paddingTop && + previous.textareaOffsetTop === textareaOffsetTop + ) { + return previous + } + + return { + lineHeight, + paddingTop, + textareaOffsetTop, + } + }) + + setScrollTop(textarea.scrollTop) + }, []) + + useEffect(() => { + refreshTextareaLayout() + + const textarea = textareaRef.current + if (!textarea) { + return + } + + const resizeObserver = new ResizeObserver(() => { + refreshTextareaLayout() + }) + resizeObserver.observe(textarea) + + return () => { + resizeObserver.disconnect() + } + }, [refreshTextareaLayout]) + const isOptionAdded = useCallback( (option: string) => { return options[option] !== undefined @@ -42,150 +621,419 @@ export default function OptionsSection({ [options] ) + const presetByKey = useMemo(() => { + return availableOptions.reduce( + (acc, option) => { + const presets = buildSelectorPresets(option) + if (presets.length > 0) { + acc[option.Name] = presets + } + return acc + }, + {} as Record + ) + }, [availableOptions]) + + const textModel = useMemo(() => { + const emptyModel = { + rows: [] as Array, + } + + if (!isJsonValid || textareaLayout.lineHeight <= 0) { + return emptyModel + } + + const textarea = textareaRef.current + if (!textarea) { + return emptyModel + } + + const topLevelEntries = parseTopLevelValueRanges(optionsJson).filter( + (entry) => (presetByKey[entry.key]?.length ?? 0) > 0 + ) + + if (topLevelEntries.length === 0) { + return emptyModel + } + + const lastEntry = topLevelEntries[topLevelEntries.length - 1] + const indicesToMeasure = topLevelEntries.map((entry) => entry.keyStart) + if (lastEntry) { + indicesToMeasure.push(Math.max(lastEntry.valueStart, lastEntry.valueEnd - 1)) + } + const topByIndex = measureWrappedTopOffsets(textarea, optionsJson, indicesToMeasure) + + const rows = topLevelEntries.map((entry, rowIndex) => { + const rowTop = topByIndex.get(entry.keyStart) ?? rowIndex * textareaLayout.lineHeight + + return { + key: entry.key, + valueStart: entry.valueStart, + valueEnd: entry.valueEnd, + rowTop, + rowBottom: rowTop + textareaLayout.lineHeight, + } + }) + + for (let index = 0; index < rows.length - 1; index += 1) { + const nextTop = rows[index + 1].rowTop + if (nextTop > rows[index].rowTop) { + rows[index].rowBottom = nextTop + } + } + if (lastEntry && rows.length > 0) { + const lastValueTop = + topByIndex.get(Math.max(lastEntry.valueStart, lastEntry.valueEnd - 1)) ?? + rows[rows.length - 1].rowTop + rows[rows.length - 1].rowBottom = Math.max( + rows[rows.length - 1].rowTop + textareaLayout.lineHeight, + lastValueTop + textareaLayout.lineHeight + ) + } + + return { + rows, + } + }, [isJsonValid, optionsJson, presetByKey, textareaLayout]) + + const getDecorationFromPointer = useCallback( + (clientY: number) => { + const textarea = textareaRef.current + if (!textarea) { + return null + } + + const rect = textarea.getBoundingClientRect() + const relativeY = clientY - rect.top - textareaLayout.paddingTop + textarea.scrollTop + + if (relativeY < 0 || !Number.isFinite(relativeY)) { + return null + } + + const selectedRow = textModel.rows.find( + (row) => relativeY >= row.rowTop && relativeY < row.rowBottom + ) + if (!selectedRow) { + return null + } + + return { + key: selectedRow.key, + valueStart: selectedRow.valueStart, + valueEnd: selectedRow.valueEnd, + rowTop: selectedRow.rowTop, + } + }, + [textModel.rows, textareaLayout.paddingTop] + ) + + const applyPresetValue = useCallback( + (token: string, decoration: DecorationState) => { + const newText = + optionsJson.slice(0, decoration.valueStart) + + token + + optionsJson.slice(decoration.valueEnd) + + const nextCursorPosition = decoration.valueStart + token.length + setOptionsJson(newText) + + requestAnimationFrame(() => { + if (!textareaRef.current) { + return + } + + textareaRef.current.focus() + textareaRef.current.selectionStart = nextCursorPosition + textareaRef.current.selectionEnd = nextCursorPosition + }) + }, + [optionsJson, setOptionsJson] + ) + + const activePresets = useMemo(() => { + if (!activeDecoration) { + return [] + } + return presetByKey[activeDecoration.key] ?? [] + }, [activeDecoration, presetByKey]) + + const shouldShowSelector = isJsonValid && !!activeDecoration && activePresets.length > 0 + + const decorationTop = shouldShowSelector + ? textareaLayout.textareaOffsetTop + + textareaLayout.paddingTop + + activeDecoration!.rowTop - + scrollTop + : 0 + + useEffect(() => { + if (isJsonValid) { + return + } + + setIsSelectorOpen(false) + setActiveDecoration(null) + }, [isJsonValid]) + + useEffect(() => { + if (!activeDecoration) { + return + } + + const hasMatchingDecoration = textModel.rows.some( + (candidate) => + candidate.key === activeDecoration.key && + candidate.valueStart === activeDecoration.valueStart && + candidate.valueEnd === activeDecoration.valueEnd && + candidate.rowTop === activeDecoration.rowTop + ) + + if (hasMatchingDecoration) { + return + } + + setIsSelectorOpen(false) + setActiveDecoration(null) + }, [activeDecoration, textModel.rows]) + return ( -
-