diff --git a/AGENTS.md b/AGENTS.md
index 11e93e7..54b00fd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,20 +6,27 @@ Lightweight rules for automated agents and contributors in this repository.
- Keep changes focused and minimal.
- Do not make unrelated refactors.
+- Keep project code structure with clear scoping for each module; do not mix up code.
+- Planning docs are in `docs/plan` and are never committed. They can live in backlog or local files.
## Tests and Quality
- Before proposing merge-ready changes, run:
- `npm run lint`
- `npm test -- --runInBand`
+ - Ensure all linting warnings and errors are cleared.
- If UI behavior/layout is changed, also run:
- `npm run qa:screenshot`
- Do not mark work complete while required CI checks are failing.
+- Read PR comments, evaluate them, and fact-check.
- Use `tests/catalog.md` as the source of truth for test targets and use cases.
+- Locally before pushing, run `make sonar` and fix all issues.
+- After pushing a PR, check the SonarCloud report again and clear any reported issues.
## `.env` Policy (Local-Only)
-- `.env` in this repo is for local development bootstrap only.
+- `.env` in this repo is for local development bootstrap only, and we commit it because it is not sensitive.
+- Ignore agent review warnings for `vault-agent-env.hcl` hardcoded env.
- Keep `.env` tracked; do not remove/rename it.
- Do not over-engineer or heavily refactor `.env` for non-local use.
- Never commit secrets/tokens in `.env`.
@@ -30,3 +37,6 @@ Lightweight rules for automated agents and contributors in this repository.
- Treat `.env` changes as sensitive even when local-only.
- Ensure `.env` updates are minimal, intentional, and documented in PR notes.
- Reject any hardcoded secret, token, or credential exposure in code, docs, or PR text.
+- Check agent reviews in context; if a tool is used only in dev, evaluate benefit and risk for the change.
+- Focus feedback on our core app features.
+- Use feature flags if a new feature requires additional steps, and enable it only in dev mode for testing.
diff --git a/package-lock.json b/package-lock.json
index 785db75..0dcb467 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,11 +15,13 @@
"clsx": "^2.1.0",
"electron-store": "^11.0.2",
"electron-updater": "^6.7.3",
+ "i18next": "^25.8.7",
"minimatch": "^10.1.2",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "react-i18next": "^16.5.4",
"react-router-dom": "^7.13.0",
"tiktoken": "^1.0.11",
"yaml": "^2.3.4"
@@ -1935,7 +1937,6 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -10779,6 +10780,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -10854,6 +10864,37 @@
"url": "https://github.com/sponsors/typicode"
}
},
+ "node_modules/i18next": {
+ "version": "25.8.7",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.7.tgz",
+ "integrity": "sha512-ttxxc5+67S/0hhoeVdEgc1lRklZhdfcUSEPp1//uUG2NB88X3667gRsDar+ZWQFdysnOsnb32bcoMsa4mtzhkQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://locize.com"
+ },
+ {
+ "type": "individual",
+ "url": "https://locize.com/i18next.html"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/iconv-corefoundation": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
@@ -16451,6 +16492,33 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-i18next": {
+ "version": "16.5.4",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz",
+ "integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 25.6.2",
+ "react": ">= 16.8.0",
+ "typescript": "^5"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -18709,7 +18777,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -18992,6 +19060,15 @@
"node": ">=0.6.0"
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
diff --git a/package.json b/package.json
index f70d617..efa435c 100644
--- a/package.json
+++ b/package.json
@@ -133,11 +133,13 @@
"clsx": "^2.1.0",
"electron-store": "^11.0.2",
"electron-updater": "^6.7.3",
+ "i18next": "^25.8.7",
"minimatch": "^10.1.2",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "react-i18next": "^16.5.4",
"react-router-dom": "^7.13.0",
"tiktoken": "^1.0.11",
"yaml": "^2.3.4"
diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx
index e4c4546..e9d84d8 100755
--- a/src/renderer/components/App.tsx
+++ b/src/renderer/components/App.tsx
@@ -1,26 +1,33 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
import { AppProvider, useApp } from '../context/AppContext';
import { DarkModeProvider } from '../context/DarkModeContext';
+import '../i18n';
import ConfigTab from './ConfigTab';
import DarkModeToggle from './DarkModeToggle';
+import LanguageSelector from './LanguageSelector';
import ProcessedTab from './ProcessedTab';
import SourceTab from './SourceTab';
import TabBar from './TabBar';
const ErrorBanner = () => {
const { appError, dismissError } = useApp();
+ const { t } = useTranslation();
if (!appError) return null;
+ const translatedMessage = appError.translationKey
+ ? t(appError.translationKey, appError.translationOptions)
+ : appError.message;
return (
-
{appError.message}
+
{translatedMessage}
@@ -548,7 +551,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => {
htmlFor='exclude-suspicious-files'
className='ml-2 block text-sm text-gray-700 dark:text-gray-300'
>
- Exclude suspicious files
+ {t('config.excludeSuspiciousFiles')}
@@ -557,7 +560,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => {
{/* Output Formatting section */}
- Output Formatting
+ {t('config.outputFormattingTitle')}
@@ -573,7 +576,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => {
htmlFor='include-tree-view'
className='ml-2 block text-sm text-gray-700 dark:text-gray-300'
>
- Include file tree in output
+ {t('config.includeFileTree')}
@@ -589,7 +592,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => {
htmlFor='show-token-count'
className='ml-2 block text-sm text-gray-700 dark:text-gray-300'
>
- Display token counts
+ {t('config.displayTokenCounts')}
@@ -598,16 +601,17 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => {
htmlFor='export-format'
className='mb-1 block text-sm text-gray-700 dark:text-gray-300'
>
- Export format
+ {t('config.exportFormat')}
@@ -617,10 +621,10 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => {
{aiSurfacesEnabled && (
- Provider Setup Assistant
+ {t('config.providerSetupTitle')}
- Configure a model provider and run a connection test before saving.
+ {t('config.providerSetupDescription')}
@@ -629,7 +633,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => {
htmlFor='provider-id'
className='mb-1 block text-sm text-gray-700 dark:text-gray-300'
>
- Provider
+ {t('config.provider')}
@@ -761,16 +764,16 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => {
onClick={() => saveConfig(formState)}
className='inline-flex items-center border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none'
>
- {isSaved ? '✓ Saved' : 'Save Config'}
+ {isSaved ? t('config.savedConfig') : t('config.saveConfig')}
- Only process files with these extensions
+ {t('config.includeExtensionsTitle')}
- One extension per line (include the dot)
+ {t('config.includeExtensionsHint')}
- Exclude Patterns
+ {t('config.excludePatternsTitle')}
- One pattern per line (using glob pattern)
+ {t('config.excludePatternsHint')}
-
Configure which file types to include and patterns to exclude in the analysis.
+
{t('config.configSummary')}
);
diff --git a/src/renderer/components/DarkModeToggle.tsx b/src/renderer/components/DarkModeToggle.tsx
index e83b8d7..e6720eb 100644
--- a/src/renderer/components/DarkModeToggle.tsx
+++ b/src/renderer/components/DarkModeToggle.tsx
@@ -1,16 +1,18 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
import { useDarkMode } from '../context/DarkModeContext';
const DarkModeToggle = () => {
+ const { t } = useTranslation();
const { darkMode, toggleDarkMode } = useDarkMode();
return (
@@ -148,7 +150,11 @@ const FileTreeItemComponent = ({
}
}}
aria-expanded={isOpen}
- aria-label={`${isOpen ? 'Collapse' : 'Expand'} folder ${item.name}`}
+ aria-label={
+ isOpen
+ ? t('fileTree.collapseFolderWithName', { name: item.name })
+ : t('fileTree.expandFolderWithName', { name: item.name })
+ }
>
{isOpen ? '📂' : '📁'}
@@ -243,6 +249,7 @@ const FileTreeComponent = ({
onFolderSelect,
onBatchSelect,
}: FileTreeProps) => {
+ const { t } = useTranslation();
const totalFiles = useMemo(() => countTotalFiles(items), [items]);
const selectAllChecked = useMemo(() => {
@@ -292,11 +299,11 @@ const FileTreeComponent = ({
htmlFor='select-all-checkbox'
className='cursor-pointer select-none text-sm font-medium text-gray-700 dark:text-gray-300'
>
- Select All
+ {t('fileTree.selectAll')}
- {selectedFiles.size} of {totalFiles} files selected
+ {t('fileTree.selectedCount', { selected: selectedFiles.size, total: totalFiles })}
@@ -317,8 +324,8 @@ const FileTreeComponent = ({
d='M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z'
>
- No files to display
- Select a directory to view files
+ {t('fileTree.emptyTitle')}
+ {t('fileTree.emptyHint')}
) : (
items.map((item) => (
diff --git a/src/renderer/components/LanguageSelector.tsx b/src/renderer/components/LanguageSelector.tsx
new file mode 100644
index 0000000..83f77eb
--- /dev/null
+++ b/src/renderer/components/LanguageSelector.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+ DEFAULT_LOCALE,
+ isSupportedLocale,
+ persistLocale,
+ SUPPORTED_LOCALES,
+ type SupportedLocale,
+} from '../i18n/settings';
+
+const LanguageSelector = () => {
+ const { i18n, t } = useTranslation();
+
+ const baseLanguage = (i18n.resolvedLanguage || i18n.language || DEFAULT_LOCALE).split('-')[0];
+ const selectedLocale = isSupportedLocale(baseLanguage) ? baseLanguage : DEFAULT_LOCALE;
+
+ const handleLanguageChange = async (event: React.ChangeEvent) => {
+ const nextLocale = event.target.value as SupportedLocale;
+ await i18n.changeLanguage(nextLocale);
+ persistLocale(nextLocale);
+ };
+
+ return (
+
+
+
+
+ );
+};
+
+export default LanguageSelector;
diff --git a/src/renderer/components/ProcessedTab.tsx b/src/renderer/components/ProcessedTab.tsx
index cf0bfb0..69668c9 100755
--- a/src/renderer/components/ProcessedTab.tsx
+++ b/src/renderer/components/ProcessedTab.tsx
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
import Spinner from './icons/Spinner';
@@ -11,6 +12,7 @@ type ProcessedTabProps = {
};
const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps) => {
+ const { t } = useTranslation();
const [isSaving, setIsSaving] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -68,12 +70,12 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
onClick={handleRefresh}
className='inline-flex items-center border border-transparent bg-green-600 px-5 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2'
disabled={isRefreshing}
- title='Reload selected files and regenerate output with latest content'
+ title={t('processed.refreshCodeTitle')}
>
{isRefreshing ? (
<>
- Reprocessing...
+ {t('processed.reprocessing')}
>
) : (
<>
@@ -91,7 +93,7 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
d='M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
/>
- Refresh Code
+ {t('processed.refreshCode')}
>
)}
@@ -100,7 +102,7 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
{/* Stats in the center */}
-
Files
+
{t('common.files')}
{processedResult.processedFiles}
@@ -109,7 +111,7 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
|
-
Tokens
+
{t('common.tokens')}
{processedResult.totalTokens.toLocaleString()}
@@ -119,7 +121,7 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
<>
|
-
Skipped
+
{t('processed.skipped')}
{processedResult.skippedFiles}
@@ -134,7 +136,7 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
className='inline-flex items-center border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none'
>
{isCopied ? (
- '✓ Copied'
+ t('processed.copied')
) : (
<>
- Copy Content
+ {t('processed.copyContent')}
>
)}
@@ -157,7 +159,7 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
} focus:outline-none`}
>
{isSaving ? (
- '✓ Saving...'
+ t('processed.saving')
) : (
<>
- Save to File
+ {t('processed.saveToFile')}
>
)}
@@ -187,10 +189,10 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
htmlFor='processed-content'
className='block text-sm font-medium text-gray-700 dark:text-gray-300'
>
- Processed Content
+ {t('processed.processedContent')}
- Content is ready to be saved
+ {t('processed.contentReady')}
- Files by Token Count
+ {t('processed.filesByTokenCount')}
@@ -214,10 +216,10 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
|
- File Path
+ {t('processed.filePath')}
|
- Tokens
+ {t('common.tokens')}
|
@@ -242,7 +244,7 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
colSpan={2}
className='px-3 py-4 text-center text-sm text-gray-500 dark:text-gray-400'
>
- No file data available
+ {t('processed.noFileData')}
)}
@@ -269,10 +271,10 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
>
- No processed content yet
+ {t('processed.noProcessedContent')}
- Go to the Source tab to select files, then analyze and process them.
+ {t('processed.noProcessedContentHint')}
)}
diff --git a/src/renderer/components/SourceTab.tsx b/src/renderer/components/SourceTab.tsx
index 2aad483..51603d4 100755
--- a/src/renderer/components/SourceTab.tsx
+++ b/src/renderer/components/SourceTab.tsx
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import yaml from 'yaml';
import FileTree from './FileTree';
@@ -73,6 +74,7 @@ const SourceTab = ({
onAnalyze,
onRefreshTree,
}: SourceTabProps) => {
+ const { t } = useTranslation();
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [showTokenCount, setShowTokenCount] = useState(true);
const [totalTokens, setTotalTokens] = useState(0);
@@ -204,7 +206,7 @@ const SourceTab = ({
return (
<>
- Processing...
+ {t('source.processingSelectedFiles')}
>
);
}
@@ -213,7 +215,7 @@ const SourceTab = ({
return (
<>
- Selecting files...
+ {t('source.selectingFiles')}
>
);
}
@@ -234,7 +236,7 @@ const SourceTab = ({
d='M13 10V3L4 14h7v7l9-11h-7z'
/>
- Process Selected Files
+ {t('source.processSelectedFiles')}
>
);
};
@@ -248,7 +250,7 @@ const SourceTab = ({
htmlFor='file-folder-selection'
className='block text-sm font-medium text-gray-700 dark:text-gray-300'
>
- Select Files and Folders
+ {t('source.selectFilesAndFolders')}
@@ -284,7 +286,7 @@ const SourceTab = ({
d='M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z'
>
-
Loading directory content...
+
{t('source.loadingDirectory')}
);
}
@@ -298,9 +300,9 @@ const SourceTab = ({
className='grow border border-gray-300 dark:border-gray-700 dark:bg-gray-700 dark:text-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 cursor-pointer'
value={rootPath}
readOnly
- placeholder='Select a root folder'
+ placeholder={t('source.selectRootFolderPlaceholder')}
onClick={handleDirectorySelect}
- title='Click to browse for a directory'
+ title={t('source.browseDirectoryTitle')}
/>
- Change Folder
+ {t('source.changeFolder')}
@@ -339,7 +341,7 @@ const SourceTab = ({
await onRefreshTree();
}}
className='inline-flex items-center border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800'
- title='Refresh the file list'
+ title={t('source.refreshFileListTitle')}
>
- Refresh file list
+ {t('source.refreshFileList')}
)}
-
Files
+
{t('common.files')}
{selectedFiles.size}
@@ -402,7 +404,7 @@ const SourceTab = ({
<>
|
-
Tokens
+
{t('common.tokens')}
{totalTokens.toLocaleString()}
@@ -436,7 +438,7 @@ const SourceTab = ({
- Analyzing selected files, please wait...
+ {t('source.analyzingWait')}
)}
diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx
index cd7d085..0ba1c18 100755
--- a/src/renderer/components/TabBar.tsx
+++ b/src/renderer/components/TabBar.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
import type { TabId } from '../../types/ipc';
@@ -8,10 +9,11 @@ type TabBarProps = {
};
const TabBar = ({ activeTab, onTabChange }: TabBarProps) => {
+ const { t } = useTranslation();
const tabs = [
- { id: 'config', label: 'Start' },
- { id: 'source', label: 'Select Files' },
- { id: 'processed', label: 'Processed Output' },
+ { id: 'config', labelKey: 'tabs.config' },
+ { id: 'source', labelKey: 'tabs.source' },
+ { id: 'processed', labelKey: 'tabs.processed' },
] as const;
return (
@@ -30,8 +32,9 @@ const TabBar = ({ activeTab, onTabChange }: TabBarProps) => {
}`}
onClick={() => onTabChange(tab.id)}
data-tab={tab.id}
+ data-testid={`tab-${tab.id}`}
>
- {tab.label}
+ {t(tab.labelKey)}
))}
diff --git a/src/renderer/context/AppContext.tsx b/src/renderer/context/AppContext.tsx
index 1919fea..24ca1b4 100644
--- a/src/renderer/context/AppContext.tsx
+++ b/src/renderer/context/AppContext.tsx
@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useR
import yaml from 'yaml';
import { normalizeExportFormat } from '../../utils/export-format';
+import i18n from '../i18n';
import type {
AnalyzeRepositoryResult,
@@ -45,6 +46,8 @@ type ProcessingOptions = {
type AppError = {
message: string;
+ translationKey?: string;
+ translationOptions?: Record
;
timestamp: number;
};
@@ -166,9 +169,30 @@ export const AppProvider = ({ children }: AppProviderProps) => {
const appWindow = globalThis as Window & typeof globalThis;
const electronAPI = appWindow.electronAPI;
- const showError = useCallback((message: string) => {
- setAppError({ message, timestamp: Date.now() });
- }, []);
+ const showError = useCallback(
+ (
+ error:
+ | string
+ | {
+ translationKey: string;
+ translationOptions?: Record;
+ message?: string;
+ }
+ ) => {
+ if (typeof error === 'string') {
+ setAppError({ message: error, timestamp: Date.now() });
+ return;
+ }
+
+ setAppError({
+ message: error.message ?? '',
+ translationKey: error.translationKey,
+ translationOptions: error.translationOptions,
+ timestamp: Date.now(),
+ });
+ },
+ []
+ );
const dismissError = useCallback(() => {
setAppError(null);
@@ -437,7 +461,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
const handleAnalyze = useCallback(async (): Promise => {
const selectedFilesArray = [...selectedFiles];
if (!rootPath || selectedFilesArray.length === 0) {
- showError('Please select a root directory and at least one file.');
+ showError({ translationKey: 'errors.selectRootAndFiles' });
return undefined;
}
@@ -452,14 +476,12 @@ export const AppProvider = ({ children }: AppProviderProps) => {
});
if (validFiles.length === 0) {
- showError(
- 'No valid files selected for analysis. Please select files within the current directory.'
- );
+ showError({ translationKey: 'errors.noValidFiles' });
return undefined;
}
if (!appWindow.electronAPI?.analyzeRepository || !appWindow.electronAPI?.processRepository) {
- throw new Error('Electron API is not available.');
+ throw new Error(i18n.t('errors.electronApiUnavailable'));
}
const currentAnalysisResult = await appWindow.electronAPI.analyzeRepository({
@@ -504,7 +526,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
} catch (error) {
const processedError = ensureError(error);
console.error('Error processing repository:', processedError);
- showError('An error occurred while processing the repository. Check the console for details.');
+ showError({ translationKey: 'errors.processingFailed' });
throw processedError;
}
}, [selectedFiles, rootPath, configContent, appWindow, showError]);
@@ -513,14 +535,12 @@ export const AppProvider = ({ children }: AppProviderProps) => {
try {
const selectedFilesArray = [...selectedFiles];
if (!rootPath || selectedFilesArray.length === 0) {
- showError(
- 'No files are selected for processing. Please go to the Source tab and select files.'
- );
+ showError({ translationKey: 'errors.noFilesSelectedForProcessing' });
return null;
}
if (!appWindow.electronAPI?.analyzeRepository || !appWindow.electronAPI?.processRepository) {
- throw new Error('Electron API is not available.');
+ throw new Error(i18n.t('errors.electronApiUnavailable'));
}
const currentReanalysisResult = await appWindow.electronAPI.analyzeRepository({
@@ -561,14 +581,14 @@ export const AppProvider = ({ children }: AppProviderProps) => {
} catch (error) {
const processedError = ensureError(error);
console.error('Error refreshing processed content:', processedError);
- showError('An error occurred while refreshing content. Check the console for details.');
+ showError({ translationKey: 'errors.refreshFailed' });
throw processedError;
}
}, [selectedFiles, rootPath, configContent, appWindow, processingOptions, showError]);
const handleSaveOutput = useCallback(async () => {
if (!processedResult) {
- showError('No processed content to save.');
+ showError({ translationKey: 'errors.noProcessedContentToSave' });
return;
}
@@ -581,7 +601,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
} catch (error) {
const processedError = ensureError(error);
console.error('Error saving file:', processedError);
- showError('An error occurred while saving the file. Check the console for details.');
+ showError({ translationKey: 'errors.saveFailed' });
}
}, [processedResult, appWindow, rootPath, showError]);
diff --git a/src/renderer/i18n/index.ts b/src/renderer/i18n/index.ts
new file mode 100644
index 0000000..d77fa61
--- /dev/null
+++ b/src/renderer/i18n/index.ts
@@ -0,0 +1,33 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+
+import deCommon from './locales/de/common.json';
+import enCommon from './locales/en/common.json';
+import esCommon from './locales/es/common.json';
+import frCommon from './locales/fr/common.json';
+import { DEFAULT_LOCALE, getInitialLocale, SUPPORTED_LOCALES } from './settings';
+
+const resources = {
+ en: { common: enCommon },
+ es: { common: esCommon },
+ fr: { common: frCommon },
+ de: { common: deCommon },
+};
+
+if (!i18n.isInitialized) {
+ i18n.use(initReactI18next).init({
+ resources,
+ lng: getInitialLocale(),
+ supportedLngs: [...SUPPORTED_LOCALES],
+ fallbackLng: DEFAULT_LOCALE,
+ defaultNS: 'common',
+ ns: ['common'],
+ initImmediate: false,
+ interpolation: {
+ escapeValue: false,
+ },
+ returnNull: false,
+ });
+}
+
+export default i18n;
diff --git a/src/renderer/i18n/locales/de/common.json b/src/renderer/i18n/locales/de/common.json
new file mode 100644
index 0000000..f543710
--- /dev/null
+++ b/src/renderer/i18n/locales/de/common.json
@@ -0,0 +1,127 @@
+{
+ "app": {
+ "name": "AI Code Fusion",
+ "github": "Auf GitHub ansehen",
+ "dismissError": "Fehler schließen"
+ },
+ "languageSelector": {
+ "label": "Sprache",
+ "en": "Englisch",
+ "es": "Spanisch",
+ "fr": "Französisch",
+ "de": "Deutsch"
+ },
+ "darkMode": {
+ "switchToLight": "Zum hellen Modus wechseln",
+ "switchToDark": "Zum dunklen Modus wechseln"
+ },
+ "tabs": {
+ "config": "Start",
+ "source": "Dateien auswählen",
+ "processed": "Verarbeitete Ausgabe"
+ },
+ "common": {
+ "files": "Dateien",
+ "tokens": "Tokens"
+ },
+ "config": {
+ "selectRootFolderPlaceholder": "Stammordner auswählen",
+ "browseDirectoryTitle": "Klicken, um ein Verzeichnis auszuwählen",
+ "selectFolder": "Ordner auswählen",
+ "fileFilteringTitle": "Dateifilter",
+ "filterByExtensions": "Nach Dateiendungen filtern",
+ "useExcludePatterns": "Ausschlussmuster verwenden",
+ "applyGitignoreRules": ".gitignore-Regeln anwenden",
+ "scanSecrets": "Inhalte auf Geheimnisse prüfen",
+ "excludeSuspiciousFiles": "Verdächtige Dateien ausschließen",
+ "outputFormattingTitle": "Ausgabeformatierung",
+ "includeFileTree": "Dateibaum in Ausgabe einfügen",
+ "displayTokenCounts": "Token-Anzahlen anzeigen",
+ "exportFormat": "Exportformat",
+ "exportFormatMarkdown": "Markdown",
+ "exportFormatXml": "XML",
+ "providerSetupTitle": "Einrichtungsassistent für Anbieter",
+ "providerSetupDescription": "Konfiguriere einen Modellanbieter und führe vor dem Speichern einen Verbindungstest aus.",
+ "provider": "Anbieter",
+ "selectProvider": "Anbieter auswählen",
+ "model": "Modell",
+ "modelPlaceholder": "z. B. gpt-4o-mini",
+ "baseUrlOptional": "Basis-URL (optional)",
+ "apiKeyOptionalOllama": "API-Schlüssel (optional für Ollama)",
+ "apiKeyPlaceholder": "API-Schlüssel des Anbieters eingeben",
+ "testing": "Teste...",
+ "testConnection": "Verbindung testen",
+ "autoSaveHint": "Änderungen werden automatisch gespeichert und beim Wechsel zum Quell-Tab angewendet. Token-Schätzungen helfen, den Kontext für große Repositories zu optimieren.",
+ "saveConfig": "Konfiguration speichern",
+ "savedConfig": "✓ Gespeichert",
+ "includeExtensionsTitle": "Nur Dateien mit diesen Endungen verarbeiten",
+ "includeExtensionsHint": "Eine Endung pro Zeile (mit Punkt)",
+ "excludePatternsTitle": "Ausschlussmuster",
+ "excludePatternsHint": "Ein Muster pro Zeile (Glob-Syntax)",
+ "configSummary": "Konfiguriere, welche Dateitypen einbezogen und welche Muster in der Analyse ausgeschlossen werden.",
+ "providerTestDisabled": "Der Anbieter-Verbindungstest ist außerhalb des Entwicklungsmodus deaktiviert.",
+ "providerFixBeforeTesting": "Korrigiere die Anbieter-Einstellungen, bevor du die Verbindung testest.",
+ "providerUnavailable": "Der Anbieter-Verbindungstest ist in diesem Build nicht verfügbar.",
+ "providerFixBeforeSaving": "Korrigiere die Anbieter-Einstellungen vor dem Speichern.",
+ "connectionTestFailed": "Verbindungstest fehlgeschlagen: {{message}}",
+ "validation": {
+ "selectProvider": "Wähle einen Anbieter aus.",
+ "modelRequired": "Modell ist erforderlich.",
+ "apiKeyRequired": "Für diesen Anbieter ist ein API-Schlüssel erforderlich.",
+ "baseUrlProtocol": "Die Basis-URL muss http oder https verwenden.",
+ "baseUrlValid": "Die Basis-URL muss gültig sein."
+ }
+ },
+ "source": {
+ "processSelectedFiles": "Ausgewählte Dateien verarbeiten",
+ "processingSelectedFiles": "Verarbeite...",
+ "selectingFiles": "Dateien werden ausgewählt...",
+ "selectFilesAndFolders": "Dateien und Ordner auswählen",
+ "loadingDirectory": "Verzeichnisinhalt wird geladen...",
+ "selectRootFolderPlaceholder": "Stammordner auswählen",
+ "browseDirectoryTitle": "Klicken, um ein Verzeichnis auszuwählen",
+ "changeFolder": "Ordner ändern",
+ "refreshFileList": "Dateiliste aktualisieren",
+ "refreshFileListTitle": "Dateiliste aktualisieren",
+ "clearSelection": "Auswahl löschen",
+ "clearSelectionTitle": "Alle ausgewählten Dateien löschen",
+ "analyzingWait": "Ausgewählte Dateien werden analysiert, bitte warten..."
+ },
+ "processed": {
+ "refreshCode": "Code aktualisieren",
+ "refreshCodeTitle": "Ausgewählte Dateien neu laden und Ausgabe mit aktuellem Inhalt neu erzeugen",
+ "reprocessing": "Erneut verarbeiten...",
+ "skipped": "Übersprungen",
+ "copied": "✓ Kopiert",
+ "copyContent": "Inhalt kopieren",
+ "saving": "✓ Speichere...",
+ "saveToFile": "In Datei speichern",
+ "processedContent": "Verarbeiteter Inhalt",
+ "contentReady": "Inhalt ist zum Speichern bereit",
+ "filesByTokenCount": "Dateien nach Token-Anzahl",
+ "filePath": "Dateipfad",
+ "noFileData": "Keine Dateidaten verfügbar",
+ "noProcessedContent": "Noch kein verarbeiteter Inhalt",
+ "noProcessedContentHint": "Wechsle zum Quell-Tab, wähle Dateien aus und analysiere/verarbeite sie dann."
+ },
+ "fileTree": {
+ "collapseFolder": "Ordner einklappen",
+ "expandFolder": "Ordner ausklappen",
+ "collapseFolderWithName": "Ordner {{name}} einklappen",
+ "expandFolderWithName": "Ordner {{name}} ausklappen",
+ "selectAll": "Alle auswählen",
+ "selectedCount": "{{selected}} von {{total}} Dateien ausgewählt",
+ "emptyTitle": "Keine Dateien zum Anzeigen",
+ "emptyHint": "Wähle ein Verzeichnis, um Dateien anzuzeigen"
+ },
+ "errors": {
+ "selectRootAndFiles": "Bitte wähle ein Stammverzeichnis und mindestens eine Datei aus.",
+ "noValidFiles": "Keine gültigen Dateien zur Analyse ausgewählt. Bitte wähle Dateien innerhalb des aktuellen Verzeichnisses aus.",
+ "electronApiUnavailable": "Electron-API ist nicht verfügbar.",
+ "processingFailed": "Beim Verarbeiten des Repositories ist ein Fehler aufgetreten. Details in der Konsole.",
+ "noFilesSelectedForProcessing": "Es sind keine Dateien zur Verarbeitung ausgewählt. Wechsle zum Quell-Tab und wähle Dateien aus.",
+ "refreshFailed": "Beim Aktualisieren des Inhalts ist ein Fehler aufgetreten. Details in der Konsole.",
+ "noProcessedContentToSave": "Kein verarbeiteter Inhalt zum Speichern vorhanden.",
+ "saveFailed": "Beim Speichern der Datei ist ein Fehler aufgetreten. Details in der Konsole."
+ }
+}
diff --git a/src/renderer/i18n/locales/en/common.json b/src/renderer/i18n/locales/en/common.json
new file mode 100644
index 0000000..13af387
--- /dev/null
+++ b/src/renderer/i18n/locales/en/common.json
@@ -0,0 +1,127 @@
+{
+ "app": {
+ "name": "AI Code Fusion",
+ "github": "View on GitHub",
+ "dismissError": "Dismiss error"
+ },
+ "languageSelector": {
+ "label": "Language",
+ "en": "English",
+ "es": "Spanish",
+ "fr": "French",
+ "de": "German"
+ },
+ "darkMode": {
+ "switchToLight": "Switch to Light Mode",
+ "switchToDark": "Switch to Dark Mode"
+ },
+ "tabs": {
+ "config": "Start",
+ "source": "Select Files",
+ "processed": "Processed Output"
+ },
+ "common": {
+ "files": "Files",
+ "tokens": "Tokens"
+ },
+ "config": {
+ "selectRootFolderPlaceholder": "Select a root folder",
+ "browseDirectoryTitle": "Click to browse for a directory",
+ "selectFolder": "Select Folder",
+ "fileFilteringTitle": "File Filtering",
+ "filterByExtensions": "Filter by file extensions",
+ "useExcludePatterns": "Use exclude patterns",
+ "applyGitignoreRules": "Apply .gitignore rules",
+ "scanSecrets": "Scan content for secrets",
+ "excludeSuspiciousFiles": "Exclude suspicious files",
+ "outputFormattingTitle": "Output Formatting",
+ "includeFileTree": "Include file tree in output",
+ "displayTokenCounts": "Display token counts",
+ "exportFormat": "Export format",
+ "exportFormatMarkdown": "Markdown",
+ "exportFormatXml": "XML",
+ "providerSetupTitle": "Provider Setup Assistant",
+ "providerSetupDescription": "Configure a model provider and run a connection test before saving.",
+ "provider": "Provider",
+ "selectProvider": "Select provider",
+ "model": "Model",
+ "modelPlaceholder": "e.g. gpt-4o-mini",
+ "baseUrlOptional": "Base URL (optional)",
+ "apiKeyOptionalOllama": "API key (optional for Ollama)",
+ "apiKeyPlaceholder": "Enter provider API key",
+ "testing": "Testing...",
+ "testConnection": "Test Connection",
+ "autoSaveHint": "Changes are automatically saved and will be applied when switching to the Source tab. Token count estimates help with optimizing context for large repositories.",
+ "saveConfig": "Save Config",
+ "savedConfig": "✓ Saved",
+ "includeExtensionsTitle": "Only process files with these extensions",
+ "includeExtensionsHint": "One extension per line (include the dot)",
+ "excludePatternsTitle": "Exclude Patterns",
+ "excludePatternsHint": "One pattern per line (using glob pattern)",
+ "configSummary": "Configure which file types to include and patterns to exclude in the analysis.",
+ "providerTestDisabled": "Provider connection testing is disabled outside dev mode.",
+ "providerFixBeforeTesting": "Fix provider settings before testing the connection.",
+ "providerUnavailable": "Provider connection testing is unavailable in this build.",
+ "providerFixBeforeSaving": "Fix provider settings before saving.",
+ "connectionTestFailed": "Connection test failed: {{message}}",
+ "validation": {
+ "selectProvider": "Select a provider.",
+ "modelRequired": "Model is required.",
+ "apiKeyRequired": "API key is required for this provider.",
+ "baseUrlProtocol": "Base URL must use http or https.",
+ "baseUrlValid": "Base URL must be a valid URL."
+ }
+ },
+ "source": {
+ "processSelectedFiles": "Process Selected Files",
+ "processingSelectedFiles": "Processing...",
+ "selectingFiles": "Selecting files...",
+ "selectFilesAndFolders": "Select Files and Folders",
+ "loadingDirectory": "Loading directory content...",
+ "selectRootFolderPlaceholder": "Select a root folder",
+ "browseDirectoryTitle": "Click to browse for a directory",
+ "changeFolder": "Change Folder",
+ "refreshFileList": "Refresh file list",
+ "refreshFileListTitle": "Refresh the file list",
+ "clearSelection": "Clear selection",
+ "clearSelectionTitle": "Clear all selected files",
+ "analyzingWait": "Analyzing selected files, please wait..."
+ },
+ "processed": {
+ "refreshCode": "Refresh Code",
+ "refreshCodeTitle": "Reload selected files and regenerate output with latest content",
+ "reprocessing": "Reprocessing...",
+ "skipped": "Skipped",
+ "copied": "✓ Copied",
+ "copyContent": "Copy Content",
+ "saving": "✓ Saving...",
+ "saveToFile": "Save to File",
+ "processedContent": "Processed Content",
+ "contentReady": "Content is ready to be saved",
+ "filesByTokenCount": "Files by Token Count",
+ "filePath": "File Path",
+ "noFileData": "No file data available",
+ "noProcessedContent": "No processed content yet",
+ "noProcessedContentHint": "Go to the Source tab to select files, then analyze and process them."
+ },
+ "fileTree": {
+ "collapseFolder": "Collapse folder",
+ "expandFolder": "Expand folder",
+ "collapseFolderWithName": "Collapse folder {{name}}",
+ "expandFolderWithName": "Expand folder {{name}}",
+ "selectAll": "Select All",
+ "selectedCount": "{{selected}} of {{total}} files selected",
+ "emptyTitle": "No files to display",
+ "emptyHint": "Select a directory to view files"
+ },
+ "errors": {
+ "selectRootAndFiles": "Please select a root directory and at least one file.",
+ "noValidFiles": "No valid files selected for analysis. Please select files within the current directory.",
+ "electronApiUnavailable": "Electron API is not available.",
+ "processingFailed": "An error occurred while processing the repository. Check the console for details.",
+ "noFilesSelectedForProcessing": "No files are selected for processing. Please go to the Source tab and select files.",
+ "refreshFailed": "An error occurred while refreshing content. Check the console for details.",
+ "noProcessedContentToSave": "No processed content to save.",
+ "saveFailed": "An error occurred while saving the file. Check the console for details."
+ }
+}
diff --git a/src/renderer/i18n/locales/es/common.json b/src/renderer/i18n/locales/es/common.json
new file mode 100644
index 0000000..3643f48
--- /dev/null
+++ b/src/renderer/i18n/locales/es/common.json
@@ -0,0 +1,127 @@
+{
+ "app": {
+ "name": "AI Code Fusion",
+ "github": "Ver en GitHub",
+ "dismissError": "Cerrar error"
+ },
+ "languageSelector": {
+ "label": "Idioma",
+ "en": "Inglés",
+ "es": "Español",
+ "fr": "Francés",
+ "de": "Alemán"
+ },
+ "darkMode": {
+ "switchToLight": "Cambiar a modo claro",
+ "switchToDark": "Cambiar a modo oscuro"
+ },
+ "tabs": {
+ "config": "Inicio",
+ "source": "Seleccionar archivos",
+ "processed": "Salida procesada"
+ },
+ "common": {
+ "files": "Archivos",
+ "tokens": "Tokens"
+ },
+ "config": {
+ "selectRootFolderPlaceholder": "Selecciona una carpeta raíz",
+ "browseDirectoryTitle": "Haz clic para buscar un directorio",
+ "selectFolder": "Seleccionar carpeta",
+ "fileFilteringTitle": "Filtrado de archivos",
+ "filterByExtensions": "Filtrar por extensiones",
+ "useExcludePatterns": "Usar patrones de exclusión",
+ "applyGitignoreRules": "Aplicar reglas de .gitignore",
+ "scanSecrets": "Escanear contenido en busca de secretos",
+ "excludeSuspiciousFiles": "Excluir archivos sospechosos",
+ "outputFormattingTitle": "Formato de salida",
+ "includeFileTree": "Incluir árbol de archivos en la salida",
+ "displayTokenCounts": "Mostrar conteo de tokens",
+ "exportFormat": "Formato de exportación",
+ "exportFormatMarkdown": "Markdown",
+ "exportFormatXml": "XML",
+ "providerSetupTitle": "Asistente de configuración de proveedor",
+ "providerSetupDescription": "Configura un proveedor de modelos y ejecuta una prueba de conexión antes de guardar.",
+ "provider": "Proveedor",
+ "selectProvider": "Seleccionar proveedor",
+ "model": "Modelo",
+ "modelPlaceholder": "p. ej. gpt-4o-mini",
+ "baseUrlOptional": "URL base (opcional)",
+ "apiKeyOptionalOllama": "API key (opcional para Ollama)",
+ "apiKeyPlaceholder": "Ingresa la API key del proveedor",
+ "testing": "Probando...",
+ "testConnection": "Probar conexión",
+ "autoSaveHint": "Los cambios se guardan automáticamente y se aplicarán al cambiar a la pestaña Fuente. Las estimaciones de tokens ayudan a optimizar el contexto para repositorios grandes.",
+ "saveConfig": "Guardar configuración",
+ "savedConfig": "✓ Guardado",
+ "includeExtensionsTitle": "Procesar solo archivos con estas extensiones",
+ "includeExtensionsHint": "Una extensión por línea (incluye el punto)",
+ "excludePatternsTitle": "Patrones de exclusión",
+ "excludePatternsHint": "Un patrón por línea (usando glob)",
+ "configSummary": "Configura qué tipos de archivo incluir y qué patrones excluir del análisis.",
+ "providerTestDisabled": "La prueba de conexión del proveedor está deshabilitada fuera del modo de desarrollo.",
+ "providerFixBeforeTesting": "Corrige la configuración del proveedor antes de probar la conexión.",
+ "providerUnavailable": "La prueba de conexión del proveedor no está disponible en esta compilación.",
+ "providerFixBeforeSaving": "Corrige la configuración del proveedor antes de guardar.",
+ "connectionTestFailed": "La prueba de conexión falló: {{message}}",
+ "validation": {
+ "selectProvider": "Selecciona un proveedor.",
+ "modelRequired": "El modelo es obligatorio.",
+ "apiKeyRequired": "La API key es obligatoria para este proveedor.",
+ "baseUrlProtocol": "La URL base debe usar http o https.",
+ "baseUrlValid": "La URL base debe ser válida."
+ }
+ },
+ "source": {
+ "processSelectedFiles": "Procesar archivos seleccionados",
+ "processingSelectedFiles": "Procesando...",
+ "selectingFiles": "Seleccionando archivos...",
+ "selectFilesAndFolders": "Seleccionar archivos y carpetas",
+ "loadingDirectory": "Cargando contenido del directorio...",
+ "selectRootFolderPlaceholder": "Selecciona una carpeta raíz",
+ "browseDirectoryTitle": "Haz clic para buscar un directorio",
+ "changeFolder": "Cambiar carpeta",
+ "refreshFileList": "Actualizar lista de archivos",
+ "refreshFileListTitle": "Actualizar la lista de archivos",
+ "clearSelection": "Limpiar selección",
+ "clearSelectionTitle": "Limpiar todos los archivos seleccionados",
+ "analyzingWait": "Analizando los archivos seleccionados, por favor espera..."
+ },
+ "processed": {
+ "refreshCode": "Actualizar código",
+ "refreshCodeTitle": "Recargar archivos seleccionados y regenerar la salida con el contenido más reciente",
+ "reprocessing": "Reprocesando...",
+ "skipped": "Omitidos",
+ "copied": "✓ Copiado",
+ "copyContent": "Copiar contenido",
+ "saving": "✓ Guardando...",
+ "saveToFile": "Guardar en archivo",
+ "processedContent": "Contenido procesado",
+ "contentReady": "El contenido está listo para guardarse",
+ "filesByTokenCount": "Archivos por conteo de tokens",
+ "filePath": "Ruta del archivo",
+ "noFileData": "No hay datos de archivos disponibles",
+ "noProcessedContent": "Todavía no hay contenido procesado",
+ "noProcessedContentHint": "Ve a la pestaña Fuente para seleccionar archivos y luego analizarlos y procesarlos."
+ },
+ "fileTree": {
+ "collapseFolder": "Contraer carpeta",
+ "expandFolder": "Expandir carpeta",
+ "collapseFolderWithName": "Contraer carpeta {{name}}",
+ "expandFolderWithName": "Expandir carpeta {{name}}",
+ "selectAll": "Seleccionar todo",
+ "selectedCount": "{{selected}} de {{total}} archivos seleccionados",
+ "emptyTitle": "No hay archivos para mostrar",
+ "emptyHint": "Selecciona un directorio para ver archivos"
+ },
+ "errors": {
+ "selectRootAndFiles": "Selecciona un directorio raíz y al menos un archivo.",
+ "noValidFiles": "No hay archivos válidos para analizar. Selecciona archivos dentro del directorio actual.",
+ "electronApiUnavailable": "La API de Electron no está disponible.",
+ "processingFailed": "Se produjo un error al procesar el repositorio. Revisa la consola para más detalles.",
+ "noFilesSelectedForProcessing": "No hay archivos seleccionados para procesar. Ve a la pestaña Fuente y selecciona archivos.",
+ "refreshFailed": "Se produjo un error al actualizar el contenido. Revisa la consola para más detalles.",
+ "noProcessedContentToSave": "No hay contenido procesado para guardar.",
+ "saveFailed": "Se produjo un error al guardar el archivo. Revisa la consola para más detalles."
+ }
+}
diff --git a/src/renderer/i18n/locales/fr/common.json b/src/renderer/i18n/locales/fr/common.json
new file mode 100644
index 0000000..e8c299c
--- /dev/null
+++ b/src/renderer/i18n/locales/fr/common.json
@@ -0,0 +1,127 @@
+{
+ "app": {
+ "name": "AI Code Fusion",
+ "github": "Voir sur GitHub",
+ "dismissError": "Fermer l'erreur"
+ },
+ "languageSelector": {
+ "label": "Langue",
+ "en": "Anglais",
+ "es": "Espagnol",
+ "fr": "Français",
+ "de": "Allemand"
+ },
+ "darkMode": {
+ "switchToLight": "Passer en mode clair",
+ "switchToDark": "Passer en mode sombre"
+ },
+ "tabs": {
+ "config": "Démarrer",
+ "source": "Sélectionner des fichiers",
+ "processed": "Sortie traitée"
+ },
+ "common": {
+ "files": "Fichiers",
+ "tokens": "Tokens"
+ },
+ "config": {
+ "selectRootFolderPlaceholder": "Sélectionnez un dossier racine",
+ "browseDirectoryTitle": "Cliquez pour parcourir un dossier",
+ "selectFolder": "Sélectionner un dossier",
+ "fileFilteringTitle": "Filtrage des fichiers",
+ "filterByExtensions": "Filtrer par extensions",
+ "useExcludePatterns": "Utiliser des motifs d'exclusion",
+ "applyGitignoreRules": "Appliquer les règles .gitignore",
+ "scanSecrets": "Scanner le contenu pour les secrets",
+ "excludeSuspiciousFiles": "Exclure les fichiers suspects",
+ "outputFormattingTitle": "Format de sortie",
+ "includeFileTree": "Inclure l'arborescence dans la sortie",
+ "displayTokenCounts": "Afficher le nombre de tokens",
+ "exportFormat": "Format d'export",
+ "exportFormatMarkdown": "Markdown",
+ "exportFormatXml": "XML",
+ "providerSetupTitle": "Assistant de configuration du fournisseur",
+ "providerSetupDescription": "Configurez un fournisseur de modèle et lancez un test de connexion avant d'enregistrer.",
+ "provider": "Fournisseur",
+ "selectProvider": "Sélectionner un fournisseur",
+ "model": "Modèle",
+ "modelPlaceholder": "ex. gpt-4o-mini",
+ "baseUrlOptional": "URL de base (optionnelle)",
+ "apiKeyOptionalOllama": "Clé API (optionnelle pour Ollama)",
+ "apiKeyPlaceholder": "Entrez la clé API du fournisseur",
+ "testing": "Test en cours...",
+ "testConnection": "Tester la connexion",
+ "autoSaveHint": "Les modifications sont enregistrées automatiquement et seront appliquées lors du passage à l'onglet Source. Les estimations de tokens aident à optimiser le contexte pour les grands dépôts.",
+ "saveConfig": "Enregistrer la configuration",
+ "savedConfig": "✓ Enregistré",
+ "includeExtensionsTitle": "Traiter uniquement les fichiers avec ces extensions",
+ "includeExtensionsHint": "Une extension par ligne (inclure le point)",
+ "excludePatternsTitle": "Motifs d'exclusion",
+ "excludePatternsHint": "Un motif par ligne (syntaxe glob)",
+ "configSummary": "Configurez les types de fichiers à inclure et les motifs à exclure de l'analyse.",
+ "providerTestDisabled": "Le test de connexion fournisseur est désactivé hors mode développement.",
+ "providerFixBeforeTesting": "Corrigez les paramètres du fournisseur avant de tester la connexion.",
+ "providerUnavailable": "Le test de connexion fournisseur n'est pas disponible dans cette version.",
+ "providerFixBeforeSaving": "Corrigez les paramètres du fournisseur avant d'enregistrer.",
+ "connectionTestFailed": "Échec du test de connexion : {{message}}",
+ "validation": {
+ "selectProvider": "Sélectionnez un fournisseur.",
+ "modelRequired": "Le modèle est requis.",
+ "apiKeyRequired": "La clé API est requise pour ce fournisseur.",
+ "baseUrlProtocol": "L'URL de base doit utiliser http ou https.",
+ "baseUrlValid": "L'URL de base doit être valide."
+ }
+ },
+ "source": {
+ "processSelectedFiles": "Traiter les fichiers sélectionnés",
+ "processingSelectedFiles": "Traitement...",
+ "selectingFiles": "Sélection des fichiers...",
+ "selectFilesAndFolders": "Sélectionner des fichiers et dossiers",
+ "loadingDirectory": "Chargement du contenu du dossier...",
+ "selectRootFolderPlaceholder": "Sélectionnez un dossier racine",
+ "browseDirectoryTitle": "Cliquez pour parcourir un dossier",
+ "changeFolder": "Changer de dossier",
+ "refreshFileList": "Actualiser la liste des fichiers",
+ "refreshFileListTitle": "Actualiser la liste des fichiers",
+ "clearSelection": "Effacer la sélection",
+ "clearSelectionTitle": "Effacer tous les fichiers sélectionnés",
+ "analyzingWait": "Analyse des fichiers sélectionnés, veuillez patienter..."
+ },
+ "processed": {
+ "refreshCode": "Actualiser le code",
+ "refreshCodeTitle": "Recharger les fichiers sélectionnés et régénérer la sortie avec le contenu le plus récent",
+ "reprocessing": "Nouveau traitement...",
+ "skipped": "Ignorés",
+ "copied": "✓ Copié",
+ "copyContent": "Copier le contenu",
+ "saving": "✓ Enregistrement...",
+ "saveToFile": "Enregistrer dans un fichier",
+ "processedContent": "Contenu traité",
+ "contentReady": "Le contenu est prêt à être enregistré",
+ "filesByTokenCount": "Fichiers par nombre de tokens",
+ "filePath": "Chemin du fichier",
+ "noFileData": "Aucune donnée de fichier disponible",
+ "noProcessedContent": "Aucun contenu traité pour le moment",
+ "noProcessedContentHint": "Allez dans l'onglet Source pour sélectionner des fichiers, puis les analyser et les traiter."
+ },
+ "fileTree": {
+ "collapseFolder": "Réduire le dossier",
+ "expandFolder": "Développer le dossier",
+ "collapseFolderWithName": "Réduire le dossier {{name}}",
+ "expandFolderWithName": "Développer le dossier {{name}}",
+ "selectAll": "Tout sélectionner",
+ "selectedCount": "{{selected}} sur {{total}} fichiers sélectionnés",
+ "emptyTitle": "Aucun fichier à afficher",
+ "emptyHint": "Sélectionnez un dossier pour afficher les fichiers"
+ },
+ "errors": {
+ "selectRootAndFiles": "Veuillez sélectionner un dossier racine et au moins un fichier.",
+ "noValidFiles": "Aucun fichier valide sélectionné pour l'analyse. Sélectionnez des fichiers dans le dossier actuel.",
+ "electronApiUnavailable": "L'API Electron n'est pas disponible.",
+ "processingFailed": "Une erreur s'est produite lors du traitement du dépôt. Consultez la console pour plus de détails.",
+ "noFilesSelectedForProcessing": "Aucun fichier sélectionné pour le traitement. Allez dans l'onglet Source et sélectionnez des fichiers.",
+ "refreshFailed": "Une erreur s'est produite lors de l'actualisation du contenu. Consultez la console pour plus de détails.",
+ "noProcessedContentToSave": "Aucun contenu traité à enregistrer.",
+ "saveFailed": "Une erreur s'est produite lors de l'enregistrement du fichier. Consultez la console pour plus de détails."
+ }
+}
diff --git a/src/renderer/i18n/settings.ts b/src/renderer/i18n/settings.ts
new file mode 100644
index 0000000..b6a5c55
--- /dev/null
+++ b/src/renderer/i18n/settings.ts
@@ -0,0 +1,59 @@
+export const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de'] as const;
+
+export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
+
+export const DEFAULT_LOCALE: SupportedLocale = 'en';
+export const LOCALE_STORAGE_KEY = 'app.locale';
+
+export const isSupportedLocale = (value: string | null | undefined): value is SupportedLocale => {
+ return value !== null && value !== undefined && SUPPORTED_LOCALES.includes(value as SupportedLocale);
+};
+
+const normalizeLocale = (value: string): SupportedLocale | null => {
+ if (isSupportedLocale(value)) {
+ return value;
+ }
+
+ const baseLocale = value.split('-')[0];
+ if (isSupportedLocale(baseLocale)) {
+ return baseLocale;
+ }
+
+ return null;
+};
+
+export const getInitialLocale = (): SupportedLocale => {
+ if (typeof window === 'undefined') {
+ return DEFAULT_LOCALE;
+ }
+
+ const storedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY);
+ if (storedLocale) {
+ const normalizedStoredLocale = normalizeLocale(storedLocale);
+ if (normalizedStoredLocale) {
+ return normalizedStoredLocale;
+ }
+ }
+
+ const languageCandidates = [
+ ...(window.navigator.languages || []),
+ window.navigator.language,
+ ].filter(Boolean);
+
+ for (const candidate of languageCandidates) {
+ const normalizedLocale = normalizeLocale(candidate);
+ if (normalizedLocale) {
+ return normalizedLocale;
+ }
+ }
+
+ return DEFAULT_LOCALE;
+};
+
+export const persistLocale = (locale: SupportedLocale): void => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
+};
diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx
index ca1eb28..0622520 100644
--- a/src/renderer/index.tsx
+++ b/src/renderer/index.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
+import './i18n';
import App from './components/App';
const container = document.getElementById('app');
diff --git a/tests/catalog.md b/tests/catalog.md
index abbb64a..d97b39a 100644
--- a/tests/catalog.md
+++ b/tests/catalog.md
@@ -20,35 +20,37 @@ Purpose: quick map of what is covered, why it exists, and which command to run.
## Unit Tests
-| File | Primary Target | Key Use Cases |
-| ------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `tests/unit/components/app.test.tsx` | `src/renderer/components/App.tsx` | Tab switching, config load, directory selection, processing flow, error handling |
-| `tests/unit/components/config-tab.test.tsx` | `src/renderer/components/ConfigTab.tsx` | Config toggles/inputs, dev-only provider surface gating, provider validation/connection wiring, provider-config preservation, directory picker trigger |
-| `tests/unit/components/file-tree.test.tsx` | `src/renderer/components/FileTree.tsx` | Tree render, folder expand/collapse, select all, empty-state behavior |
-| `tests/unit/components/source-tab.test.tsx` | `src/renderer/components/SourceTab.tsx` | Source tab input state, filter toggles, and event forwarding behavior |
-| `tests/unit/file-analyzer.test.ts` | `src/utils/file-analyzer.ts` | Include/exclude rules, gitignore behavior, binary handling, error cases |
-| `tests/unit/gitignore-parser.test.ts` | `src/utils/gitignore-parser.ts` | Pattern parsing, negation behavior, caching, nested path handling |
-| `tests/unit/binary-detection.test.ts` | `src/utils/file-analyzer.ts` | Binary signature detection, control-char thresholds, fallback-on-error behavior |
-| `tests/unit/utils/filter-utils.test.ts` | `src/utils/filter-utils.ts` | Path normalization, extension filtering, custom excludes, gitignore precedence |
-| `tests/unit/utils/secret-scanner.test.ts` | `src/utils/secret-scanner.ts` | Sensitive path detection, secret-pattern scanning, default-on safety toggles |
-| `tests/unit/utils/fnmatch.test.ts` | `src/utils/fnmatch.ts` | Glob semantics: wildcards, classes, double-star, braces, path anchors |
-| `tests/unit/utils/export-format.test.ts` | `src/utils/export-format.ts` | Export format normalization, XML attribute escaping, CDATA-safe sanitization |
-| `tests/unit/utils/content-processor.test.ts` | `src/utils/content-processor.ts` | Content assembly, binary skip logic, malformed input handling |
-| `tests/unit/utils/config-manager.test.ts` | `src/utils/config-manager.ts` | Default config load, parse failures, graceful fallback behavior |
-| `tests/unit/utils/token-counter.test.ts` | `src/utils/token-counter.ts` | Token counting basics, empty/null input handling |
-| `tests/unit/scripts/security.test.js` | `scripts/lib/security.js` | Command safety validation, Windows path acceptance for approved executables |
-| `tests/unit/scripts/actions-freshness.test.js` | `scripts/lib/actions-freshness.js` | Workflow `uses:` reference parsing, pinning classification, freshness markdown report output |
-| `tests/unit/scripts/eslint-config.test.js` | `eslint.config.js` | Guard scoped unicorn/sonarjs strict-pack configuration and test exclusions |
-| `tests/unit/scripts/lint-gates.test.js` | `package.json` + `eslint.config.js` | Ensure lint/format gates include scripts + config coverage and staged-lint scope |
-| `tests/unit/scripts/electron-eslint-rules.test.js` | `eslint-rules/electron-security.js` | Validate custom Electron safety lint rules (BrowserWindow flags, IPC channels, renderer bans) |
-| `tests/unit/scripts/sonar-options.test.js` | `scripts/lib/sonar-options.js` | Sonar scanner option merge behavior and CPD exclusion defaults |
-| `tests/unit/scripts/publish-stress-metrics.test.js` | `scripts/publish-stress-metrics.js` | Prometheus payload generation and Pushgateway publication safeguards |
-| `tests/unit/scripts/verify-prometheus-metrics.test.js` | `scripts/verify-prometheus-metrics.js` | Prometheus scrape verification retries, timeouts, and parsing |
-| `tests/unit/scripts/perf-metrics-job.test.js` | `scripts/run-perf-metrics-job.js` | End-to-end performance job orchestration (stress, publish, verify) |
-| `tests/unit/scripts/validate-test-catalog.test.js` | `scripts/validate-test-catalog.js` | Catalog path validity and Jest discovery coverage checks |
-| `tests/unit/scripts/validate-changelog.test.js` | `scripts/validate-changelog.js` | Release heading/date format checks, allowed section headings, latest release section coverage |
-| `tests/unit/main/updater.test.ts` | `src/main/updater.ts` | Alpha/stable channel selection, platform gating, update-check result handling |
-| `tests/unit/main/feature-flags.test.ts` | `src/main/feature-flags.ts` | OpenFeature normalization, env/remote merge rules, secure remote fetch behavior |
+| File | Primary Target | Key Use Cases |
+| ------------------------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `tests/unit/components/app.test.tsx` | `src/renderer/components/App.tsx` | Tab switching, config load, directory selection, processing flow, error handling |
+| `tests/unit/components/config-tab.test.tsx` | `src/renderer/components/ConfigTab.tsx` | Config toggles/inputs, dev-only provider surface gating, provider validation/connection wiring, provider-config preservation, directory picker trigger |
+| `tests/unit/components/file-tree.test.tsx` | `src/renderer/components/FileTree.tsx` | Tree render, folder expand/collapse, select all, empty-state behavior |
+| `tests/unit/components/language-selector.test.tsx` | `src/renderer/components/LanguageSelector.tsx` | Locale selector rendering, language switching, and localStorage persistence |
+| `tests/unit/components/source-tab.test.tsx` | `src/renderer/components/SourceTab.tsx` | Source tab input state, filter toggles, and event forwarding behavior |
+| `tests/unit/i18n/locales-parity.test.ts` | `src/renderer/i18n/locales/*/common.json` | Locale key parity across EN/ES/FR/DE resources |
+| `tests/unit/file-analyzer.test.ts` | `src/utils/file-analyzer.ts` | Include/exclude rules, gitignore behavior, binary handling, error cases |
+| `tests/unit/gitignore-parser.test.ts` | `src/utils/gitignore-parser.ts` | Pattern parsing, negation behavior, caching, nested path handling |
+| `tests/unit/binary-detection.test.ts` | `src/utils/file-analyzer.ts` | Binary signature detection, control-char thresholds, fallback-on-error behavior |
+| `tests/unit/utils/filter-utils.test.ts` | `src/utils/filter-utils.ts` | Path normalization, extension filtering, custom excludes, gitignore precedence |
+| `tests/unit/utils/secret-scanner.test.ts` | `src/utils/secret-scanner.ts` | Sensitive path detection, secret-pattern scanning, default-on safety toggles |
+| `tests/unit/utils/fnmatch.test.ts` | `src/utils/fnmatch.ts` | Glob semantics: wildcards, classes, double-star, braces, path anchors |
+| `tests/unit/utils/export-format.test.ts` | `src/utils/export-format.ts` | Export format normalization, XML attribute escaping, CDATA-safe sanitization |
+| `tests/unit/utils/content-processor.test.ts` | `src/utils/content-processor.ts` | Content assembly, binary skip logic, malformed input handling |
+| `tests/unit/utils/config-manager.test.ts` | `src/utils/config-manager.ts` | Default config load, parse failures, graceful fallback behavior |
+| `tests/unit/utils/token-counter.test.ts` | `src/utils/token-counter.ts` | Token counting basics, empty/null input handling |
+| `tests/unit/scripts/security.test.js` | `scripts/lib/security.js` | Command safety validation, Windows path acceptance for approved executables |
+| `tests/unit/scripts/actions-freshness.test.js` | `scripts/lib/actions-freshness.js` | Workflow `uses:` reference parsing, pinning classification, freshness markdown report output |
+| `tests/unit/scripts/eslint-config.test.js` | `eslint.config.js` | Guard scoped unicorn/sonarjs strict-pack configuration and test exclusions |
+| `tests/unit/scripts/lint-gates.test.js` | `package.json` + `eslint.config.js` | Ensure lint/format gates include scripts + config coverage and staged-lint scope |
+| `tests/unit/scripts/electron-eslint-rules.test.js` | `eslint-rules/electron-security.js` | Validate custom Electron safety lint rules (BrowserWindow flags, IPC channels, renderer bans) |
+| `tests/unit/scripts/sonar-options.test.js` | `scripts/lib/sonar-options.js` | Sonar scanner option merge behavior and CPD exclusion defaults |
+| `tests/unit/scripts/publish-stress-metrics.test.js` | `scripts/publish-stress-metrics.js` | Prometheus payload generation and Pushgateway publication safeguards |
+| `tests/unit/scripts/verify-prometheus-metrics.test.js` | `scripts/verify-prometheus-metrics.js` | Prometheus scrape verification retries, timeouts, and parsing |
+| `tests/unit/scripts/perf-metrics-job.test.js` | `scripts/run-perf-metrics-job.js` | End-to-end performance job orchestration (stress, publish, verify) |
+| `tests/unit/scripts/validate-test-catalog.test.js` | `scripts/validate-test-catalog.js` | Catalog path validity and Jest discovery coverage checks |
+| `tests/unit/scripts/validate-changelog.test.js` | `scripts/validate-changelog.js` | Release heading/date format checks, allowed section headings, latest release section coverage |
+| `tests/unit/main/updater.test.ts` | `src/main/updater.ts` | Alpha/stable channel selection, platform gating, update-check result handling |
+| `tests/unit/main/feature-flags.test.ts` | `src/main/feature-flags.ts` | OpenFeature normalization, env/remote merge rules, secure remote fetch behavior |
## Integration Tests
@@ -76,9 +78,9 @@ Stress benchmark outputs:
## Electron E2E Tests
-| File | Primary Target | Key Use Cases |
-| ----------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
-| `tests/e2e/electron-process-flow.spec.ts` | Full renderer + preload + main-process wiring | Folder selection, file tree interaction, process flow, XML format handling, refresh-from-disk behavior, save flow |
+| File | Primary Target | Key Use Cases |
+| ----------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| `tests/e2e/electron-process-flow.spec.ts` | Full renderer + preload + main-process wiring | Folder selection, file tree interaction, process flow, XML format handling, refresh-from-disk behavior, save flow, locale persistence |
## Visual Regression Signal
@@ -103,6 +105,8 @@ Stress benchmark outputs:
- Renderer flow changes:
- `tests/unit/components/app.test.tsx`
- `tests/unit/components/config-tab.test.tsx`
+ - `tests/unit/components/language-selector.test.tsx`
+ - `tests/unit/i18n/locales-parity.test.ts`
- `tests/e2e/electron-process-flow.spec.ts`
- Main process / IPC changes:
- `tests/integration/main-process/handlers.test.ts`
diff --git a/tests/e2e/electron-process-flow.spec.ts b/tests/e2e/electron-process-flow.spec.ts
index 70678f7..8b794c5 100644
--- a/tests/e2e/electron-process-flow.spec.ts
+++ b/tests/e2e/electron-process-flow.spec.ts
@@ -245,6 +245,11 @@ const test = base.extend({
page: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
+ await page.evaluate(() => {
+ localStorage.setItem('app.locale', 'en');
+ });
+ await page.reload();
+ await page.waitForLoadState('domcontentloaded');
await expect(page.getByRole('heading', { name: 'AI Code Fusion' })).toBeVisible();
await use(page);
},
@@ -323,3 +328,17 @@ test('saves processed output to disk through the native save flow', async ({ pag
expect(savedContent).toContain('src/App.tsx');
expect(savedContent).toContain('APP_MARKER_V1');
});
+
+test('persists selected language across reloads', async ({ page }) => {
+ const languageSelector = page.getByTestId('language-selector');
+ await expect(languageSelector).toHaveValue('en');
+
+ await languageSelector.selectOption('es');
+ await expect(languageSelector).toHaveValue('es');
+ await expect(page.getByRole('tab', { name: 'Inicio' })).toBeVisible();
+
+ await page.reload();
+ await page.waitForLoadState('domcontentloaded');
+ await expect(page.getByTestId('language-selector')).toHaveValue('es');
+ await expect(page.getByRole('tab', { name: 'Inicio' })).toBeVisible();
+});
diff --git a/tests/setup.ts b/tests/setup.ts
index 5373288..63c375f 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -1,4 +1,11 @@
import '@testing-library/jest-dom';
+import '../src/renderer/i18n';
+import i18n from '../src/renderer/i18n';
+
+beforeEach(async () => {
+ window.localStorage.setItem('app.locale', 'en');
+ await i18n.changeLanguage('en');
+});
// Add a dummy test to avoid Jest warning about no tests
describe('Setup validation', () => {
@@ -131,6 +138,7 @@ jest.mock('fs', () => ({
// Mock console methods to reduce test noise
global.console = {
...console,
+ info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
log: jest.fn(),
diff --git a/tests/unit/components/language-selector.test.tsx b/tests/unit/components/language-selector.test.tsx
new file mode 100644
index 0000000..0b4fc0c
--- /dev/null
+++ b/tests/unit/components/language-selector.test.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import LanguageSelector from '../../../src/renderer/components/LanguageSelector';
+import i18n from '../../../src/renderer/i18n';
+import { LOCALE_STORAGE_KEY } from '../../../src/renderer/i18n/settings';
+
+describe('LanguageSelector', () => {
+ beforeEach(async () => {
+ window.localStorage.clear();
+ await i18n.changeLanguage('en');
+ });
+
+ test('renders supported locale options', () => {
+ render();
+
+ const languageSelect = screen.getByTestId('language-selector');
+ expect(languageSelect).toHaveValue('en');
+
+ const optionValues = Array.from(screen.getAllByRole('option')).map((option) => option.getAttribute('value'));
+ expect(optionValues).toEqual(['en', 'es', 'fr', 'de']);
+ });
+
+ test('changes locale and persists it to localStorage', async () => {
+ const setItemSpy = jest.spyOn(Storage.prototype, 'setItem');
+ render();
+
+ fireEvent.change(screen.getByTestId('language-selector'), { target: { value: 'es' } });
+
+ await waitFor(() => {
+ expect(i18n.resolvedLanguage?.startsWith('es') || i18n.language.startsWith('es')).toBe(true);
+ expect(setItemSpy).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'es');
+ expect(screen.getByLabelText('Idioma')).toBeInTheDocument();
+ });
+
+ setItemSpy.mockRestore();
+ });
+});
diff --git a/tests/unit/i18n/locales-parity.test.ts b/tests/unit/i18n/locales-parity.test.ts
new file mode 100644
index 0000000..e09fa85
--- /dev/null
+++ b/tests/unit/i18n/locales-parity.test.ts
@@ -0,0 +1,43 @@
+import deCommon from '../../../src/renderer/i18n/locales/de/common.json';
+import enCommon from '../../../src/renderer/i18n/locales/en/common.json';
+import esCommon from '../../../src/renderer/i18n/locales/es/common.json';
+import frCommon from '../../../src/renderer/i18n/locales/fr/common.json';
+
+type JsonRecord = Record;
+
+const collectLeafKeys = (value: unknown, prefix = ''): string[] => {
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
+ return prefix ? [prefix] : [];
+ }
+
+ const keys = Object.keys(value as JsonRecord);
+ if (keys.length === 0) {
+ return prefix ? [prefix] : [];
+ }
+
+ let result: string[] = [];
+ for (const key of keys) {
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
+ result = [...result, ...collectLeafKeys((value as JsonRecord)[key], nextPrefix)];
+ }
+
+ return result;
+};
+
+const getSortedUniqueLeafKeys = (locale: JsonRecord): string[] => {
+ return [...new Set(collectLeafKeys(locale))].sort();
+};
+
+describe('i18n locale key parity', () => {
+ const englishKeys = getSortedUniqueLeafKeys(enCommon as JsonRecord);
+
+ test.each([
+ ['es', esCommon],
+ ['fr', frCommon],
+ ['de', deCommon],
+ ])('%s locale has exactly the same keys as en', (localeCode, locale) => {
+ const localeKeys = getSortedUniqueLeafKeys(locale as JsonRecord);
+
+ expect(localeKeys).toEqual(englishKeys);
+ });
+});