feat(desktop): spellcheck toggle and system-language dictionaries (#59129)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-10-30 04:21:10 +00:00
committed by GitHub
Unverified
parent a88d3ca0bc
commit bf94de965d
20 changed files with 218 additions and 29 deletions
+18 -23
View File
@@ -1,3 +1,5 @@
import { flow, Option, Schema } from "effect"
export const DESKTOP_NATIVE_LOCALES = [
"en",
"zh",
@@ -302,6 +304,9 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.picker.error.notSelected": "File was not selected by the picker",
"desktop.picker.error.sizeLimit": "Selected attachments exceed the {{limit}} MB limit",
"desktop.contextMenu.spellcheck": "Spellcheck",
"desktop.contextMenu.spellcheckLanguages": "Spellcheck Languages",
} as const
export type DesktopNativeKey = keyof typeof DESKTOP_NATIVE_ENGLISH
@@ -310,6 +315,7 @@ export type DesktopNativeMessages = Record<DesktopNativeKey, string>
export type DesktopNativeBundle = { locale: DesktopNativeLocale; messages: DesktopNativeMessages }
// SAFETY: DESKTOP_NATIVE_ENGLISH is a const literal, so its own keys are exactly DesktopNativeKey.
export const DESKTOP_NATIVE_KEYS = Object.keys(DESKTOP_NATIVE_ENGLISH) as DesktopNativeKey[]
export const DESKTOP_NATIVE_MAX_PAYLOAD_BYTES = 64 * 1024
@@ -320,35 +326,24 @@ export function createDesktopNativeBundle(
): DesktopNativeBundle {
return {
locale,
// SAFETY: the entries map every DesktopNativeKey to a string, which is exactly DesktopNativeMessages.
messages: Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key) => [key, translate(key)])) as DesktopNativeMessages,
}
}
export function parseDesktopNativeBundle(value: unknown): DesktopNativeBundle | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
const DesktopNativeBundleSchema = Schema.Struct({
locale: Schema.Literals(DESKTOP_NATIVE_LOCALES),
messages: Schema.Record(Schema.Literals(DESKTOP_NATIVE_KEYS), Schema.String),
}).check(
Schema.makeFilter(
(bundle) => new TextEncoder().encode(JSON.stringify(bundle)).byteLength <= DESKTOP_NATIVE_MAX_PAYLOAD_BYTES,
),
)
try {
if (new TextEncoder().encode(JSON.stringify(value)).byteLength > DESKTOP_NATIVE_MAX_PAYLOAD_BYTES) return undefined
} catch {
return undefined
}
const decodeDesktopNativeBundle = Schema.decodeUnknownOption(DesktopNativeBundleSchema, { onExcessProperty: "error" })
const bundle = value as { locale?: unknown; messages?: unknown }
if (!DESKTOP_NATIVE_LOCALES.some((locale) => locale === bundle.locale)) return undefined
if (!bundle.messages || typeof bundle.messages !== "object" || Array.isArray(bundle.messages)) return undefined
const messages = bundle.messages as Record<string, unknown>
const keys = Object.keys(messages)
if (keys.length !== DESKTOP_NATIVE_KEYS.length) return undefined
if (!DESKTOP_NATIVE_KEYS.every((key) => typeof messages[key] === "string")) return undefined
if (!keys.every((key) => key in DESKTOP_NATIVE_ENGLISH)) return undefined
return bundle as DesktopNativeBundle
}
/** Validates a bundle the renderer sent over IPC: a supported locale, exactly the native keys, and a bounded size. */
export const parseDesktopNativeBundle = flow(decodeDesktopNativeBundle, Option.getOrUndefined)
export function formatDesktopNativeMessage(message: string, params?: Record<string, string | number>) {
if (!params) return message
+2
View File
@@ -1005,6 +1005,8 @@ export const dict = {
"settings.general.row.followUpBehavior.steer": "Steer",
"settings.general.row.pinchZoom.title": "Pinch to zoom",
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
"settings.general.row.spellcheck.title": "Spellcheck",
"settings.general.row.spellcheck.description": "Underline misspelled words while typing",
"sound.option.none": "None",
"sound.option.alert01": "Alert 01",
+1 -1
View File
@@ -72,7 +72,7 @@ const desktop = [
]
export const dict = {
...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])),
...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])),
"command.category.suggested": "Predloženo",
"command.category.view": "Prikaz",
"command.category.project": "Projekt",
+1 -1
View File
@@ -72,7 +72,7 @@ const desktop = [
]
export const dict = {
...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])),
...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])),
"command.category.suggested": "Javasolt",
"command.category.view": "Nézet",
"command.category.project": "Projekt",
+1 -1
View File
@@ -72,7 +72,7 @@ const desktop = [
]
export const dict = {
...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])),
...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])),
"command.category.suggested": "Tillögur",
"command.category.view": "Skoða",
"command.category.project": "Verkefni",
+1 -1
View File
@@ -72,7 +72,7 @@ const desktop = [
]
export const dict = {
...Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key, index) => [key, desktop[index]])),
...Object.fromEntries(desktop.map((value, index) => [DESKTOP_NATIVE_KEYS[index], value])),
"command.category.suggested": "Siūloma",
"command.category.view": "Rodinys",
"command.category.project": "Projektas",
@@ -108,6 +108,12 @@ type PlatformBase = {
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
/** Get whether the native spellchecker underlines misspelled words (desktop only) */
getSpellcheckEnabled?(): Promise<boolean>
/** Turn the native spellchecker on or off (desktop only) */
setSpellcheckEnabled?(enabled: boolean): Promise<void>
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
@@ -249,6 +249,17 @@ export const SettingsGeneral: Component = () => {
void update.catch(() => setPinchZoom(!checked))
}
const [spellcheck, { mutate: setSpellcheck }] = createResource(
() => desktop() && "getSpellcheckEnabled" in platform,
() => platform.getSpellcheckEnabled?.().catch(() => true) ?? true,
{ initialValue: true },
)
const onSpellcheckChange = (checked: boolean) => {
setSpellcheck(checked)
void platform.setSpellcheckEnabled?.(checked).catch(() => setSpellcheck(!checked))
}
const GeneralSection = () => (
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.general")}</h3>
@@ -282,6 +293,14 @@ export const SettingsGeneral: Component = () => {
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.spellcheck.title")}
description={language.t("settings.general.row.spellcheck.description")}
>
<div data-action="settings-spellcheck">
<Switch checked={spellcheck.latest} onChange={onSpellcheckChange} />
</div>
</SettingsRow>
</Show>
<PluginSettingsSections page="general" section="general" />
@@ -55,6 +55,13 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
target: "settings-pinch-zoom",
available: "desktop",
},
{
tab: "general",
label: "settings.general.row.spellcheck.title",
target: "settings-spellcheck",
keywords: "spelling spell check dictionary",
available: "desktop",
},
{
tab: "general",
label: "settings.general.row.mobileTitlebarBottom.title",
@@ -8,6 +8,7 @@ import { setForceFocus } from "../native/debug"
import { showCliInstaller } from "../native/install-cli"
import { DesktopLogging, scoped } from "../native/logging"
import { createMenu, sendMenuCommand } from "../native/menu"
import { getSpellcheckEnabled, setSpellcheckEnabled } from "../native/spellcheck"
import { setNativeTranslations } from "../native/translations"
import { IpcPortHandoff } from "../ipc-transport"
import { ApplicationLifecycle } from "../lifecycle"
@@ -37,6 +38,8 @@ export const appHandlers = AppRpcs.toLayer(
AppCheckAppExists: ({ appName }) => checkAppExists(appName).pipe(Effect.orDie),
AppResolveAppPath: ({ appName }) => resolveAppPath(appName).pipe(Effect.orDie),
AppSetBackgroundColor: ({ color }) => Effect.sync(() => setBackgroundColor(color)),
AppGetSpellcheckEnabled: () => Effect.sync(getSpellcheckEnabled),
AppSetSpellcheckEnabled: ({ enabled }) => Effect.sync(() => setSpellcheckEnabled(enabled)),
AppExportDebugLogs: () => logging.exportDebug,
AppSetForceFocus: ({ enabled }, context) => promise(() => setForceFocus(sender(handoff, context), enabled)),
AppRecordFatalRendererError: ({ error }) =>
@@ -6,12 +6,18 @@ import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { registerRendererProtocol, setDockIcon, setProtocolReporter } from "../windows"
import { scoped } from "../native/logging"
import { applySpellcheck, spellcheckMenuItems } from "../native/spellcheck"
// electron-context-menu attaches to every existing and future window, so it can load once the first
// window is up instead of holding up startup with its dependency tree.
export const installContextMenu = Effect.gen(function* () {
const { default: contextMenu } = yield* Effect.promise(() => import("electron-context-menu"))
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
contextMenu({
showSaveImageAs: true,
showLookUpSelection: false,
showSearchWithGoogle: false,
append: (_actions, params) => (params.isEditable ? spellcheckMenuItems() : []),
})
})
export const prepareApplicationEnvironment = Effect.gen(function* () {
@@ -45,12 +51,13 @@ export const prepareDesktop = Effect.gen(function* () {
)
registerRendererProtocol(paths.rendererRoot)
setDockIcon(path, paths)
applySpellcheck()
})
export const loadProxyEnvironment = Effect.gen(function* () {
yield* Effect.try(() => {
ensureLoopbackNoProxy()
// Electron 41.2 has a newer Node API than the current @types/node package.
// SAFETY: Electron 41.2 ships a Node with http.setGlobalProxyFromEnv, which the current @types/node lacks.
const proxyAwareHttp = http as typeof http & { setGlobalProxyFromEnv(): void }
proxyAwareHttp.setGlobalProxyFromEnv()
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error })))
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test"
import { defaultSpellcheckLanguages } from "./spellcheck-languages"
// A slice of Electron 44's Linux and Windows list, including aliases such as de and de-DE.
const available = ["de", "de-DE", "en", "en-AU", "en-GB", "en-US", "es", "es-MX", "fr", "fr-FR", "pt", "pt-BR"]
test.each([
[["de-DE"], ["de-DE", "en-US"]],
[
["de-DE", "de", "en-US", "en"],
["de-DE", "en-US"],
],
[
["de-AT", "fr-CA"],
["de", "fr", "en-US"],
],
[
["en-GB", "de-DE"],
["en-GB", "de-DE"],
],
[["en-NZ"], ["en"]],
[
["es-MX", "es-ES"],
["es-MX", "en-US"],
],
[["pt-AO"], ["pt", "en-US"]],
[["DE-de"], ["de-DE", "en-US"]],
[["ja-JP"], ["en-US"]],
[[], ["en-US"]],
])("system languages %p use dictionaries %p", (preferred, languages) => {
expect(defaultSpellcheckLanguages(available, preferred)).toEqual(languages)
})
@@ -0,0 +1,16 @@
const base = (tag: string) => tag.split("-")[0].toLowerCase()
// One dictionary per system language, matched exactly or by base language (de-AT -> de). Chromium
// lists aliases such as de and de-DE separately, so a later tag for the same language is skipped.
// US English is added when none of them is English, because prompts mix prose with English code terms.
export function defaultSpellcheckLanguages(available: readonly string[], preferred: readonly string[]) {
const find = (tag: string) => available.find((language) => language.toLowerCase() === tag.toLowerCase())
const languages = preferred
.flatMap((tag) => find(tag) ?? find(base(tag)) ?? [])
.filter((language, index, all) => all.findIndex((other) => base(other) === base(language)) === index)
if (languages.some((language) => base(language) === "en") || !available.includes("en-US")) return languages
return [...languages, "en-US"]
}
@@ -0,0 +1,74 @@
import { app, session, type MenuItemConstructorOptions } from "electron"
import { SPELLCHECK_ENABLED_KEY, SPELLCHECK_LANGUAGES_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { defaultSpellcheckLanguages } from "./spellcheck-languages"
import { nativeLanguageOptions, nativeT } from "./translations"
// macOS checks spelling with the system spellchecker, which detects the language itself; Electron
// ignores dictionary choices there.
const choosesLanguages = process.platform !== "darwin"
export function applySpellcheck() {
// Setting languages also turns the spellchecker on, so the stored on/off state goes last.
if (choosesLanguages) session.defaultSession.setSpellCheckerLanguages(spellcheckLanguages())
session.defaultSession.setSpellCheckerEnabled(getSpellcheckEnabled())
}
export function getSpellcheckEnabled() {
return getStore().get(SPELLCHECK_ENABLED_KEY) !== false
}
export function setSpellcheckEnabled(enabled: boolean) {
getStore().set(SPELLCHECK_ENABLED_KEY, enabled)
session.defaultSession.setSpellCheckerEnabled(enabled)
}
// Mirrors the Spellcheck menu in chat apps: a checkbox, and a dictionary picker where Electron owns
// the dictionaries.
export function spellcheckMenuItems(): MenuItemConstructorOptions[] {
const enabled = getSpellcheckEnabled()
const current = session.defaultSession.getSpellCheckerLanguages()
const toggle: MenuItemConstructorOptions = {
label: nativeT("desktop.contextMenu.spellcheck"),
type: "checkbox",
checked: enabled,
click: () => setSpellcheckEnabled(!enabled),
}
if (!choosesLanguages) return [{ type: "separator" }, toggle]
return [
{ type: "separator" },
toggle,
{
label: nativeT("desktop.contextMenu.spellcheckLanguages"),
enabled,
submenu: nativeLanguageOptions(session.defaultSession.availableSpellCheckerLanguages).map((option) => ({
label: option.label,
type: "checkbox",
checked: current.includes(option.tag),
// The last dictionary stays; the Spellcheck checkbox is how to stop checking entirely.
enabled: !(current.length === 1 && current[0] === option.tag),
click: () => {
const next = current.includes(option.tag)
? current.filter((language) => language !== option.tag)
: [...current, option.tag]
getStore().set(SPELLCHECK_LANGUAGES_KEY, next)
session.defaultSession.setSpellCheckerLanguages(next)
},
})),
},
]
}
// Dictionaries picked from the menu win; otherwise follow the system languages on every launch.
function spellcheckLanguages() {
const available = session.defaultSession.availableSpellCheckerLanguages
const stored = getStore().get(SPELLCHECK_LANGUAGES_KEY)
const chosen = Array.isArray(stored) ? available.filter((language) => stored.includes(language)) : []
return chosen.length ? chosen : defaultSpellcheckLanguages(available, app.getPreferredSystemLanguages())
}
@@ -29,6 +29,16 @@ export function nativeT(key: DesktopNativeKey, params?: Record<string, string |
return formatDesktopNativeMessage(bundle.messages[key], params)
}
/** Language tags with their names in the app locale, sorted for a menu. */
export function nativeLanguageOptions(tags: readonly string[]) {
const locale = DESKTOP_NATIVE_LOCALE_TAGS[bundle.locale]
const names = new Intl.DisplayNames([locale], { type: "language" })
return tags
.map((tag) => ({ tag, label: names.of(tag) ?? tag }))
.toSorted((a, b) => a.label.localeCompare(b.label, locale))
}
/** The app locale the renderer last pushed. GUI plugin catalogs resolve against it. */
export function nativeLocale() {
return bundle.locale
@@ -6,6 +6,10 @@ export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const ZOOM_LEVEL_KEY = "zoomLevel"
export const SPELLCHECK_ENABLED_KEY = "spellcheckEnabled"
export const SPELLCHECK_LANGUAGES_KEY = "spellcheckLanguages"
export const BACKGROUND_COLOR_KEY = "backgroundColor"
export const WINDOW_IDS_KEY = "windowIds"
@@ -71,6 +71,8 @@ export type ElectronAPI = {
setBackgroundColor(color: string): Promise<void>
exportDebugLogs(): Promise<string>
setForceFocus(enabled: boolean): Promise<void>
getSpellcheckEnabled(): Promise<boolean>
setSpellcheckEnabled(enabled: boolean): Promise<void>
recordFatalRendererError(error: FatalRendererError): Promise<void>
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
}
+2
View File
@@ -94,6 +94,8 @@ export const api: ElectronAPI = {
setBackgroundColor: (color) => invoke("AppSetBackgroundColor", { color }),
exportDebugLogs: () => invoke("AppExportDebugLogs"),
setForceFocus: (enabled) => invoke("AppSetForceFocus", { enabled }),
getSpellcheckEnabled: () => invoke("AppGetSpellcheckEnabled"),
setSpellcheckEnabled: (enabled) => invoke("AppSetSpellcheckEnabled", { enabled }),
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
}
@@ -43,6 +43,8 @@ export function createDesktopPlatform(api: ElectronAPI, windowState: DesktopWind
setTrafficLightsPosition: (position) => api.setTrafficLightsPosition(position),
getPinchZoomEnabled: () => api.getPinchZoomEnabled(),
setPinchZoomEnabled,
getSpellcheckEnabled: () => api.getSpellcheckEnabled(),
setSpellcheckEnabled: (enabled) => api.setSpellcheckEnabled(enabled),
onDragCancel: (callback) => {
window.addEventListener(DragCancelEvent, callback)
@@ -54,6 +54,12 @@ export const AppRecordFatalRendererError = Rpc.make("AppRecordFatalRendererError
},
})
export const AppGetSpellcheckEnabled = Rpc.make("AppGetSpellcheckEnabled", { success: Schema.Boolean })
export const AppSetSpellcheckEnabled = Rpc.make("AppSetSpellcheckEnabled", {
payload: { enabled: Schema.Boolean },
})
export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
payload: { value: Schema.Unknown },
})
@@ -71,6 +77,8 @@ export const AppRpcs = RpcGroup.make(
AppSetBackgroundColor,
AppExportDebugLogs,
AppSetForceFocus,
AppGetSpellcheckEnabled,
AppSetSpellcheckEnabled,
AppRecordFatalRendererError,
AppSetNativeTranslations,
AppRelaunch,