From 476dc004acf5489c775f06d1789fa0433ac9992e Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sat, 14 Feb 2026 04:13:18 +0000 Subject: [PATCH 1/2] refactor(ui): centralise state via AppContext, fix code quality issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 715-line App.tsx god component with thin shell + AppContext provider - Introduce useReducer in ConfigTab (19 useState → single reducer) - Convert selection arrays to Set for O(1) lookups in FileTree - Add batch select handler for O(n) select-all instead of N state updates - Fix nested interactive elements: outer + + ); }; -const sanitizeConfigForStorage = (configContent: string): string => { - try { - const parsedConfig = yaml.parse(configContent); - if (!parsedConfig || typeof parsedConfig !== 'object') { - return configContent; - } - - const config = parsedConfig as ConfigObject; - if (!config.provider || typeof config.provider !== 'object' || !config.provider.api_key) { - return configContent; - } +const AppContent = () => { + const { + activeTab, + rootPath, + directoryTree, + selectedFiles, + selectedFolders, + processedResult, + configContent, + switchTab, + selectDirectory, + refreshDirectoryTree, + updateConfig, + handleFileSelect, + handleFolderSelect, + handleBatchSelect, + handleAnalyze, + handleRefreshProcessed, + handleSaveOutput, + } = useApp(); - const sanitizedProvider = { ...config.provider }; - delete sanitizedProvider.api_key; - - const sanitizedConfig: ConfigObject = { ...config }; - const providerValues = Object.values(sanitizedProvider).filter((value) => value !== undefined); - if (providerValues.length === 0) { - delete sanitizedConfig.provider; - } else { - sanitizedConfig.provider = sanitizedProvider; - } - - return yaml.stringify(sanitizedConfig); - } catch { - return configContent; - } -}; - -const App = () => { - const [activeTab, setActiveTab] = useState('config'); - const [rootPath, setRootPath] = useState(''); - const [directoryTree, setDirectoryTree] = useState([]); - const [selectedFiles, setSelectedFiles] = useState([]); - const [selectedFolders, setSelectedFolders] = useState([]); - const analysisResultRef = useRef(null); - const [processedResult, setProcessedResult] = useState(null); - const [processingOptions, setProcessingOptions] = useState({ - showTokenCount: true, - includeTreeView: false, - exportFormat: 'markdown', - }); - // Load config from localStorage or via API, no fallbacks - const [configContent, setConfigContent] = useState('# Loading configuration...'); const appWindow = globalThis as Window & typeof globalThis; - const electronAPI = appWindow.electronAPI; - - // Load config from localStorage or default config - useEffect(() => { - // First try to load from localStorage - const savedConfig = localStorage.getItem('configContent'); - if (savedConfig) { - setConfigContent(savedConfig); - } else if (electronAPI?.getDefaultConfig) { - // Otherwise load from the main process - electronAPI - .getDefaultConfig?.() - .then((defaultConfig) => { - if (defaultConfig) { - setConfigContent(defaultConfig); - localStorage.setItem('configContent', sanitizeConfigForStorage(defaultConfig)); - } - }) - .catch((err) => { - console.error('Error loading config:', err); - }); - } - - // Load rootPath from localStorage if available - const savedRootPath = localStorage.getItem('rootPath'); - if (savedRootPath) { - setRootPath(savedRootPath); - // Load directory tree for the saved path - if (electronAPI?.getDirectoryTree) { - electronAPI - .getDirectoryTree?.(savedRootPath, localStorage.getItem('configContent')) - .then((tree) => { - setDirectoryTree(tree ?? []); - }) - .catch((err) => { - console.error('Error loading directory tree:', err); - }); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- electronAPI is a stable preload bridge on globalThis - }, []); - - // Setup path change listener to keep all components in sync - useEffect(() => { - // Create a function to check for rootPath changes - const handleStorageChange = (event: StorageEvent) => { - if (event.key === 'rootPath' && event.newValue && event.newValue !== rootPath) { - // Update our internal state with the new path - setRootPath(event.newValue); - } - }; - - // Add event listener for localStorage changes - appWindow.addEventListener('storage', handleStorageChange); - - // Create an interval to check localStorage directly (for cross-component updates) - const pathSyncInterval = setInterval(() => { - const currentStoredPath = localStorage.getItem('rootPath'); - if (currentStoredPath && currentStoredPath !== rootPath) { - setRootPath(currentStoredPath); - } - }, 500); - - // Cleanup - return () => { - appWindow.removeEventListener('storage', handleStorageChange); - clearInterval(pathSyncInterval); - }; - }, [rootPath, appWindow]); - - // Whenever configContent changes, save to localStorage - useEffect(() => { - localStorage.setItem('configContent', sanitizeConfigForStorage(configContent)); - }, [configContent]); - - const handleTabChange = (tab: TabId) => { - if (activeTab === tab) return; // Don't do anything if clicking the same tab - - // Save current tab configuration to localStorage for all components to access - localStorage.setItem('configContent', sanitizeConfigForStorage(configContent)); - - // When switching tabs, try to do so with consistent state - try { - const config = (yaml.parse(configContent) || {}) as ConfigObject; - - // Make sure arrays are initialized to avoid issues - if (!config.include_extensions) config.include_extensions = []; - if (!config.exclude_patterns) config.exclude_patterns = []; - - // Update processing options from config to maintain consistency - setProcessingOptions({ - showTokenCount: config.show_token_count !== false, - includeTreeView: config.include_tree_view === true, - exportFormat: normalizeExportFormat(config.export_format), - }); - - // Ensure we've saved any config changes before switching tabs - localStorage.setItem('configContent', sanitizeConfigForStorage(configContent)); - } catch (error) { - console.error('Error parsing config when changing tabs:', error); - } - - setActiveTab(tab); - - // If switching from config tab to source tab and we have a root path, refresh the directory tree - // This allows the exclude patterns to be applied when the config is updated - if (activeTab === 'config' && tab === 'source' && rootPath) { - // Reset gitignore parser cache to ensure fresh parsing - appWindow.electronAPI?.resetGitignoreCache?.(); - // refreshDirectoryTree now resets selection states and gets a fresh tree - refreshDirectoryTree(); - } - - // Clear analysis results when switching to source tab - if (tab === 'source') { - analysisResultRef.current = null; - } - - if (tab === 'source') { - setProcessedResult(null); - } - }; - - // Expose the tab change function for other components to use - appWindow.switchToTab = handleTabChange; - - // Function to refresh the directory tree with current config - const refreshDirectoryTree = async () => { - if (rootPath) { - // Reset selection states completely - setSelectedFiles([]); - setSelectedFolders([]); - - // Reset analysis results to prevent stale data - analysisResultRef.current = null; - setProcessedResult(null); - - // Reset gitignore cache to ensure fresh parsing - await appWindow.electronAPI?.resetGitignoreCache?.(); - - // Get fresh directory tree - const tree = await appWindow.electronAPI?.getDirectoryTree?.(rootPath, configContent); - setDirectoryTree(tree ?? []); - } - }; - - // Expose the refreshDirectoryTree function to the global window object for SourceTab to use - appWindow.refreshDirectoryTree = refreshDirectoryTree; - - const handleDirectorySelect = async () => { - const dirPath = await appWindow.electronAPI?.selectDirectory?.(); - - if (dirPath) { - // First reset selection states and analysis results - setSelectedFiles([]); - setSelectedFolders([]); - analysisResultRef.current = null; - setProcessedResult(null); - - // Update rootPath and save to localStorage - setRootPath(dirPath); - localStorage.setItem('rootPath', dirPath); - - // Dispatch a custom event to notify all components of the path change - appWindow.dispatchEvent(new CustomEvent('rootPathChanged', { detail: dirPath })); - - // Reset gitignore cache to ensure fresh parsing - await appWindow.electronAPI?.resetGitignoreCache?.(); - - // Get fresh directory tree - const tree = await appWindow.electronAPI?.getDirectoryTree?.(dirPath, configContent); - setDirectoryTree(tree ?? []); - } - }; - - // Process files directly from Source to Processed Output - const handleAnalyze = async () => { - if (!rootPath || selectedFiles.length === 0) { - alert('Please select a root directory and at least one file.'); - throw new Error('No directory or files selected'); - } - - try { - // Validate selected files before analysis - const validFiles = selectedFiles.filter((file) => { - const withinRoot = isPathWithinRootBoundary(file); - - if (!withinRoot) { - console.warn(`Skipping file outside current root directory: ${file}`); - return false; - } - - return true; - }); - - if (validFiles.length === 0) { - alert( - 'No valid files selected for analysis. Please select files within the current directory.' - ); - throw new Error('No valid files selected'); - } - - if (!appWindow.electronAPI?.analyzeRepository || !appWindow.electronAPI?.processRepository) { - throw new Error('Electron API is not available.'); - } - - // Apply current config before analyzing - const currentAnalysisResult = await appWindow.electronAPI.analyzeRepository({ - rootPath, - configContent, - selectedFiles: validFiles, // Use validated files only - }); - - // Store analysis result - analysisResultRef.current = currentAnalysisResult; - - // Read options from config - const options: ProcessingOptions = { - showTokenCount: true, - includeTreeView: false, - exportFormat: 'markdown', - }; - try { - const config = (yaml.parse(configContent) || {}) as ConfigObject; - options.showTokenCount = config.show_token_count !== false; - options.includeTreeView = config.include_tree_view === true; - options.exportFormat = normalizeExportFormat(config.export_format); - } catch (error) { - console.error('Error parsing config for processing:', ensureError(error)); - } - setProcessingOptions(options); - - // Process directly without going to analyze tab - const result = await appWindow.electronAPI.processRepository({ - rootPath, - filesInfo: currentAnalysisResult.filesInfo ?? [], - treeView: null, // Let the main process handle tree generation - options, - }); - - // Check if the result is valid before using it - if (!result) { - console.error('Processing failed or returned invalid data:', result); - throw new Error('Processing operation failed or did not return expected data.'); - } - - // Set processed result and go directly to processed tab - setProcessedResult(result); - setActiveTab('processed'); - - return currentAnalysisResult; - } catch (error) { - const processedError = ensureError(error); - console.error('Error processing repository:', processedError); - alert(`Error processing repository: ${processedError.message}`); - throw processedError; - } - }; - - const normalizePathForBoundaryCheck = (inputPath: string): string => { - const normalizedSlashes = inputPath.replaceAll('\\', '/'); - const driveMatch = /^[A-Za-z]:/.exec(normalizedSlashes); - const drivePrefix = driveMatch ? driveMatch[0].toLowerCase() : ''; - const pathWithoutDrive = drivePrefix ? normalizedSlashes.slice(2) : normalizedSlashes; - const hasLeadingSlash = pathWithoutDrive.startsWith('/'); - - const segments = pathWithoutDrive.split('/').filter((segment) => segment && segment !== '.'); - const resolvedSegments: string[] = []; - - for (const segment of segments) { - if (segment === '..') { - if (resolvedSegments.length > 0 && resolvedSegments.at(-1) !== '..') { - resolvedSegments.pop(); - } else if (!hasLeadingSlash) { - // Preserve relative parent traversals so boundary checks can reject them. - resolvedSegments.push('..'); - } - continue; - } - - resolvedSegments.push(segment); - } - - return `${drivePrefix}${hasLeadingSlash ? '/' : ''}${resolvedSegments.join('/')}`; - }; - - const isPathWithinRootBoundary = (candidatePath: string): boolean => { - if (!candidatePath || !rootPath) { - return false; - } - - const normalizedRootPath = normalizePathForBoundaryCheck(rootPath); - const normalizedCandidatePath = normalizePathForBoundaryCheck(candidatePath); - - return ( - normalizedCandidatePath === normalizedRootPath || - normalizedCandidatePath.startsWith(`${normalizedRootPath}/`) - ); - }; - - // Method to reload and reprocess files with the latest content - const handleRefreshProcessed = async () => { - try { - // First check if we have valid selections - if (!rootPath || selectedFiles.length === 0) { - alert( - 'No files are selected for processing. Please go to the Source tab and select files.' - ); - return null; - } - - if (!appWindow.electronAPI?.analyzeRepository || !appWindow.electronAPI?.processRepository) { - throw new Error('Electron API is not available.'); - } - - console.log('Reloading and processing files...'); - - // Run a fresh analysis to re-read all files from disk - const currentReanalysisResult = await appWindow.electronAPI.analyzeRepository({ - rootPath, - configContent, - selectedFiles: selectedFiles, - }); - - // Update our state with the fresh analysis - analysisResultRef.current = currentReanalysisResult; - - // Get the latest config options - const options: ProcessingOptions = { ...processingOptions }; - try { - const configStr = localStorage.getItem('configContent'); - if (configStr) { - const config = (yaml.parse(configStr) || {}) as ConfigObject; - options.showTokenCount = config.show_token_count !== false; - options.includeTreeView = config.include_tree_view === true; - options.exportFormat = normalizeExportFormat(config.export_format); - } - } catch (error) { - console.error('Error parsing config for refresh:', ensureError(error)); - } - setProcessingOptions(options); - - console.log('Processing with fresh analysis and options:', options); - - // Process with the fresh analysis - const result = await appWindow.electronAPI.processRepository({ - rootPath, - filesInfo: currentReanalysisResult.filesInfo ?? [], - treeView: null, // Let server generate - options, - }); - - // Check if the result is valid before using it - if (!result) { - console.error('Re-processing failed or returned invalid data:', result); - throw new Error('Re-processing operation failed or did not return expected data.'); - } - - // Update the result and stay on the processed tab - setProcessedResult(result); - return result; - } catch (error) { - const processedError = ensureError(error); - console.error('Error refreshing processed content:', processedError); - alert(`Error refreshing processed content: ${processedError.message}`); - throw processedError; - } - }; - - const handleSaveOutput = async () => { - if (!processedResult) { - alert('No processed content to save.'); - return; - } - - try { - const outputExtension = processedResult.exportFormat === 'xml' ? 'xml' : 'md'; - await appWindow.electronAPI?.saveFile?.({ - content: processedResult.content, - defaultPath: `${rootPath}/output.${outputExtension}`, - }); - } catch (error) { - const processedError = ensureError(error); - console.error('Error saving file:', processedError); - alert(`Error saving file: ${processedError.message}`); - } - }; - - // Utility function for path validation - const isValidFilePath = (filePath: string): boolean => { - // Check if file path exists and is within the current root path - if (!filePath || !rootPath) return false; - - // Ensure the file is within the current root path - return isPathWithinRootBoundary(filePath); - }; - - const handleFileSelect = (filePath: string, isSelected: boolean) => { - // Validate file path before selection - if (isSelected && !isValidFilePath(filePath)) { - console.warn(`Attempted to select an invalid file: ${filePath}`); - return; - } - - if (isSelected) { - setSelectedFiles((prev) => { - // Avoid duplicates using Set - return [...new Set([...prev, filePath])]; - }); - } else { - setSelectedFiles((prev) => prev.filter((path) => path !== filePath)); - } - }; - - const handleFolderSelect = (folderPath: string, isSelected: boolean) => { - // Validate folder path before selection - if (isSelected && !isPathWithinRootBoundary(folderPath)) { - console.warn(`Attempted to select an invalid folder: ${folderPath}`); - return; - } - - // Find the folder in the directory tree - const findFolder = ( - items: DirectoryTreeItem[] | undefined, - targetPath: string - ): DirectoryTreeItem | null => { - for (const item of items ?? []) { - if (item.path === targetPath) { - return item; - } - - if (item.type === 'directory' && item.children) { - const found = findFolder(item.children, targetPath); - if (found) { - return found; - } - } - } - - return null; - }; - - // Get all sub-folders in the folder recursively - const getAllSubFolders = (folder: DirectoryTreeItem): string[] => { - if (!folder.children) return []; - - let folders: string[] = []; - - for (const item of folder.children ?? []) { - if (item.type === 'directory' && isPathWithinRootBoundary(item.path)) { - folders.push(item.path, ...getAllSubFolders(item)); - } - } - - return folders; - }; - - // Get all files in the folder recursively - const getAllFiles = (folder: DirectoryTreeItem): string[] => { - if (!folder.children) return []; - - let files: string[] = []; - - for (const item of folder.children ?? []) { - if (item.type === 'file') { - // Validate each file is within current root - if (isPathWithinRootBoundary(item.path)) { - files.push(item.path); - } - } else if (item.type === 'directory') { - files = [...files, ...getAllFiles(item)]; - } - } - - return files; - }; - - const folder = findFolder(directoryTree, folderPath); - - if (folder) { - // Get all subfolders - const subFolders = getAllSubFolders(folder); - - // Get all files - const files = getAllFiles(folder); - - // Update selected folders state - if (isSelected) { - // Add this folder and all sub-folders - setSelectedFolders((prev) => { - const allFolders = [folderPath, ...subFolders]; - // Filter out duplicates - return [...new Set([...prev, ...allFolders])]; - }); - - // Add all files in this folder and sub-folders - setSelectedFiles((prev) => { - // Filter out duplicates - return [...new Set([...prev, ...files])]; - }); - } else { - // Remove this folder and all sub-folders - setSelectedFolders((prev) => - prev.filter((path) => path !== folderPath && !subFolders.includes(path)) - ); - - // Remove all files in this folder and sub-folders - setSelectedFiles((prev) => prev.filter((path) => !files.includes(path))); - } - } - }; return ( - -
- {/* Tab navigation and content container */} -
- {/* Tab Bar and title in the same row */} -
- -
- - -
+ +
+
+

AI Code Fusion

+ + + +
+
+
+ + - {/* Tab content */} + {/* Tab content - tabs stay mounted, hidden via CSS to preserve state */} +
- {activeTab === 'config' && ( - - )} + +
- {activeTab === 'source' && ( - - )} +
+ +
- {activeTab === 'processed' && ( - - )} +
+
+ + ); +}; + +const App = () => { + return ( + + + + ); }; diff --git a/src/renderer/components/ConfigTab.tsx b/src/renderer/components/ConfigTab.tsx index 6e7cb51..0d7ee45 100755 --- a/src/renderer/components/ConfigTab.tsx +++ b/src/renderer/components/ConfigTab.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import yaml from 'yaml'; import { normalizeExportFormat } from '../../utils/export-format'; import { yamlArrayToPlainText } from '../../utils/formatters/list-formatter'; +import { useApp } from '../context/AppContext'; import type { ConfigObject, @@ -16,19 +17,6 @@ type ConfigTabProps = { onConfigChange: (config: string) => void; }; -type ConfigStateSetters = { - setFileExtensions: React.Dispatch>; - setExcludePatterns: React.Dispatch>; - setUseCustomExcludes: React.Dispatch>; - setUseCustomIncludes: React.Dispatch>; - setUseGitignore: React.Dispatch>; - setEnableSecretScanning: React.Dispatch>; - setExcludeSuspiciousFiles: React.Dispatch>; - setIncludeTreeView: React.Dispatch>; - setShowTokenCount: React.Dispatch>; - setExportFormat: React.Dispatch>; -}; - const PROVIDER_OPTIONS: Array<{ id: ProviderId; label: string; @@ -129,132 +117,99 @@ const getProviderValidationErrors = (providerFields: { return errors; }; -// Helper functions for extension and pattern handling to reduce complexity -const processExtensions = ( - config: ConfigObject, - setFileExtensions: React.Dispatch> -) => { - setFileExtensions( - config?.include_extensions && Array.isArray(config.include_extensions) - ? yamlArrayToPlainText(config.include_extensions) - : '' - ); -}; - -const processPatterns = ( - config: ConfigObject, - setExcludePatterns: React.Dispatch> -) => { - setExcludePatterns( - config?.exclude_patterns && Array.isArray(config.exclude_patterns) - ? yamlArrayToPlainText(config.exclude_patterns) - : '' - ); +// Config form state managed by useReducer +type ConfigFormState = { + useCustomExcludes: boolean; + useCustomIncludes: boolean; + useGitignore: boolean; + enableSecretScanning: boolean; + excludeSuspiciousFiles: boolean; + includeTreeView: boolean; + showTokenCount: boolean; + exportFormat: ExportFormat; + fileExtensions: string; + excludePatterns: string; + providerId: ProviderId | ''; + providerModel: string; + providerApiKey: string; + providerBaseUrl: string; }; -// Helper function to update config-related states -const updateConfigStates = (config: ConfigObject, stateSetters: ConfigStateSetters) => { - const { - setFileExtensions, - setExcludePatterns, - setUseCustomExcludes, - setUseCustomIncludes, - setUseGitignore, - setEnableSecretScanning, - setExcludeSuspiciousFiles, - setIncludeTreeView, - setShowTokenCount, - setExportFormat, - } = stateSetters; - - // Process extensions and patterns - processExtensions(config, setFileExtensions); - processPatterns(config, setExcludePatterns); - - // Set checkbox states - if (config?.use_custom_excludes !== undefined) { - setUseCustomExcludes(config.use_custom_excludes !== false); - } - - if (config?.use_custom_includes !== undefined) { - setUseCustomIncludes(config.use_custom_includes !== false); - } - - if (config?.use_gitignore !== undefined) { - setUseGitignore(config.use_gitignore !== false); - } - - if (config?.enable_secret_scanning !== undefined) { - setEnableSecretScanning(config.enable_secret_scanning !== false); - } +type ConfigFormAction = + | { type: 'SET_FIELD'; field: keyof ConfigFormState; value: ConfigFormState[keyof ConfigFormState] } + | { type: 'LOAD_FROM_CONFIG'; config: ConfigObject }; - if (config?.exclude_suspicious_files !== undefined) { - setExcludeSuspiciousFiles(config.exclude_suspicious_files !== false); - } - - if (config?.include_tree_view !== undefined) { - setIncludeTreeView(config.include_tree_view === true); - } - - if (config?.show_token_count !== undefined) { - setShowTokenCount(config.show_token_count === true); +const configFormReducer = (state: ConfigFormState, action: ConfigFormAction): ConfigFormState => { + switch (action.type) { + case 'SET_FIELD': + return { ...state, [action.field]: action.value }; + case 'LOAD_FROM_CONFIG': { + const config = action.config; + const providerConfig = config?.provider ?? {}; + return { + ...state, + fileExtensions: + config?.include_extensions && Array.isArray(config.include_extensions) + ? yamlArrayToPlainText(config.include_extensions) + : '', + excludePatterns: + config?.exclude_patterns && Array.isArray(config.exclude_patterns) + ? yamlArrayToPlainText(config.exclude_patterns) + : '', + useCustomExcludes: config?.use_custom_excludes !== false, + useCustomIncludes: config?.use_custom_includes !== false, + useGitignore: config?.use_gitignore !== false, + enableSecretScanning: config?.enable_secret_scanning !== false, + excludeSuspiciousFiles: config?.exclude_suspicious_files !== false, + includeTreeView: config?.include_tree_view === true, + showTokenCount: config?.show_token_count !== false, + exportFormat: normalizeExportFormat(config?.export_format), + providerId: isSupportedProviderId(providerConfig.id) ? providerConfig.id : '', + providerModel: typeof providerConfig.model === 'string' ? providerConfig.model : '', + providerApiKey: typeof providerConfig.api_key === 'string' ? providerConfig.api_key : '', + providerBaseUrl: typeof providerConfig.base_url === 'string' ? providerConfig.base_url : '', + }; + } + default: + return state; } +}; - if (config?.export_format !== undefined) { - setExportFormat(normalizeExportFormat(config.export_format)); - } +const initialFormState: ConfigFormState = { + useCustomExcludes: true, + useCustomIncludes: true, + useGitignore: true, + enableSecretScanning: true, + excludeSuspiciousFiles: true, + includeTreeView: true, + showTokenCount: true, + exportFormat: 'markdown', + fileExtensions: '', + excludePatterns: '', + providerId: '', + providerModel: '', + providerApiKey: '', + providerBaseUrl: '', }; const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { + const { rootPath, selectDirectory, switchTab } = useApp(); + const [formState, dispatch] = useReducer(configFormReducer, initialFormState); const [isSaved, setIsSaved] = useState(false); - const [useCustomExcludes, setUseCustomExcludes] = useState(true); - const [useCustomIncludes, setUseCustomIncludes] = useState(true); - const [useGitignore, setUseGitignore] = useState(true); - const [enableSecretScanning, setEnableSecretScanning] = useState(true); - const [excludeSuspiciousFiles, setExcludeSuspiciousFiles] = useState(true); - const [includeTreeView, setIncludeTreeView] = useState(true); - const [showTokenCount, setShowTokenCount] = useState(true); - const [exportFormat, setExportFormat] = useState('markdown'); - const [fileExtensions, setFileExtensions] = useState(''); - const [excludePatterns, setExcludePatterns] = useState(''); - const [providerId, setProviderId] = useState(''); - const [providerModel, setProviderModel] = useState(''); - const [providerApiKey, setProviderApiKey] = useState(''); - const [providerBaseUrl, setProviderBaseUrl] = useState(''); const [providerValidationErrors, setProviderValidationErrors] = useState([]); const [providerTestResult, setProviderTestResult] = useState( null ); const [isTestingProviderConnection, setIsTestingProviderConnection] = useState(false); + const formStateRef = useRef(formState); + formStateRef.current = formState; const appWindow = globalThis as Window & typeof globalThis; - // Extract and set file extensions and exclude patterns sections + // Load form state from config prop useEffect(() => { try { - // Parse the YAML config const config = (yaml.parse(configContent) || {}) as ConfigObject; - - // Use helper function to update states - updateConfigStates(config, { - setFileExtensions, - setExcludePatterns, - setUseCustomExcludes, - setUseCustomIncludes, - setUseGitignore, - setEnableSecretScanning, - setExcludeSuspiciousFiles, - setIncludeTreeView, - setShowTokenCount, - setExportFormat, - }); - - const providerConfig = config?.provider ?? {}; - setProviderId(isSupportedProviderId(providerConfig.id) ? providerConfig.id : ''); - setProviderModel(typeof providerConfig.model === 'string' ? providerConfig.model : ''); - setProviderApiKey(typeof providerConfig.api_key === 'string' ? providerConfig.api_key : ''); - setProviderBaseUrl( - typeof providerConfig.base_url === 'string' ? providerConfig.base_url : '' - ); + dispatch({ type: 'LOAD_FROM_CONFIG', config }); setProviderValidationErrors([]); setProviderTestResult(null); } catch (error) { @@ -262,128 +217,111 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { } }, [configContent]); - // Auto-save function whenever options change or manual save - const saveConfig = useCallback(() => { - try { - let config: ConfigObject; - + // Save config from form state - accepts explicit state to keep identity stable + const saveConfig = useCallback( + (state: ConfigFormState) => { try { - // Parse the current config - config = yaml.parse(configContent) as ConfigObject; - // If parsing returns null or undefined, use empty object - if (!config) { + let config: ConfigObject; + + try { + config = yaml.parse(configContent) as ConfigObject; + if (!config) { + config = {}; + } + } catch (error) { + console.error('Error parsing config content, using empty config:', error); config = {}; } - } catch (error) { - console.error('Error parsing config content, using empty config:', error); - config = {}; - } - // Update with current values - config.use_custom_excludes = useCustomExcludes; - config.use_custom_includes = useCustomIncludes; - config.use_gitignore = useGitignore; - config.enable_secret_scanning = enableSecretScanning; - config.exclude_suspicious_files = excludeSuspiciousFiles; - config.include_tree_view = includeTreeView; - config.show_token_count = showTokenCount; - config.export_format = exportFormat; - - // Process file extensions from the textarea - config.include_extensions = fileExtensions - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0); - - // Process exclude patterns from the textarea - config.exclude_patterns = excludePatterns - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0); - - const providerFields = { - providerId, - providerModel, - providerApiKey, - providerBaseUrl, - }; - const validationErrors = getProviderValidationErrors(providerFields); - const hasProviderValidationErrors = validationErrors.length > 0; - - if (hasProviderValidationErrors) { - setProviderValidationErrors(validationErrors); - setProviderTestResult({ - ok: false, - message: 'Fix provider settings before saving.', - }); - } + config.use_custom_excludes = state.useCustomExcludes; + config.use_custom_includes = state.useCustomIncludes; + config.use_gitignore = state.useGitignore; + config.enable_secret_scanning = state.enableSecretScanning; + config.exclude_suspicious_files = state.excludeSuspiciousFiles; + config.include_tree_view = state.includeTreeView; + config.show_token_count = state.showTokenCount; + config.export_format = state.exportFormat; + + config.include_extensions = state.fileExtensions + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + config.exclude_patterns = state.excludePatterns + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const providerFields = { + providerId: state.providerId, + providerModel: state.providerModel, + providerApiKey: state.providerApiKey, + providerBaseUrl: state.providerBaseUrl, + }; + const validationErrors = getProviderValidationErrors(providerFields); + const hasProviderValidationErrors = validationErrors.length > 0; + + if (hasProviderValidationErrors) { + setProviderValidationErrors(validationErrors); + setProviderTestResult({ + ok: false, + message: 'Fix provider settings before saving.', + }); + } - if (hasProviderValidationErrors) { - if (config.provider) { + if (hasProviderValidationErrors) { + if (config.provider) { + delete config.provider; + } + } else if (hasProviderInput(providerFields) && state.providerId) { + config.provider = { + id: state.providerId, + model: state.providerModel.trim(), + api_key: trimToUndefined(state.providerApiKey), + base_url: trimToUndefined(state.providerBaseUrl), + }; + } else if (config.provider) { delete config.provider; } - } else if (hasProviderInput(providerFields) && providerId) { - config.provider = { - id: providerId, - model: providerModel.trim(), - api_key: trimToUndefined(providerApiKey), - base_url: trimToUndefined(providerBaseUrl), - }; - } else if (config.provider) { - delete config.provider; - } - // Convert back to YAML and save - const updatedConfig = yaml.stringify(config); - onConfigChange(updatedConfig); + const updatedConfig = yaml.stringify(config); - if (hasProviderValidationErrors) { - setIsSaved(false); - } else { - // Show saved indicator - setProviderValidationErrors([]); - setIsSaved(true); - setTimeout(() => { + // Guard against no-op updates to prevent circular effects + if (updatedConfig === configContent) { + return; + } + + onConfigChange(updatedConfig); + + if (hasProviderValidationErrors) { setIsSaved(false); - }, 1500); + } else { + setProviderValidationErrors([]); + setIsSaved(true); + setTimeout(() => { + setIsSaved(false); + }, 1500); + } + } catch (error) { + console.error('Error updating config:', error); } - } catch (error) { - console.error('Error updating config:', error); - alert('Error updating configuration. Please check the YAML syntax.'); - } - }, [ - configContent, - useCustomExcludes, - useCustomIncludes, - useGitignore, - enableSecretScanning, - excludeSuspiciousFiles, - includeTreeView, - showTokenCount, - exportFormat, - fileExtensions, - excludePatterns, - providerId, - providerModel, - providerApiKey, - providerBaseUrl, - onConfigChange, - ]); + }, + [configContent, onConfigChange] + ); - // Auto-save whenever any option changes, but with a small delay to prevent - // circular updates and rapid toggling + // Auto-save on checkbox/select changes (not text fields - those save on blur/button) useEffect(() => { - const timer = setTimeout(saveConfig, 50); + const timer = setTimeout(() => saveConfig(formStateRef.current), 50); return () => clearTimeout(timer); }, [ - useCustomExcludes, - useCustomIncludes, - useGitignore, - enableSecretScanning, - excludeSuspiciousFiles, - includeTreeView, - showTokenCount, - exportFormat, + formState.useCustomExcludes, + formState.useCustomIncludes, + formState.useGitignore, + formState.enableSecretScanning, + formState.excludeSuspiciousFiles, + formState.includeTreeView, + formState.showTokenCount, + formState.exportFormat, saveConfig, ]); @@ -394,10 +332,10 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { const handleTestProviderConnection = async () => { const providerFields = { - providerId, - providerModel, - providerApiKey, - providerBaseUrl, + providerId: formState.providerId, + providerModel: formState.providerModel, + providerApiKey: formState.providerApiKey, + providerBaseUrl: formState.providerBaseUrl, }; const validationErrors = getProviderValidationErrors(providerFields); if (validationErrors.length > 0) { @@ -409,7 +347,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { return; } - if (!providerId) { + if (!formState.providerId) { setProviderValidationErrors(['Select a provider.']); return; } @@ -427,10 +365,10 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { try { const result = await appWindow.electronAPI.testProviderConnection({ - providerId, - model: providerModel.trim(), - apiKey: providerApiKey.trim(), - baseUrl: trimToUndefined(providerBaseUrl), + providerId: formState.providerId, + model: formState.providerModel.trim(), + apiKey: formState.providerApiKey.trim(), + baseUrl: trimToUndefined(formState.providerBaseUrl), }); setProviderTestResult(result); if (result.ok) { @@ -448,67 +386,15 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { } }; - // State to track the current folder path - const [folderPath, setFolderPath] = useState(localStorage.getItem('rootPath') || ''); - - // Listen for path changes from other components - useEffect(() => { - // Function to update our path when localStorage changes - const checkForPathChanges = () => { - const currentPath = localStorage.getItem('rootPath'); - if (currentPath && currentPath !== folderPath) { - setFolderPath(currentPath); - } - }; - - // Check immediately - checkForPathChanges(); - - // Setup interval to check for changes - const pathCheckInterval = setInterval(checkForPathChanges, 500); - - // Listen for custom events - const handleRootPathChanged = (event: Event) => { - const customEvent = event as CustomEvent; - const detail = customEvent.detail; - if (detail && detail !== folderPath) { - setFolderPath(detail); - } - }; - - appWindow.addEventListener('rootPathChanged', handleRootPathChanged); - - return () => { - clearInterval(pathCheckInterval); - appWindow.removeEventListener('rootPathChanged', handleRootPathChanged); - }; - }, [folderPath, appWindow]); - - // Handle folder selection const handleFolderSelect = async () => { - if (appWindow.electronAPI?.selectDirectory) { - const dirPath = await appWindow.electronAPI.selectDirectory?.(); - if (dirPath) { - // Store the selected path in localStorage for use across the app - localStorage.setItem('rootPath', dirPath); - setFolderPath(dirPath); - - // Dispatch a custom event to notify other components - appWindow.dispatchEvent(new CustomEvent('rootPathChanged', { detail: dirPath })); - - // Automatically switch to Select Files tab - setTimeout(() => { - goToSourceTab(); - }, 500); - } + const selected = await selectDirectory(); + if (selected) { + switchTab('source'); } }; - const goToSourceTab = () => { - // Switch to the Source tab - if (appWindow.switchToTab) { - appWindow.switchToTab('source'); - } + const setField = (field: K, value: ConfigFormState[K]) => { + dispatch({ type: 'SET_FIELD', field, value }); }; return ( @@ -519,7 +405,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { { setUseCustomIncludes(e.target.checked)} + checked={formState.useCustomIncludes} + onChange={(e) => setField('useCustomIncludes', e.target.checked)} className='size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500' />