diff options
| author | Ali Al Jufairi <[email protected]> | 2025-08-19 11:28:45 +0900 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-08-19 02:28:45 +0000 |
| commit | 92bb4624c4ce191ed5dd60aaca4e2786039b3b2b (patch) | |
| tree | 31ec7041f8cdf1cecf7bb708135ce74fffa81d2f /packages/cli/src/ui/components/SettingsDialog.tsx | |
| parent | 36ea986cfe443d2d363db6e6daa3a0ced7408f3b (diff) | |
feat(settings): enhance settings management with generic setter and display hel… (#6202)
Co-authored-by: Jacob Richman <[email protected]>
Diffstat (limited to 'packages/cli/src/ui/components/SettingsDialog.tsx')
| -rw-r--r-- | packages/cli/src/ui/components/SettingsDialog.tsx | 487 |
1 files changed, 385 insertions, 102 deletions
diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index a09cd76a..8fa689d0 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -29,9 +29,13 @@ import { requiresRestart, getRestartRequiredFromModified, getDefaultValue, + setPendingSettingValueAny, + getNestedValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useKeypress } from '../hooks/useKeypress.js'; +import chalk from 'chalk'; +import { cpSlice, cpLen } from '../utils/textUtils.js'; interface SettingsDialogProps { settings: LoadedSettings; @@ -74,61 +78,55 @@ export function SettingsDialog({ new Set(), ); - // Track the intended values for modified settings - const [modifiedValues, setModifiedValues] = useState<Map<string, boolean>>( - new Map(), - ); + // Preserve pending changes across scope switches (boolean and number values only) + type PendingValue = boolean | number; + const [globalPendingChanges, setGlobalPendingChanges] = useState< + Map<string, PendingValue> + >(new Map()); // Track restart-required settings across scope changes - const [restartRequiredSettings, setRestartRequiredSettings] = useState< + const [_restartRequiredSettings, setRestartRequiredSettings] = useState< Set<string> >(new Set()); useEffect(() => { - setPendingSettings( - structuredClone(settings.forScope(selectedScope).settings), - ); - // Don't reset modifiedSettings when scope changes - preserve user's pending changes - if (restartRequiredSettings.size === 0) { - setShowRestartPrompt(false); - } - }, [selectedScope, settings, restartRequiredSettings]); - - // Preserve pending changes when scope changes - useEffect(() => { - if (modifiedSettings.size > 0) { - setPendingSettings((prevPending) => { - let updatedPending = { ...prevPending }; - - // Reapply all modified settings to the new pending settings using stored values - modifiedSettings.forEach((key) => { - const storedValue = modifiedValues.get(key); - if (storedValue !== undefined) { - updatedPending = setPendingSettingValue( - key, - storedValue, - updatedPending, - ); - } - }); - - return updatedPending; - }); + // Base settings for selected scope + let updated = structuredClone(settings.forScope(selectedScope).settings); + // Overlay globally pending (unsaved) changes so user sees their modifications in any scope + const newModified = new Set<string>(); + const newRestartRequired = new Set<string>(); + for (const [key, value] of globalPendingChanges.entries()) { + const def = getSettingDefinition(key); + if (def?.type === 'boolean' && typeof value === 'boolean') { + updated = setPendingSettingValue(key, value, updated); + } else if (def?.type === 'number' && typeof value === 'number') { + updated = setPendingSettingValueAny(key, value, updated); + } + newModified.add(key); + if (requiresRestart(key)) newRestartRequired.add(key); } - }, [selectedScope, modifiedSettings, modifiedValues, settings]); + setPendingSettings(updated); + setModifiedSettings(newModified); + setRestartRequiredSettings(newRestartRequired); + setShowRestartPrompt(newRestartRequired.size > 0); + }, [selectedScope, settings, globalPendingChanges]); const generateSettingsItems = () => { const settingKeys = getDialogSettingKeys(); return settingKeys.map((key: string) => { - const currentValue = getSettingValue(key, pendingSettings, {}); const definition = getSettingDefinition(key); return { label: definition?.label || key, value: key, - checked: currentValue, + type: definition?.type, toggle: () => { + if (definition?.type !== 'boolean') { + // For non-boolean (e.g., number) items, toggle will be handled via edit mode. + return; + } + const currentValue = getSettingValue(key, pendingSettings, {}); const newValue = !currentValue; setPendingSettings((prev) => @@ -137,10 +135,10 @@ export function SettingsDialog({ if (!requiresRestart(key)) { const immediateSettings = new Set([key]); - const immediateSettingsObject = setPendingSettingValue( + const immediateSettingsObject = setPendingSettingValueAny( key, newValue, - {}, + {} as Settings, ); console.log( @@ -162,23 +160,13 @@ export function SettingsDialog({ }); } - // Capture the current modified settings before updating state - const currentModifiedSettings = new Set(modifiedSettings); - - // Remove the saved setting from modifiedSettings since it's now saved + // Remove from modifiedSettings since it's now saved setModifiedSettings((prev) => { const updated = new Set(prev); updated.delete(key); return updated; }); - // Remove from modifiedValues as well - setModifiedValues((prev) => { - const updated = new Map(prev); - updated.delete(key); - return updated; - }); - // Also remove from restart-required settings if it was there setRestartRequiredSettings((prev) => { const updated = new Set(prev); @@ -186,34 +174,20 @@ export function SettingsDialog({ return updated; }); - setPendingSettings((_prevPending) => { - let updatedPending = structuredClone( - settings.forScope(selectedScope).settings, - ); - - currentModifiedSettings.forEach((modifiedKey) => { - if (modifiedKey !== key) { - const modifiedValue = modifiedValues.get(modifiedKey); - if (modifiedValue !== undefined) { - updatedPending = setPendingSettingValue( - modifiedKey, - modifiedValue, - updatedPending, - ); - } - } - }); - - return updatedPending; - }); - } else { - // For restart-required settings, store the actual value - setModifiedValues((prev) => { - const updated = new Map(prev); - updated.set(key, newValue); - return updated; + // Remove from global pending changes if present + setGlobalPendingChanges((prev) => { + if (!prev.has(key)) return prev; + const next = new Map(prev); + next.delete(key); + return next; }); + // Refresh pending settings from the saved state + setPendingSettings( + structuredClone(settings.forScope(selectedScope).settings), + ); + } else { + // For restart-required settings, track as modified setModifiedSettings((prev) => { const updated = new Set(prev).add(key); const needsRestart = hasRestartRequiredSettings(updated); @@ -231,6 +205,13 @@ export function SettingsDialog({ } return updated; }); + + // Add/update pending change globally so it persists across scopes + setGlobalPendingChanges((prev) => { + const next = new Map(prev); + next.set(key, newValue as PendingValue); + return next; + }); } }, }; @@ -239,6 +220,108 @@ export function SettingsDialog({ const items = generateSettingsItems(); + // Number edit state + const [editingKey, setEditingKey] = useState<string | null>(null); + const [editBuffer, setEditBuffer] = useState<string>(''); + const [editCursorPos, setEditCursorPos] = useState<number>(0); // Cursor position within edit buffer + const [cursorVisible, setCursorVisible] = useState<boolean>(true); + + useEffect(() => { + if (!editingKey) { + setCursorVisible(true); + return; + } + const id = setInterval(() => setCursorVisible((v) => !v), 500); + return () => clearInterval(id); + }, [editingKey]); + + const startEditingNumber = (key: string, initial?: string) => { + setEditingKey(key); + const initialValue = initial ?? ''; + setEditBuffer(initialValue); + setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value + }; + + const commitNumberEdit = (key: string) => { + if (editBuffer.trim() === '') { + // Nothing entered; cancel edit + setEditingKey(null); + setEditBuffer(''); + setEditCursorPos(0); + return; + } + const parsed = Number(editBuffer.trim()); + if (Number.isNaN(parsed)) { + // Invalid number; cancel edit + setEditingKey(null); + setEditBuffer(''); + setEditCursorPos(0); + return; + } + + // Update pending + setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev)); + + if (!requiresRestart(key)) { + const immediateSettings = new Set([key]); + const immediateSettingsObject = setPendingSettingValueAny( + key, + parsed, + {} as Settings, + ); + saveModifiedSettings( + immediateSettings, + immediateSettingsObject, + settings, + selectedScope, + ); + + // Remove from modified sets if present + setModifiedSettings((prev) => { + const updated = new Set(prev); + updated.delete(key); + return updated; + }); + setRestartRequiredSettings((prev) => { + const updated = new Set(prev); + updated.delete(key); + return updated; + }); + + // Remove from global pending since it's immediately saved + setGlobalPendingChanges((prev) => { + if (!prev.has(key)) return prev; + const next = new Map(prev); + next.delete(key); + return next; + }); + } else { + // Mark as modified and needing restart + setModifiedSettings((prev) => { + const updated = new Set(prev).add(key); + const needsRestart = hasRestartRequiredSettings(updated); + if (needsRestart) { + setShowRestartPrompt(true); + setRestartRequiredSettings((prevRestart) => + new Set(prevRestart).add(key), + ); + } + return updated; + }); + + // Record pending change globally for persistence across scopes + setGlobalPendingChanges((prev) => { + const next = new Map(prev); + next.set(key, parsed as PendingValue); + return next; + }); + } + + setEditingKey(null); + setEditBuffer(''); + setEditCursorPos(0); + }; + // Scope selector items const scopeItems = getScopeItems(); @@ -264,7 +347,83 @@ export function SettingsDialog({ setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings')); } if (focusSection === 'settings') { + // If editing a number, capture numeric input and control keys + if (editingKey) { + if (key.paste && key.sequence) { + const pasted = key.sequence.replace(/[^0-9\-+.]/g, ''); + if (pasted) { + setEditBuffer((b) => { + const before = cpSlice(b, 0, editCursorPos); + const after = cpSlice(b, editCursorPos); + return before + pasted + after; + }); + setEditCursorPos((pos) => pos + cpLen(pasted)); + } + return; + } + if (name === 'backspace' || name === 'delete') { + if (name === 'backspace' && editCursorPos > 0) { + setEditBuffer((b) => { + const before = cpSlice(b, 0, editCursorPos - 1); + const after = cpSlice(b, editCursorPos); + return before + after; + }); + setEditCursorPos((pos) => pos - 1); + } else if (name === 'delete' && editCursorPos < cpLen(editBuffer)) { + setEditBuffer((b) => { + const before = cpSlice(b, 0, editCursorPos); + const after = cpSlice(b, editCursorPos + 1); + return before + after; + }); + // Cursor position stays the same for delete + } + return; + } + if (name === 'escape') { + commitNumberEdit(editingKey); + return; + } + if (name === 'return') { + commitNumberEdit(editingKey); + return; + } + // Allow digits, minus, plus, and dot + const ch = key.sequence; + if (/[0-9\-+.]/.test(ch)) { + setEditBuffer((currentBuffer) => { + const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos); + const afterCursor = cpSlice(currentBuffer, editCursorPos); + return beforeCursor + ch + afterCursor; + }); + setEditCursorPos((pos) => pos + 1); + return; + } + // Arrow key navigation + if (name === 'left') { + setEditCursorPos((pos) => Math.max(0, pos - 1)); + return; + } + if (name === 'right') { + setEditCursorPos((pos) => Math.min(cpLen(editBuffer), pos + 1)); + return; + } + // Home and End keys + if (name === 'home') { + setEditCursorPos(0); + return; + } + if (name === 'end') { + setEditCursorPos(cpLen(editBuffer)); + return; + } + // Block other keys while editing + return; + } if (name === 'up' || name === 'k') { + // If editing, commit first + if (editingKey) { + commitNumberEdit(editingKey); + } const newIndex = activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1; setActiveSettingIndex(newIndex); @@ -275,6 +434,10 @@ export function SettingsDialog({ setScrollOffset(newIndex); } } else if (name === 'down' || name === 'j') { + // If editing, commit first + if (editingKey) { + commitNumberEdit(editingKey); + } const newIndex = activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0; setActiveSettingIndex(newIndex); @@ -285,24 +448,44 @@ export function SettingsDialog({ setScrollOffset(newIndex - maxItemsToShow + 1); } } else if (name === 'return' || name === 'space') { - items[activeSettingIndex]?.toggle(); + const currentItem = items[activeSettingIndex]; + if (currentItem?.type === 'number') { + startEditingNumber(currentItem.value); + } else { + currentItem?.toggle(); + } + } else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) { + const currentItem = items[activeSettingIndex]; + if (currentItem?.type === 'number') { + startEditingNumber(currentItem.value, key.sequence); + } } else if (ctrl && (name === 'c' || name === 'l')) { // Ctrl+C or Ctrl+L: Clear current setting and reset to default const currentSetting = items[activeSettingIndex]; if (currentSetting) { const defaultValue = getDefaultValue(currentSetting.value); - // Ensure defaultValue is a boolean for setPendingSettingValue - const booleanDefaultValue = - typeof defaultValue === 'boolean' ? defaultValue : false; - - // Update pending settings to default value - setPendingSettings((prev) => - setPendingSettingValue( - currentSetting.value, - booleanDefaultValue, - prev, - ), - ); + const defType = currentSetting.type; + if (defType === 'boolean') { + const booleanDefaultValue = + typeof defaultValue === 'boolean' ? defaultValue : false; + setPendingSettings((prev) => + setPendingSettingValue( + currentSetting.value, + booleanDefaultValue, + prev, + ), + ); + } else if (defType === 'number') { + if (typeof defaultValue === 'number') { + setPendingSettings((prev) => + setPendingSettingValueAny( + currentSetting.value, + defaultValue, + prev, + ), + ); + } + } // Remove from modified settings since it's now at default setModifiedSettings((prev) => { @@ -321,11 +504,22 @@ export function SettingsDialog({ // If this setting doesn't require restart, save it immediately if (!requiresRestart(currentSetting.value)) { const immediateSettings = new Set([currentSetting.value]); - const immediateSettingsObject = setPendingSettingValue( - currentSetting.value, - booleanDefaultValue, - {}, - ); + const toSaveValue = + currentSetting.type === 'boolean' + ? typeof defaultValue === 'boolean' + ? defaultValue + : false + : typeof defaultValue === 'number' + ? defaultValue + : undefined; + const immediateSettingsObject = + toSaveValue !== undefined + ? setPendingSettingValueAny( + currentSetting.value, + toSaveValue, + {} as Settings, + ) + : ({} as Settings); saveModifiedSettings( immediateSettings, @@ -333,6 +527,28 @@ export function SettingsDialog({ settings, selectedScope, ); + + // Remove from global pending changes if present + setGlobalPendingChanges((prev) => { + if (!prev.has(currentSetting.value)) return prev; + const next = new Map(prev); + next.delete(currentSetting.value); + return next; + }); + } else { + // Track default reset as a pending change if restart required + if ( + (currentSetting.type === 'boolean' && + typeof defaultValue === 'boolean') || + (currentSetting.type === 'number' && + typeof defaultValue === 'number') + ) { + setGlobalPendingChanges((prev) => { + const next = new Map(prev); + next.set(currentSetting.value, defaultValue as PendingValue); + return next; + }); + } } } } @@ -350,6 +566,16 @@ export function SettingsDialog({ settings, selectedScope, ); + + // Remove saved keys from global pending changes + setGlobalPendingChanges((prev) => { + if (prev.size === 0) return prev; + const next = new Map(prev); + for (const key of restartRequiredSet) { + next.delete(key); + } + return next; + }); } setShowRestartPrompt(false); @@ -357,7 +583,11 @@ export function SettingsDialog({ if (onRestartRequest) onRestartRequest(); } if (name === 'escape') { - onSelect(undefined, selectedScope); + if (editingKey) { + commitNumberEdit(editingKey); + } else { + onSelect(undefined, selectedScope); + } } }, { isActive: true }, @@ -385,13 +615,66 @@ export function SettingsDialog({ const scopeSettings = settings.forScope(selectedScope).settings; const mergedSettings = settings.merged; - const displayValue = getDisplayValue( - item.value, - scopeSettings, - mergedSettings, - modifiedSettings, - pendingSettings, - ); + + let displayValue: string; + if (editingKey === item.value) { + // Show edit buffer with advanced cursor highlighting + if (cursorVisible && editCursorPos < cpLen(editBuffer)) { + // Cursor is in the middle or at start of text + const beforeCursor = cpSlice(editBuffer, 0, editCursorPos); + const atCursor = cpSlice( + editBuffer, + editCursorPos, + editCursorPos + 1, + ); + const afterCursor = cpSlice(editBuffer, editCursorPos + 1); + displayValue = + beforeCursor + chalk.inverse(atCursor) + afterCursor; + } else if (cursorVisible && editCursorPos >= cpLen(editBuffer)) { + // Cursor is at the end - show inverted space + displayValue = editBuffer + chalk.inverse(' '); + } else { + // Cursor not visible + displayValue = editBuffer; + } + } else if (item.type === 'number') { + // For numbers, get the actual current value from pending settings + const path = item.value.split('.'); + const currentValue = getNestedValue(pendingSettings, path); + + const defaultValue = getDefaultValue(item.value); + + if (currentValue !== undefined && currentValue !== null) { + displayValue = String(currentValue); + } else { + displayValue = + defaultValue !== undefined && defaultValue !== null + ? String(defaultValue) + : ''; + } + + // Add * if value differs from default OR if currently being modified + const isModified = modifiedSettings.has(item.value); + const effectiveCurrentValue = + currentValue !== undefined && currentValue !== null + ? currentValue + : defaultValue; + const isDifferentFromDefault = + effectiveCurrentValue !== defaultValue; + + if (isDifferentFromDefault || isModified) { + displayValue += '*'; + } + } else { + // For booleans and other types, use existing logic + displayValue = getDisplayValue( + item.value, + scopeSettings, + mergedSettings, + modifiedSettings, + pendingSettings, + ); + } const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings); // Generate scope message for this setting |
