diff --git a/apps/frontend/src/main/app-language.ts b/apps/frontend/src/main/app-language.ts new file mode 100644 index 00000000..7a29ca87 --- /dev/null +++ b/apps/frontend/src/main/app-language.ts @@ -0,0 +1,45 @@ +/** + * App language tracking module for main process. + * + * Tracks the user's in-app language setting (not OS locale) for use in + * main process code that needs localized strings (e.g., context menus). + * + * Updated via IPC when user changes language in settings. + */ + +import { app } from 'electron'; + +// Current app language, defaults to 'en' +// Updated via setAppLanguage() when renderer notifies of language change +let currentAppLanguage = 'en'; + +/** + * Get the current app language. + * Falls back to 'en' if not set. + */ +export function getAppLanguage(): string { + return currentAppLanguage; +} + +/** + * Set the current app language. + * Called by IPC handler when renderer changes language. + */ +export function setAppLanguage(language: string): void { + currentAppLanguage = language; +} + +/** + * Initialize app language from OS locale as a starting point. + * The renderer will update this once i18n initializes. + */ +export function initAppLanguage(): void { + try { + // app.getLocale() may not be available in test environments + const osLocale = app?.getLocale?.() || 'en'; + // Extract base language (e.g., 'en-US' -> 'en') + currentAppLanguage = osLocale.split('-')[0] || 'en'; + } catch { + currentAppLanguage = 'en'; + } +} diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 3366e831..8f68abf6 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -35,7 +35,7 @@ for (const envPath of possibleEnvPaths) { } } -import { app, BrowserWindow, shell, nativeImage, session, screen } from 'electron'; +import { app, BrowserWindow, shell, nativeImage, session, screen, Menu, MenuItem } from 'electron'; import { join } from 'path'; import { accessSync, readFileSync, writeFileSync, rmSync } from 'fs'; import { electronApp, optimizer, is } from '@electron-toolkit/utils'; @@ -46,7 +46,8 @@ import { pythonEnvManager } from './python-env-manager'; import { getUsageMonitor } from './claude-profile/usage-monitor'; import { initializeUsageMonitorForwarding } from './ipc-handlers/terminal-handlers'; import { initializeAppUpdater, stopPeriodicUpdates } from './app-updater'; -import { DEFAULT_APP_SETTINGS, IPC_CHANNELS } from '../shared/constants'; +import { DEFAULT_APP_SETTINGS, IPC_CHANNELS, SPELL_CHECK_LANGUAGE_MAP, DEFAULT_SPELL_CHECK_LANGUAGE, ADD_TO_DICTIONARY_LABELS } from '../shared/constants'; +import { getAppLanguage, initAppLanguage } from './app-language'; import { readSettingsFile } from './settings-utils'; import { setupErrorLogging } from './app-logger'; import { initSentryMain } from './sentry'; @@ -204,7 +205,8 @@ function createWindow(): void { sandbox: false, contextIsolation: true, nodeIntegration: false, - backgroundThrottling: false // Prevent terminal lag when window loses focus + backgroundThrottling: false, // Prevent terminal lag when window loses focus + spellcheck: true // Enable spell check for text inputs } }); @@ -213,6 +215,87 @@ function createWindow(): void { mainWindow?.show(); }); + // Configure initial spell check languages with proper fallback logic + // Uses shared constant for consistency with the IPC handler + const defaultLanguage = 'en'; + const defaultSpellCheckLanguages = SPELL_CHECK_LANGUAGE_MAP[defaultLanguage] || [DEFAULT_SPELL_CHECK_LANGUAGE]; + const availableSpellCheckLanguages = session.defaultSession.availableSpellCheckerLanguages; + const validSpellCheckLanguages = defaultSpellCheckLanguages.filter(lang => + availableSpellCheckLanguages.includes(lang) + ); + const initialSpellCheckLanguages = validSpellCheckLanguages.length > 0 + ? validSpellCheckLanguages + : (availableSpellCheckLanguages.includes(DEFAULT_SPELL_CHECK_LANGUAGE) ? [DEFAULT_SPELL_CHECK_LANGUAGE] : []); + + if (initialSpellCheckLanguages.length > 0) { + session.defaultSession.setSpellCheckerLanguages(initialSpellCheckLanguages); + console.log(`[SPELLCHECK] Initial languages set to: ${initialSpellCheckLanguages.join(', ')}`); + } else { + console.warn('[SPELLCHECK] No spell check languages available on this system'); + } + + // Handle context menu with spell check and standard editing options + mainWindow.webContents.on('context-menu', (_event, params) => { + const menu = new Menu(); + + // Add spelling suggestions if there's a misspelled word + if (params.misspelledWord) { + for (const suggestion of params.dictionarySuggestions) { + menu.append(new MenuItem({ + label: suggestion, + click: () => mainWindow?.webContents.replaceMisspelling(suggestion) + })); + } + + if (params.dictionarySuggestions.length > 0) { + menu.append(new MenuItem({ type: 'separator' })); + } + + // Use localized label for "Add to Dictionary" based on app language (not OS locale) + // getAppLanguage() tracks the user's in-app language setting, updated via SPELLCHECK_SET_LANGUAGES IPC + const addToDictionaryLabel = ADD_TO_DICTIONARY_LABELS[getAppLanguage()] || ADD_TO_DICTIONARY_LABELS['en']; + menu.append(new MenuItem({ + label: addToDictionaryLabel, + click: () => mainWindow?.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord) + })); + + menu.append(new MenuItem({ type: 'separator' })); + } + + // Standard editing options for editable fields + // Using role without explicit label allows Electron to provide localized labels + if (params.isEditable) { + menu.append(new MenuItem({ + role: 'cut', + enabled: params.editFlags.canCut + })); + menu.append(new MenuItem({ + role: 'copy', + enabled: params.editFlags.canCopy + })); + menu.append(new MenuItem({ + role: 'paste', + enabled: params.editFlags.canPaste + })); + menu.append(new MenuItem({ + role: 'selectAll', + enabled: params.editFlags.canSelectAll + })); + } else if (params.selectionText?.trim()) { + // Non-editable text selection (e.g., labels, paragraphs) + // Use .trim() to avoid showing menu for whitespace-only selections + menu.append(new MenuItem({ + role: 'copy', + enabled: params.editFlags.canCopy + })); + } + + // Only show menu if there are items + if (menu.items.length > 0) { + menu.popup(); + } + }); + // Handle external links with URL scheme allowlist for security // Note: Terminal links now use IPC via WebLinksAddon callback, but this handler // catches any other window.open() calls (e.g., from third-party libraries) @@ -278,6 +361,9 @@ app.whenReady().then(() => { .catch((err) => console.warn('[main] Failed to clear cache:', err)); } + // Initialize app language from OS locale for main process i18n (context menus) + initAppLanguage(); + // Clean up stale update metadata from the old source updater system // This prevents version display desync after electron-updater installs a new version cleanupStaleUpdateMetadata(); diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 82df85a6..4b1ba2e1 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain, dialog, app, shell } from 'electron'; +import { ipcMain, dialog, app, shell, session } from 'electron'; import { existsSync, writeFileSync, mkdirSync, statSync, readFileSync } from 'fs'; import { execFileSync } from 'node:child_process'; import path from 'path'; @@ -8,7 +8,8 @@ import { is } from '@electron-toolkit/utils'; // ESM-compatible __dirname const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES } from '../../shared/constants'; +import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES, SPELL_CHECK_LANGUAGE_MAP, DEFAULT_SPELL_CHECK_LANGUAGE } from '../../shared/constants'; +import { setAppLanguage } from '../app-language'; import type { AppSettings, IPCResult, @@ -801,4 +802,64 @@ export function registerSettingsHandlers( } } ); + + // ============================================ + // Spell Check Operations + // ============================================ + + /** + * Set spell check languages based on app language. + * Called when renderer's i18n language changes to sync spell checker. + */ + ipcMain.handle( + IPC_CHANNELS.SPELLCHECK_SET_LANGUAGES, + async (_, language: string): Promise> => { + try { + // Validate language parameter + if (!language || typeof language !== 'string') { + return { + success: false, + error: 'Invalid language parameter' + }; + } + + // Update tracked app language for context menu labels + setAppLanguage(language); + + // Get spell check languages for this app language + const spellCheckLanguages = SPELL_CHECK_LANGUAGE_MAP[language] || [DEFAULT_SPELL_CHECK_LANGUAGE]; + + // Get available languages on this system + const availableLanguages = session.defaultSession.availableSpellCheckerLanguages; + + // Filter to only available languages + const validLanguages = spellCheckLanguages.filter(lang => + availableLanguages.includes(lang) + ); + + // Fallback to default if none of the preferred languages are available + const languagesToSet = validLanguages.length > 0 + ? validLanguages + : (availableLanguages.includes(DEFAULT_SPELL_CHECK_LANGUAGE) ? [DEFAULT_SPELL_CHECK_LANGUAGE] : []); + + if (languagesToSet.length > 0) { + session.defaultSession.setSpellCheckerLanguages(languagesToSet); + console.log(`[SPELLCHECK] Languages set to: ${languagesToSet.join(', ')} for app language: ${language}`); + } else { + console.warn(`[SPELLCHECK] No valid spell check languages available for: ${language}`); + } + + return { + success: true, + data: { success: true } + }; + } catch (error) { + console.error('[SPELLCHECK_SET_LANGUAGES] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set spell check languages' + }; + } + } + ); } diff --git a/apps/frontend/src/preload/api/settings-api.ts b/apps/frontend/src/preload/api/settings-api.ts index 36cdb0f9..c9e84885 100644 --- a/apps/frontend/src/preload/api/settings-api.ts +++ b/apps/frontend/src/preload/api/settings-api.ts @@ -36,6 +36,9 @@ export interface SettingsAPI { notifySentryStateChanged: (enabled: boolean) => void; getSentryDsn: () => Promise; getSentryConfig: () => Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }>; + + // Spell check + setSpellCheckLanguages: (language: string) => Promise>; } export const createSettingsAPI = (): SettingsAPI => ({ @@ -83,5 +86,9 @@ export const createSettingsAPI = (): SettingsAPI => ({ // Get full Sentry config from main process (DSN + sample rates) getSentryConfig: (): Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }> => - ipcRenderer.invoke(IPC_CHANNELS.GET_SENTRY_CONFIG) + ipcRenderer.invoke(IPC_CHANNELS.GET_SENTRY_CONFIG), + + // Spell check - sync spell checker language with app language + setSpellCheckLanguages: (language: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.SPELLCHECK_SET_LANGUAGES, language) }); diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index ef125b65..f0b22d88 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -313,7 +313,24 @@ export function App() { if (settings.language && settings.language !== i18n.language) { i18n.changeLanguage(settings.language); } - }, [settings.language, i18n]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only run when settings.language changes, not on every i18n object change + }, [settings.language, i18n.language]); + + // Sync spell check language with i18n language + useEffect(() => { + const syncSpellCheck = async () => { + try { + const result = await window.electronAPI.setSpellCheckLanguages(i18n.language); + if (!result.success) { + console.warn('[App] Failed to set spell check language:', result.error); + } + } catch (error) { + console.warn('[App] Error syncing spell check language:', error); + } + }; + + syncSpellCheck(); + }, [i18n.language]); // Listen for open-app-settings events (e.g., from project settings) useEffect(() => { diff --git a/apps/frontend/src/renderer/components/ui/input.tsx b/apps/frontend/src/renderer/components/ui/input.tsx index 59f3d14a..2b45b4f4 100644 --- a/apps/frontend/src/renderer/components/ui/input.tsx +++ b/apps/frontend/src/renderer/components/ui/input.tsx @@ -1,28 +1,33 @@ -import * as React from 'react'; -import { cn } from '../../lib/utils'; - -export interface InputProps extends React.InputHTMLAttributes {} - -const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { - return ( - - ); - } -); -Input.displayName = 'Input'; - -export { Input }; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { cn } from '../../lib/utils'; + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, spellCheck, lang, ...props }, ref) => { + const { i18n } = useTranslation(); + + return ( + + ); + } +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/frontend/src/renderer/components/ui/textarea.tsx b/apps/frontend/src/renderer/components/ui/textarea.tsx index fb665030..0bfa6d08 100644 --- a/apps/frontend/src/renderer/components/ui/textarea.tsx +++ b/apps/frontend/src/renderer/components/ui/textarea.tsx @@ -1,27 +1,32 @@ -import * as React from 'react'; -import { cn } from '../../lib/utils'; - -export interface TextareaProps extends React.TextareaHTMLAttributes {} - -const Textarea = React.forwardRef( - ({ className, ...props }, ref) => { - return ( -