/** * Settings shape as the UI consumes it: every field present and in range. * Values come from user-edited TOML (via the backend) or from older versions, * so `validateSettings` never throws — anything unknown or malformed falls * back to the default (mirrors `ide-core::settings` defaults). */ import { validateCMake } from "@/platform/cmakeSettings"; import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames"; import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes"; import type { AgentSettings, LanguageServerSettings, Settings } from "@/platform/ipc"; import { DEFAULT_LANGUAGES, DEFAULT_PROJECT_STRUCTURE, validateLanguages, validateProjectStructure, type LanguagesValid, type ProjectStructureValid, } from "@/platform/languageSettings"; import { EDITOR_EXTRA_DEFAULTS, validateEditorExtras, type EditorExtras } from "@/platform/editorSettings"; import { DEFAULT_WORKFLOW, validateWorkflow, type WorkflowSettings } from "@/platform/workflowSettings"; import { DEFAULT_INSPECTIONS, validateInspections, type InspectionSettingsValid } from "@/platform/inspections"; import { DEFAULT_PROXY, validateProxy, type ProxySettingsValid } from "@/platform/proxySettings"; import { DEFAULT_APPEARANCE_EXTRAS, DEFAULT_GENERAL_EXTRAS, DEFAULT_NOTIFICATIONS, validateAppearanceExtras, validateGeneralExtras, validateNotifications, type AppearanceExtras, type GeneralExtras, type NotificationsValid, } from "./behaviorSchema"; import { isThemeChoiceId, type ThemeChoiceId } from "@/platform/themes"; /** A built-in theme, a plugin's (`plugin:/`) or the legacy `"system"`. */ export type ThemeId = ThemeChoiceId | "system"; export type LocaleId = "en" | "zh" | "ko"; /** A user keymap (Keymap › Duplicate…): its own overrides on top of a bundled preset. */ export interface CustomKeymapSettings { name: string; parent: string; overrides: Record; } export interface ValidSettings extends WorkflowSettings { general: { reopenLastProject: boolean; confirmExit: boolean } & GeneralExtras; appearance: { theme: ThemeId; accent: string; uiFontSize: number; locale: LocaleId } & AppearanceExtras; editor: { fontFamily: string; fontSize: number; lineHeight: number; tabSize: number; insertSpaces: boolean; softWrap: boolean; showWhitespace: boolean; inlayHints: boolean; stickyScroll: boolean; formatOnSave: boolean; /** `theme-default` follows the IDE theme. */ colorScheme: string; } & EditorExtras; keymap: string; files: { autoSave: boolean; syncOnFocus: boolean; exclude: string[]; ignoredNames: string[]; trimTrailingWhitespace: boolean; ensureFinalNewline: boolean; /** Local History keeps this many working days (days with changes). */ localHistoryDays: number; /** Local History size bound per project, MB. */ localHistoryMaxMb: number; /** File Types: the user's patterns per language id. */ fileTypes: FileTypeAssociations; /** Override File Type: file → language id (project scope). */ fileTypeOverrides: Record; }; keymapOverrides: Record; customKeymaps: Record; notifications: NotificationsValid; /** System Settings › HTTP Proxy (global). */ proxy: ProxySettingsValid; languages: LanguagesValid; cmake: ReturnType; projectStructure: ProjectStructureValid; inspections: InspectionSettingsValid; /** Editor › Natural Languages › Spelling. */ spelling: SpellingValid; /** Executables chosen for language servers / debug adapters, by command. */ tools: Record; /** Settings › Language Servers, by server name (as the backend sends them). */ languageServers: Record; /** Tools › Agents › Chat: the meters in an agent task's header. */ agents: Required; } /** One `languageServers.` entry; every field optional, like in settings.toml. */ export type LanguageServerEntry = Partial; export interface SpellingValid { ideWords: string[]; projectWords: string[]; customDictionaries: string[]; } const stringList = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && !!x.trim()) : []; function validateSpelling(raw: unknown): SpellingValid { const r = obj(raw); return { ideWords: stringList(r.ideWords), projectWords: stringList(r.projectWords), customDictionaries: stringList(r.customDictionaries), }; } export { ACCENT_PRESETS } from "@/workbench/theme"; export const DEFAULT_SETTINGS: ValidSettings = { general: { reopenLastProject: true, confirmExit: true, ...DEFAULT_GENERAL_EXTRAS }, appearance: { theme: "light", accent: "#3871e1", uiFontSize: 13, locale: "en", ...DEFAULT_APPEARANCE_EXTRAS }, editor: { fontFamily: "JetBrains Mono", fontSize: 13, lineHeight: 22, tabSize: 4, insertSpaces: true, softWrap: true, showWhitespace: false, inlayHints: true, stickyScroll: true, formatOnSave: false, colorScheme: "theme-default", ...EDITOR_EXTRA_DEFAULTS, }, keymap: "intellij-macos", files: { autoSave: true, syncOnFocus: true, exclude: ["**/.git", "**/node_modules", "**/target"], ignoredNames: [...DEFAULT_IGNORED_NAMES], trimTrailingWhitespace: true, ensureFinalNewline: true, localHistoryDays: 5, localHistoryMaxMb: 512, fileTypes: {}, fileTypeOverrides: {}, }, keymapOverrides: {}, customKeymaps: {}, notifications: DEFAULT_NOTIFICATIONS, proxy: DEFAULT_PROXY, languages: DEFAULT_LANGUAGES, cmake: {}, projectStructure: DEFAULT_PROJECT_STRUCTURE, inspections: DEFAULT_INSPECTIONS, spelling: { ideWords: [], projectWords: [], customDictionaries: [] }, tools: {}, languageServers: {}, agents: { showContextUsage: true, showWorkTime: true, showUsageLimits: true, showCost: false }, ...DEFAULT_WORKFLOW, }; export const LIMITS = { uiFontSize: { min: 10, max: 18 }, fontSize: { min: 6, max: 72 }, lineHeight: { min: 8, max: 120 }, tabSize: { min: 1, max: 16 }, localHistoryDays: { min: 1, max: 365 }, localHistoryMaxMb: { min: 16, max: 16384 }, } as const; const theme = (v: unknown, d: ThemeId): ThemeId => (v === "system" || isThemeChoiceId(v) ? v : d); const LOCALES: readonly LocaleId[] = ["en", "zh", "ko"]; export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; type Obj = Record; const obj = (v: unknown): Obj => (v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {}); const bool = (v: unknown, d: boolean) => (typeof v === "boolean" ? v : d); const str = (v: unknown, d: string) => (typeof v === "string" && v.trim() ? v : d); const oneOf = (v: unknown, allowed: readonly T[], d: T): T => typeof v === "string" && (allowed as readonly string[]).includes(v) ? (v as T) : d; const num = (v: unknown, d: number, { min, max }: { min: number; max: number }, int = false) => { if (typeof v !== "number" || !Number.isFinite(v)) return d; const n = Math.min(max, Math.max(min, v)); return int ? Math.round(n) : n; }; function bindingMap(raw: unknown): Record { const out: Record = {}; for (const [id, keys] of Object.entries(obj(raw))) { if (Array.isArray(keys)) out[id] = keys.filter((k): k is string => typeof k === "string" && !!k.trim()); } return out; } export function validateSettings(raw: Settings | unknown): ValidSettings { const d = DEFAULT_SETTINGS; const root = obj(raw); const g = obj(root.general); const a = obj(root.appearance); const e = obj(root.editor); const f = obj(root.files); const agents = obj(root.agents); const overrides = bindingMap(root.keymapOverrides); const customKeymaps: Record = {}; for (const [id, raw] of Object.entries(obj(root.customKeymaps))) { const k = obj(raw); if (!id.trim()) continue; customKeymaps[id] = { name: str(k.name, id), parent: str(k.parent, d.keymap), overrides: bindingMap(k.overrides) }; } return { general: { ...validateGeneralExtras(g), reopenLastProject: bool(g.reopenLastProject, d.general.reopenLastProject), confirmExit: bool(g.confirmExit, d.general.confirmExit), }, appearance: { ...validateAppearanceExtras(a), theme: theme(a.theme, d.appearance.theme), accent: typeof a.accent === "string" && HEX_COLOR.test(a.accent) ? a.accent.toLowerCase() : d.appearance.accent, uiFontSize: num(a.uiFontSize, d.appearance.uiFontSize, LIMITS.uiFontSize, true), locale: oneOf(a.locale, LOCALES, d.appearance.locale), }, editor: { fontFamily: str(e.fontFamily, d.editor.fontFamily), fontSize: num(e.fontSize, d.editor.fontSize, LIMITS.fontSize), lineHeight: num(e.lineHeight, d.editor.lineHeight, LIMITS.lineHeight), tabSize: num(e.tabSize, d.editor.tabSize, LIMITS.tabSize, true), insertSpaces: bool(e.insertSpaces, d.editor.insertSpaces), softWrap: bool(e.softWrap, d.editor.softWrap), showWhitespace: bool(e.showWhitespace, d.editor.showWhitespace), inlayHints: bool(e.inlayHints, d.editor.inlayHints), stickyScroll: bool(e.stickyScroll, d.editor.stickyScroll), formatOnSave: bool(e.formatOnSave, d.editor.formatOnSave), colorScheme: str(e.colorScheme, d.editor.colorScheme), ...validateEditorExtras(e), }, keymap: str(root.keymap, d.keymap), files: { autoSave: bool(f.autoSave, d.files.autoSave), syncOnFocus: bool(f.syncOnFocus, d.files.syncOnFocus), exclude: Array.isArray(f.exclude) ? f.exclude.filter((x): x is string => typeof x === "string" && !!x.trim()) : [...d.files.exclude], ignoredNames: Array.isArray(f.ignoredNames) ? f.ignoredNames.filter((x): x is string => typeof x === "string" && !!x.trim()) : [...d.files.ignoredNames], trimTrailingWhitespace: bool(f.trimTrailingWhitespace, d.files.trimTrailingWhitespace), ensureFinalNewline: bool(f.ensureFinalNewline, d.files.ensureFinalNewline), localHistoryDays: num(f.localHistoryDays, d.files.localHistoryDays, LIMITS.localHistoryDays, true), localHistoryMaxMb: num(f.localHistoryMaxMb, d.files.localHistoryMaxMb, LIMITS.localHistoryMaxMb, true), fileTypes: validateFileTypes(f.fileTypes), fileTypeOverrides: validateFileTypeOverrides(f.fileTypeOverrides), }, keymapOverrides: overrides, customKeymaps, notifications: validateNotifications(root.notifications), proxy: validateProxy(root.proxy), languages: validateLanguages(root.languages), cmake: validateCMake(root.cmake), projectStructure: validateProjectStructure(root.projectStructure), inspections: validateInspections(root.inspections), spelling: validateSpelling(root.spelling), tools: Object.fromEntries( Object.entries(obj(root.tools)).flatMap(([cmd, t]) => { const path = obj(t).path; return typeof path === "string" ? [[cmd, { path }]] : []; }), ), languageServers: Object.fromEntries( Object.entries(obj(root.languageServers)).map(([name, ls]) => [name, obj(ls) as LanguageServerEntry]), ), agents: { showContextUsage: bool(agents.showContextUsage, d.agents.showContextUsage), showWorkTime: bool(agents.showWorkTime, d.agents.showWorkTime), showUsageLimits: bool(agents.showUsageLimits, d.agents.showUsageLimits), showCost: bool(agents.showCost, d.agents.showCost), }, ...validateWorkflow(root), }; } /** Reads a dotted key (`editor.fontSize`); `undefined` when absent. */ export function getAt(source: unknown, key: string): unknown { let cur: unknown = source; for (const seg of key.split(".")) { if (!cur || typeof cur !== "object") return undefined; cur = (cur as Obj)[seg]; } return cur; } /** Returns a copy of `source` with the dotted `key` set (immutable path copy). */ export function setAt(source: T, key: string, value: unknown): T { const [head, ...rest] = key.split("."); const base = obj(source); return { ...base, [head!]: rest.length ? setAt(base[head!], rest.join("."), value) : value } as T; } /** Like `setAt`, but `null` / `undefined` removes the key (as a settings write of `null` does). */ export function putAt(source: T, key: string, value: unknown): T { const [head, ...rest] = key.split("."); const base = { ...obj(source) }; if (rest.length) base[head!] = putAt(base[head!], rest.join("."), value); else if (value == null) delete base[head!]; else base[head!] = value; return base as T; } export function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== typeof b || !a || !b || typeof a !== "object") return false; if (Array.isArray(a) !== Array.isArray(b)) return false; const ka = Object.keys(a as Obj); const kb = Object.keys(b as Obj); return ka.length === kb.length && ka.every((k) => deepEqual((a as Obj)[k], (b as Obj)[k])); }