diff --git a/src/renderer/components/ConfigTab.tsx b/src/renderer/components/ConfigTab.tsx index 2746c0f..889df6c 100755 --- a/src/renderer/components/ConfigTab.tsx +++ b/src/renderer/components/ConfigTab.tsx @@ -1,303 +1,33 @@ import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import yaml from 'yaml'; +import * as yaml from 'yaml'; import { normalizeExportFormat } from '../../utils/export-format'; -import { yamlArrayToPlainText } from '../../utils/formatters/list-formatter'; import { useApp } from '../context/AppContext'; import { isAiSurfacesEnabled } from '../feature-flags'; -import type { - ConfigObject, - ExportFormat, - ProviderConnectionResult, - ProviderId, -} from '../../types/ipc'; +import { + applyBaseConfigState, + applyProviderConfigState, + configFormReducer, + initialFormState, + parseConfigContent, +} from './config-tab/config-form'; +import { + getProviderValidationErrors, + isSupportedProviderId, + PROVIDER_OPTIONS, + trimToUndefined, +} from './config-tab/provider-utils'; + +import type { ConfigFormState } from './config-tab/config-form'; +import type { ProviderConnectionResult } from '../../types/ipc'; type ConfigTabProps = { configContent: string; onConfigChange: (config: string) => void; }; -const PROVIDER_OPTIONS: Array<{ - id: ProviderId; - label: string; - defaultBaseUrl: string; - requiresApiKey: boolean; -}> = [ - { - id: 'openai', - label: 'OpenAI', - defaultBaseUrl: 'https://api.openai.com/v1', - requiresApiKey: true, - }, - { - id: 'anthropic', - label: 'Anthropic', - defaultBaseUrl: 'https://api.anthropic.com/v1', - requiresApiKey: true, - }, - { - id: 'ollama', - label: 'Ollama (local)', - defaultBaseUrl: 'http://127.0.0.1:11434', - requiresApiKey: false, - }, - { - id: 'openai-compatible', - label: 'OpenAI-compatible', - defaultBaseUrl: 'http://127.0.0.1:8080/v1', - requiresApiKey: true, - }, -]; - -const isSupportedProviderId = (value: unknown): value is ProviderId => { - return ( - typeof value === 'string' && - PROVIDER_OPTIONS.some((providerOption) => providerOption.id === value) - ); -}; - -const trimToUndefined = (value: string): string | undefined => { - const trimmedValue = value.trim(); - return trimmedValue.length > 0 ? trimmedValue : undefined; -}; - -const hasProviderInput = (providerFields: { - providerId: ProviderId | ''; - providerModel: string; - providerApiKey: string; - providerBaseUrl: string; -}): boolean => { - return Boolean( - providerFields.providerId || - providerFields.providerModel.trim() || - providerFields.providerApiKey.trim() || - providerFields.providerBaseUrl.trim() - ); -}; - -const getProviderValidationErrors = (providerFields: { - providerId: ProviderId | ''; - providerModel: string; - providerApiKey: string; - providerBaseUrl: string; -}, translate: (key: string) => string): string[] => { - if (!hasProviderInput(providerFields)) { - return []; - } - - const errors: string[] = []; - const { providerId, providerModel, providerApiKey, providerBaseUrl } = providerFields; - - if (!providerId) { - errors.push(translate('config.validation.selectProvider')); - } - - if (!providerModel.trim()) { - errors.push(translate('config.validation.modelRequired')); - } - - const selectedProviderOption = PROVIDER_OPTIONS.find( - (providerOption) => providerOption.id === providerId - ); - if (selectedProviderOption?.requiresApiKey && !providerApiKey.trim()) { - errors.push(translate('config.validation.apiKeyRequired')); - } - - if (providerBaseUrl.trim()) { - try { - const parsedUrl = new URL(providerBaseUrl.trim()); - if (!['http:', 'https:'].includes(parsedUrl.protocol)) { - errors.push(translate('config.validation.baseUrlProtocol')); - } - } catch { - errors.push(translate('config.validation.baseUrlValid')); - } - } - - return errors; -}; - -// 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; -}; - -type ConfigFormAction = - | { type: 'SET_FIELD'; field: keyof ConfigFormState; value: ConfigFormState[keyof ConfigFormState] } - | { type: 'LOAD_FROM_CONFIG'; config: ConfigObject; aiSurfacesEnabled: boolean }; - -const toPlainTextList = (value: unknown): string => { - return Array.isArray(value) ? yamlArrayToPlainText(value) : ''; -}; - -const toTrimmedLines = (value: string): string[] => { - return value - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0); -}; - -const extractProviderFormFields = ( - config: ConfigObject, - aiSurfacesEnabled: boolean -): Pick => { - if (!aiSurfacesEnabled || !config.provider) { - return { - providerId: '', - providerModel: '', - providerApiKey: '', - providerBaseUrl: '', - }; - } - - const providerConfig = config.provider; - return { - 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 : '', - }; -}; - -const loadFormStateFromConfig = ( - state: ConfigFormState, - config: ConfigObject, - aiSurfacesEnabled: boolean -): ConfigFormState => { - const providerFields = extractProviderFormFields(config, aiSurfacesEnabled); - return { - ...state, - fileExtensions: toPlainTextList(config.include_extensions), - excludePatterns: toPlainTextList(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), - ...providerFields, - }; -}; - -const configFormReducer = (state: ConfigFormState, action: ConfigFormAction): ConfigFormState => { - switch (action.type) { - case 'SET_FIELD': - return { ...state, [action.field]: action.value }; - case 'LOAD_FROM_CONFIG': - return loadFormStateFromConfig(state, action.config, action.aiSurfacesEnabled); - default: - return state; - } -}; - -const initialFormState: ConfigFormState = { - useCustomExcludes: true, - useCustomIncludes: true, - useGitignore: true, - enableSecretScanning: true, - excludeSuspiciousFiles: true, - includeTreeView: false, - showTokenCount: true, - exportFormat: 'markdown', - fileExtensions: '', - excludePatterns: '', - providerId: '', - providerModel: '', - providerApiKey: '', - providerBaseUrl: '', -}; - -const parseConfigContent = (configContent: string): ConfigObject => { - try { - const parsedConfig = yaml.parse(configContent) as ConfigObject; - if (!parsedConfig || typeof parsedConfig !== 'object') { - return {}; - } - return parsedConfig; - } catch (error) { - console.error('Error parsing config content, using empty config:', error); - return {}; - } -}; - -const applyBaseConfigState = (config: ConfigObject, state: ConfigFormState): void => { - 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 = toTrimmedLines(state.fileExtensions); - config.exclude_patterns = toTrimmedLines(state.excludePatterns); -}; - -type ProviderConfigSaveResult = { - hasValidationErrors: boolean; - validationErrors: string[]; -}; - -const applyProviderConfigState = ( - config: ConfigObject, - state: ConfigFormState, - aiSurfacesEnabled: boolean, - translate: (key: string) => string -): ProviderConfigSaveResult => { - if (!aiSurfacesEnabled) { - return { hasValidationErrors: false, validationErrors: [] }; - } - - const providerFields = { - providerId: state.providerId, - providerModel: state.providerModel, - providerApiKey: state.providerApiKey, - providerBaseUrl: state.providerBaseUrl, - }; - const validationErrors = getProviderValidationErrors(providerFields, translate); - const hasValidationErrors = validationErrors.length > 0; - - if (hasValidationErrors) { - if (config.provider) { - delete config.provider; - } - return { hasValidationErrors, validationErrors }; - } - - if (hasProviderInput(providerFields) && state.providerId) { - config.provider = { - id: state.providerId, - model: state.providerModel.trim(), - api_key: trimToUndefined(state.providerApiKey), - base_url: trimToUndefined(state.providerBaseUrl), - }; - return { hasValidationErrors, validationErrors }; - } - - if (config.provider) { - delete config.provider; - } - - return { hasValidationErrors, validationErrors }; -}; - const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { const { t } = useTranslation(); const { rootPath, selectDirectory, switchTab } = useApp(); @@ -316,7 +46,10 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { // Load form state from config prop useEffect(() => { try { - const config = (yaml.parse(configContent) || {}) as ConfigObject; + const config = parseConfigContent(configContent); + if (!config) { + return; + } dispatch({ type: 'LOAD_FROM_CONFIG', config, aiSurfacesEnabled }); setProviderValidationErrors([]); setProviderTestResult(null); @@ -329,7 +62,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { const saveConfig = useCallback( (state: ConfigFormState) => { try { - const config = parseConfigContent(configContent); + const config = parseConfigContent(configContent) ?? {}; applyBaseConfigState(config, state); const providerResult = applyProviderConfigState(config, state, aiSurfacesEnabled, t); diff --git a/src/renderer/components/config-tab/config-form.ts b/src/renderer/components/config-tab/config-form.ts new file mode 100644 index 0000000..8dd391b --- /dev/null +++ b/src/renderer/components/config-tab/config-form.ts @@ -0,0 +1,198 @@ +import * as yaml from 'yaml'; + +import { normalizeExportFormat } from '../../../utils/export-format'; +import { yamlArrayToPlainText } from '../../../utils/formatters/list-formatter'; + +import { + getProviderValidationErrors, + hasProviderInput, + isSupportedProviderId, + trimToUndefined, +} from './provider-utils'; + +import type { ConfigObject, ExportFormat, ProviderId } from '../../../types/ipc'; + +export 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; +}; + +type SetFieldAction = { + [K in keyof ConfigFormState]: { + type: 'SET_FIELD'; + field: K; + value: ConfigFormState[K]; + }; +}[keyof ConfigFormState]; + +export type ConfigFormAction = + | SetFieldAction + | { type: 'LOAD_FROM_CONFIG'; config: ConfigObject; aiSurfacesEnabled: boolean }; + +export const initialFormState: ConfigFormState = { + useCustomExcludes: true, + useCustomIncludes: true, + useGitignore: true, + enableSecretScanning: true, + excludeSuspiciousFiles: true, + includeTreeView: false, + showTokenCount: true, + exportFormat: 'markdown', + fileExtensions: '', + excludePatterns: '', + providerId: '', + providerModel: '', + providerApiKey: '', + providerBaseUrl: '', +}; + +const toPlainTextList = (value: unknown): string => { + return Array.isArray(value) ? yamlArrayToPlainText(value) : ''; +}; + +const toTrimmedLines = (value: string): string[] => { + return value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +}; + +const extractProviderFormFields = ( + config: ConfigObject, + aiSurfacesEnabled: boolean +): Pick => { + if (!aiSurfacesEnabled || !config.provider) { + return { + providerId: '', + providerModel: '', + providerApiKey: '', + providerBaseUrl: '', + }; + } + + const providerConfig = config.provider; + return { + 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 : '', + }; +}; + +const loadFormStateFromConfig = ( + state: ConfigFormState, + config: ConfigObject, + aiSurfacesEnabled: boolean +): ConfigFormState => { + const providerFields = extractProviderFormFields(config, aiSurfacesEnabled); + return { + ...state, + fileExtensions: toPlainTextList(config.include_extensions), + excludePatterns: toPlainTextList(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), + ...providerFields, + }; +}; + +export const configFormReducer = (state: ConfigFormState, action: ConfigFormAction): ConfigFormState => { + switch (action.type) { + case 'SET_FIELD': + return { ...state, [action.field]: action.value }; + case 'LOAD_FROM_CONFIG': + return loadFormStateFromConfig(state, action.config, action.aiSurfacesEnabled); + default: + return state; + } +}; + +export const parseConfigContent = (configContent: string): ConfigObject | null => { + try { + const parsedConfig = yaml.parse(configContent) as ConfigObject; + if (!parsedConfig || typeof parsedConfig !== 'object') { + return {}; + } + return parsedConfig; + } catch { + return null; + } +}; + +export const applyBaseConfigState = (config: ConfigObject, state: ConfigFormState): void => { + 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 = toTrimmedLines(state.fileExtensions); + config.exclude_patterns = toTrimmedLines(state.excludePatterns); +}; + +export type ProviderConfigSaveResult = { + hasValidationErrors: boolean; + validationErrors: string[]; +}; + +export const applyProviderConfigState = ( + config: ConfigObject, + state: ConfigFormState, + aiSurfacesEnabled: boolean, + translate: (key: string) => string +): ProviderConfigSaveResult => { + if (!aiSurfacesEnabled) { + return { hasValidationErrors: false, validationErrors: [] }; + } + + const providerFields = { + providerId: state.providerId, + providerModel: state.providerModel, + providerApiKey: state.providerApiKey, + providerBaseUrl: state.providerBaseUrl, + }; + const validationErrors = getProviderValidationErrors(providerFields, translate); + const hasValidationErrors = validationErrors.length > 0; + + if (hasValidationErrors) { + if (config.provider) { + delete config.provider; + } + return { hasValidationErrors, validationErrors }; + } + + if (hasProviderInput(providerFields) && state.providerId) { + config.provider = { + id: state.providerId, + model: state.providerModel.trim(), + api_key: trimToUndefined(state.providerApiKey), + base_url: trimToUndefined(state.providerBaseUrl), + }; + return { hasValidationErrors, validationErrors }; + } + + if (config.provider) { + delete config.provider; + } + + return { hasValidationErrors, validationErrors }; +}; diff --git a/src/renderer/components/config-tab/provider-utils.ts b/src/renderer/components/config-tab/provider-utils.ts new file mode 100644 index 0000000..6fa43f9 --- /dev/null +++ b/src/renderer/components/config-tab/provider-utils.ts @@ -0,0 +1,103 @@ +import type { ProviderId } from '../../../types/ipc'; + +export type ProviderOption = { + id: ProviderId; + label: string; + defaultBaseUrl: string; + requiresApiKey: boolean; +}; + +export const PROVIDER_OPTIONS: ProviderOption[] = [ + { + id: 'openai', + label: 'OpenAI', + defaultBaseUrl: 'https://api.openai.com/v1', + requiresApiKey: true, + }, + { + id: 'anthropic', + label: 'Anthropic', + defaultBaseUrl: 'https://api.anthropic.com/v1', + requiresApiKey: true, + }, + { + id: 'ollama', + label: 'Ollama (local)', + defaultBaseUrl: 'http://127.0.0.1:11434', + requiresApiKey: false, + }, + { + id: 'openai-compatible', + label: 'OpenAI-compatible', + defaultBaseUrl: 'http://127.0.0.1:8080/v1', + requiresApiKey: true, + }, +]; + +export type ProviderFields = { + providerId: ProviderId | ''; + providerModel: string; + providerApiKey: string; + providerBaseUrl: string; +}; + +export const isSupportedProviderId = (value: unknown): value is ProviderId => { + return ( + typeof value === 'string' && + PROVIDER_OPTIONS.some((providerOption) => providerOption.id === value) + ); +}; + +export const trimToUndefined = (value: string): string | undefined => { + const trimmedValue = value.trim(); + return trimmedValue.length > 0 ? trimmedValue : undefined; +}; + +export const hasProviderInput = (providerFields: ProviderFields): boolean => { + return Boolean( + providerFields.providerId || + providerFields.providerModel.trim() || + providerFields.providerApiKey.trim() || + providerFields.providerBaseUrl.trim() + ); +}; + +export const getProviderValidationErrors = ( + providerFields: ProviderFields, + translate: (key: string) => string +): string[] => { + if (!hasProviderInput(providerFields)) { + return []; + } + + const errors: string[] = []; + const { providerId, providerModel, providerApiKey, providerBaseUrl } = providerFields; + + if (!providerId) { + errors.push(translate('config.validation.selectProvider')); + } + + if (!providerModel.trim()) { + errors.push(translate('config.validation.modelRequired')); + } + + const selectedProviderOption = PROVIDER_OPTIONS.find( + (providerOption) => providerOption.id === providerId + ); + if (selectedProviderOption?.requiresApiKey && !providerApiKey.trim()) { + errors.push(translate('config.validation.apiKeyRequired')); + } + + if (providerBaseUrl.trim()) { + try { + const parsedUrl = new URL(providerBaseUrl.trim()); + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + errors.push(translate('config.validation.baseUrlProtocol')); + } + } catch { + errors.push(translate('config.validation.baseUrlValid')); + } + } + + return errors; +}; diff --git a/src/renderer/context/AppContext.tsx b/src/renderer/context/AppContext.tsx index 24ca1b4..577e423 100644 --- a/src/renderer/context/AppContext.tsx +++ b/src/renderer/context/AppContext.tsx @@ -4,6 +4,15 @@ import yaml from 'yaml'; import { normalizeExportFormat } from '../../utils/export-format'; import i18n from '../i18n'; +import { INITIAL_CONFIG_PLACEHOLDER, sanitizeConfigForStorage } from './utils/config-storage'; +import { ensureError } from './utils/error-utils'; +import { isPathWithinRootBoundary } from './utils/path-boundary'; +import { + collectFilesWithinBoundary, + collectSubFoldersWithinBoundary, + findFolderByPath, +} from './utils/tree-selection'; + import type { AnalyzeRepositoryResult, ConfigObject, @@ -13,31 +22,6 @@ import type { TabId, } from '../../types/ipc'; -// Helper function to ensure consistent error handling -const ensureError = (error: unknown): Error => { - if (error instanceof Error) { - return error; - } - - if (typeof error === 'string') { - return new Error(error); - } - - if (typeof error === 'number' || typeof error === 'boolean' || typeof error === 'bigint') { - return new Error(String(error)); - } - - if (typeof error === 'object' && error !== null) { - try { - return new Error(JSON.stringify(error)); - } catch { - return new Error('Unknown error'); - } - } - - return new Error('Unknown error'); -}; - type ProcessingOptions = { showTokenCount: boolean; includeTreeView: boolean; @@ -76,77 +60,6 @@ type AppContextValue = { const AppContext = createContext(undefined); -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 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 INITIAL_CONFIG_PLACEHOLDER = '# Loading configuration...'; - -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) { - resolvedSegments.push('..'); - } - continue; - } - - resolvedSegments.push(segment); - } - - return `${drivePrefix}${hasLeadingSlash ? '/' : ''}${resolvedSegments.join('/')}`; -}; - -const isPathWithinRootBoundary = (candidatePath: string, rootPath: string): boolean => { - if (!candidatePath || !rootPath) { - return false; - } - - const normalizedRootPath = normalizePathForBoundaryCheck(rootPath); - const normalizedCandidatePath = normalizePathForBoundaryCheck(candidatePath); - - return ( - normalizedCandidatePath === normalizedRootPath || - normalizedCandidatePath.startsWith(`${normalizedRootPath}/`) - ); -}; - type AppProviderProps = { children: React.ReactNode; }; @@ -337,63 +250,11 @@ export const AppProvider = ({ children }: AppProviderProps) => { return; } - 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; - }; - - 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, rootPath)) { - folders.push(item.path, ...getAllSubFolders(item)); - } - } - - return folders; - }; - - const getAllFiles = (folder: DirectoryTreeItem): string[] => { - if (!folder.children) return []; - - let files: string[] = []; - - for (const item of folder.children ?? []) { - if (item.type === 'file') { - if (isPathWithinRootBoundary(item.path, rootPath)) { - files.push(item.path); - } - } else if (item.type === 'directory') { - files = [...files, ...getAllFiles(item)]; - } - } - - return files; - }; - - const folder = findFolder(directoryTree, folderPath); + const folder = findFolderByPath(directoryTree, folderPath); if (folder) { - const subFolders = getAllSubFolders(folder); - const files = getAllFiles(folder); + const subFolders = collectSubFoldersWithinBoundary(folder, rootPath); + const files = collectFilesWithinBoundary(folder, rootPath); if (isSelected) { setSelectedFolders((prev) => { diff --git a/src/renderer/context/utils/config-storage.ts b/src/renderer/context/utils/config-storage.ts new file mode 100644 index 0000000..4d8b961 --- /dev/null +++ b/src/renderer/context/utils/config-storage.ts @@ -0,0 +1,59 @@ +import yaml from 'yaml'; + +import type { ConfigObject } from '../../../types/ipc'; + +export const INITIAL_CONFIG_PLACEHOLDER = '# Loading configuration...'; + +const redactApiKeyLines = (configContent: string): string => { + const usesWindowsLineEndings = configContent.includes('\r\n'); + const normalizedContent = usesWindowsLineEndings + ? configContent.replaceAll('\r\n', '\n') + : configContent; + const redactedContent = normalizedContent + .split('\n') + .map((line) => { + const trimmedLine = line.trimStart().toLowerCase(); + if (!trimmedLine.startsWith('api_key:')) { + return line; + } + + const separatorIndex = line.indexOf(':'); + if (separatorIndex === -1) { + return line; + } + + return `${line.slice(0, separatorIndex + 1)} [redacted]`; + }) + .join('\n'); + + return usesWindowsLineEndings ? redactedContent.replaceAll('\n', '\r\n') : redactedContent; +}; + +export const sanitizeConfigForStorage = (configContent: string): string => { + try { + const parsedConfig = yaml.parse(configContent); + if (!parsedConfig || typeof parsedConfig !== 'object') { + return redactApiKeyLines(configContent); + } + + const config = parsedConfig as ConfigObject; + if (!config.provider || typeof config.provider !== 'object' || !config.provider.api_key) { + return redactApiKeyLines(configContent); + } + + 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 redactApiKeyLines(configContent); + } +}; diff --git a/src/renderer/context/utils/error-utils.ts b/src/renderer/context/utils/error-utils.ts new file mode 100644 index 0000000..6d7118c --- /dev/null +++ b/src/renderer/context/utils/error-utils.ts @@ -0,0 +1,23 @@ +export const ensureError = (error: unknown): Error => { + if (error instanceof Error) { + return error; + } + + if (typeof error === 'string') { + return new Error(error); + } + + if (typeof error === 'number' || typeof error === 'boolean' || typeof error === 'bigint') { + return new Error(String(error)); + } + + if (typeof error === 'object' && error !== null) { + try { + return new Error(JSON.stringify(error)); + } catch { + return new Error('Unknown error'); + } + } + + return new Error('Unknown error'); +}; diff --git a/src/renderer/context/utils/path-boundary.ts b/src/renderer/context/utils/path-boundary.ts new file mode 100644 index 0000000..34b6ab8 --- /dev/null +++ b/src/renderer/context/utils/path-boundary.ts @@ -0,0 +1,39 @@ +export 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) { + resolvedSegments.push('..'); + } + continue; + } + + resolvedSegments.push(segment); + } + + return `${drivePrefix}${hasLeadingSlash ? '/' : ''}${resolvedSegments.join('/')}`; +}; + +export const isPathWithinRootBoundary = (candidatePath: string, rootPath: string): boolean => { + if (!candidatePath || !rootPath) { + return false; + } + + const normalizedRootPath = normalizePathForBoundaryCheck(rootPath); + const normalizedCandidatePath = normalizePathForBoundaryCheck(candidatePath); + + return ( + normalizedCandidatePath === normalizedRootPath || + normalizedCandidatePath.startsWith(`${normalizedRootPath}/`) + ); +}; diff --git a/src/renderer/context/utils/tree-selection.ts b/src/renderer/context/utils/tree-selection.ts new file mode 100644 index 0000000..ae7c7a4 --- /dev/null +++ b/src/renderer/context/utils/tree-selection.ts @@ -0,0 +1,65 @@ +import { isPathWithinRootBoundary } from './path-boundary'; + +import type { DirectoryTreeItem } from '../../../types/ipc'; + +export const findFolderByPath = ( + 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 = findFolderByPath(item.children, targetPath); + if (found) { + return found; + } + } + } + + return null; +}; + +export const collectSubFoldersWithinBoundary = ( + folder: DirectoryTreeItem, + rootPath: string +): string[] => { + if (!folder.children) { + return []; + } + + let folders: string[] = []; + + for (const item of folder.children ?? []) { + if (item.type === 'directory' && isPathWithinRootBoundary(item.path, rootPath)) { + folders.push(item.path, ...collectSubFoldersWithinBoundary(item, rootPath)); + } + } + + return folders; +}; + +export const collectFilesWithinBoundary = ( + folder: DirectoryTreeItem, + rootPath: string +): string[] => { + if (!folder.children) { + return []; + } + + let files: string[] = []; + + for (const item of folder.children ?? []) { + if (item.type === 'file') { + if (isPathWithinRootBoundary(item.path, rootPath)) { + files.push(item.path); + } + } else if (item.type === 'directory') { + files = [...files, ...collectFilesWithinBoundary(item, rootPath)]; + } + } + + return files; +}; diff --git a/tests/unit/components/app.test.tsx b/tests/unit/components/app.test.tsx index e55cf69..eda5962 100644 --- a/tests/unit/components/app.test.tsx +++ b/tests/unit/components/app.test.tsx @@ -324,6 +324,36 @@ describe('App Component', () => { }); }); + test('redacts api_key when config cannot be parsed before localStorage save', async () => { + const safeConfig = '# Base config\nuse_custom_excludes: true'; + localStorage.setItem('configContent', safeConfig); + + render(); + + await waitFor(() => { + expect((screen.getByTestId('config-content') as HTMLTextAreaElement).value).toBe(safeConfig); + }); + + const invalidConfigWithSecret = [ + 'provider:', + ' id: openai', + ' model: gpt-4o-mini', + ' api_key: leaked-api-key', + ' base_url: https://api.openai.com/v1', + 'broken: [', + ].join('\n'); + + fireEvent.change(screen.getByTestId('config-content'), { + target: { value: invalidConfigWithSecret }, + }); + + await waitFor(() => { + const storedConfig = localStorageStore.configContent; + expect(storedConfig).not.toContain('api_key: leaked-api-key'); + expect(storedConfig).not.toContain('leaked-api-key'); + }); + }); + test('updates rootPath when directory is selected', async () => { render(); diff --git a/tests/unit/components/config-tab.test.tsx b/tests/unit/components/config-tab.test.tsx index 132bcde..f04fb3b 100644 --- a/tests/unit/components/config-tab.test.tsx +++ b/tests/unit/components/config-tab.test.tsx @@ -24,6 +24,10 @@ jest.mock('../../../src/utils/formatters/list-formatter', () => ({ // Mock yaml package jest.mock('yaml', () => ({ parse: jest.fn().mockImplementation((str = '') => { + if (str.includes('invalid_yaml')) { + throw new Error('Invalid YAML'); + } + const exportFormat = str.includes('export_format: xml') ? 'xml' : 'markdown'; const includesProvider = str.includes('provider:'); if (str && str.includes('include_extensions')) { @@ -189,6 +193,19 @@ describe('ConfigTab', () => { expect(screen.getByLabelText('Export format')).toHaveValue('xml'); }); + test('keeps existing form values when incoming config yaml is invalid', () => { + const xmlConfigContent = `${mockConfigContent}\nexport_format: xml`; + const { rerender } = render( + + ); + + expect(screen.getByLabelText('Export format')).toHaveValue('xml'); + + rerender(); + + expect(screen.getByLabelText('Export format')).toHaveValue('xml'); + }); + test('calls selectDirectory from context when folder button is clicked', async () => { render();