diff --git a/src/main/api/Base.routes.ts b/src/main/api/Base.routes.ts index b948696..190ef66 100644 --- a/src/main/api/Base.routes.ts +++ b/src/main/api/Base.routes.ts @@ -2,7 +2,7 @@ import { version } from '../../../package.json'; import { ok } from '../core/RestAPI'; import { getAllProfiles, getSettings, saveSettings } from '../core/Store'; import { processManager } from '../core/process/ProcessManager'; -import { AppSettings } from '../shared/types/App.types'; +import { AppSettings } from '../shared/config/Settings.config'; import { defineRoute, RouteMap } from '../shared/types/RestAPI.types'; export const BaseRoutes: RouteMap = { diff --git a/src/main/core/Store.ts b/src/main/core/Store.ts index 3231cdc..fb6b701 100644 --- a/src/main/core/Store.ts +++ b/src/main/core/Store.ts @@ -1,7 +1,7 @@ import { app } from 'electron'; import Store from 'electron-store'; -import { DEFAULT_SETTINGS } from '../shared/config/App.config'; -import { AppSettings } from '../shared/types/App.types'; +import { DEFAULT_SETTINGS } from '../shared/config/Settings.config'; +import { AppSettings } from '../shared/config/Settings.config'; import { Profile } from '../shared/types/Profile.types'; interface StoreSchema { diff --git a/src/main/ipc/Dev.ipc.ts b/src/main/ipc/Dev.ipc.ts index c6eb8cd..95b9489 100644 --- a/src/main/ipc/Dev.ipc.ts +++ b/src/main/ipc/Dev.ipc.ts @@ -1,7 +1,7 @@ import { BrowserWindow } from 'electron'; import type { RouteMap } from '../core/IPCController'; import { getAllProfiles } from '../core/Store'; -import { DEFAULT_SETTINGS } from '../shared/config/App.config'; +import { DEFAULT_SETTINGS } from '../shared/config/Settings.config'; let getWindow: () => BrowserWindow | null = () => null; diff --git a/src/main/ipc/System.ipc.ts b/src/main/ipc/System.ipc.ts index 0f08a6f..bc47e81 100644 --- a/src/main/ipc/System.ipc.ts +++ b/src/main/ipc/System.ipc.ts @@ -2,7 +2,7 @@ import { app, dialog, shell } from 'electron'; import type { RouteMap } from '../core/IPCController'; import { restApiServer } from '../core/RestAPI'; import { getSettings, saveSettings } from '../core/Store'; -import type { AppSettings } from '../shared/types/App.types'; +import type { AppSettings } from '../shared/config/Settings.config'; // mainWindow is needed for dialogs — set via initSystemIPC() called from main.ts let getWindow: () => Electron.BrowserWindow | null = () => null; diff --git a/src/main/shared/config/App.config.ts b/src/main/shared/config/App.config.ts deleted file mode 100644 index 98ecf13..0000000 --- a/src/main/shared/config/App.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { AppSettings } from '../types/App.types'; -import { REST_API_CONFIG } from './API.config'; - -export const DEFAULT_SETTINGS: AppSettings = { - launchOnStartup: false, - startMinimized: false, - minimizeToTray: true, - consoleFontSize: 13, - consoleMaxLines: 5000, - consoleWordWrap: false, - consoleLineNumbers: false, - consoleTimestamps: false, - consoleHistorySize: 200, - themeId: 'dark-default', - languageId: 'en', - restApiEnabled: false, - restApiPort: REST_API_CONFIG.defaultPort, - devModeEnabled: false, -}; diff --git a/src/main/shared/config/Settings.config.ts b/src/main/shared/config/Settings.config.ts index 1a7b9cc..e1cfae3 100644 --- a/src/main/shared/config/Settings.config.ts +++ b/src/main/shared/config/Settings.config.ts @@ -1,9 +1,193 @@ -import type { SidebarTopic } from '../types/Sidebar.types'; +import { REST_API_CONFIG } from './API.config'; +import { + AnyFieldDef, + extractDefaults, + InferSettings, + NoteDef, + NumberDef, + RangeDef, + SettingSidebarTopic, + TextDef, + ToggleDef, +} from '../types/Settings.types'; -export const SETTINGS_TOPICS: SidebarTopic[] = [ +// ─── Section registry ────────────────────────────────────────────────────── +// Add a new entry here when adding a new settings section. + +export type SettingSection = 'general' | 'console' | 'appearance' | 'advanced' | 'about'; + +export const SETTINGS_TOPICS: SettingSidebarTopic[] = [ { id: 'general', label: 'General' }, { id: 'console', label: 'Console' }, { id: 'appearance', label: 'Appearance' }, { id: 'advanced', label: 'Advanced' }, { id: 'about', label: 'About' }, ]; + +// ─── Schema ──────────────────────────────────────────────────────────────── + +export const SETTINGS_SCHEMA = { + // ── General › Startup ──────────────────────────────────────────────────── + launchOnStartup: { + type: 'toggle', + default: false, + section: 'general', + group: 'settings.startup', + label: 'settings.launchOnStartup', + hint: 'settings.launchOnStartupHint', + } as ToggleDef, + + startMinimized: { + type: 'toggle', + default: false, + section: 'general', + group: 'settings.startup', + label: 'settings.startMinimized', + hint: 'settings.startMinimizedHint', + sub: true, + disabledWhen: (s: any) => !s.launchOnStartup, + } as ToggleDef, + + minimizeToTray: { + type: 'toggle', + default: true, + section: 'general', + group: 'settings.startup', + label: 'settings.minimizeToTray', + hint: 'settings.minimizeToTrayHint', + } as ToggleDef, + + // ── Console ─────────────────────────────────────────────────────────────── + consoleFontSize: { + type: 'range', + default: 13, + section: 'console', + group: 'settings.console', + label: 'settings.fontSize', + hint: 'settings.fontSizeHint', + min: 10, + max: 20, + step: 1, + unit: 'px', + } as RangeDef, + + consoleLineNumbers: { + type: 'toggle', + default: false, + section: 'console', + group: 'settings.console', + label: 'settings.lineNumbers', + hint: 'settings.lineNumbersHint', + } as ToggleDef, + + consoleTimestamps: { + type: 'toggle', + default: false, + section: 'console', + group: 'settings.console', + label: 'settings.timestamps', + hint: 'settings.timestampsHint', + } as ToggleDef, + + consoleWordWrap: { + type: 'toggle', + default: false, + section: 'console', + group: 'settings.console', + label: 'settings.wordWrap', + hint: 'settings.wordWrapHint', + } as ToggleDef, + + consoleMaxLines: { + type: 'number', + default: 5000, + section: 'console', + group: 'settings.console', + label: 'settings.maxLines', + hint: 'settings.maxLinesHint', + min: 500, + max: 50000, + step: 500, + } as NumberDef, + + consoleHistorySize: { + type: 'number', + default: 200, + section: 'console', + group: 'settings.console', + label: 'settings.historySize', + hint: 'settings.historySizeHint', + min: 10, + max: 2000, + step: 10, + } as NumberDef, + + // ── Appearance (managed by ThemeProvider / I18nProvider) ────────────────── + themeId: { + type: 'text', + default: 'dark-default', + section: 'appearance', + group: 'settings.theme', + label: 'settings.theme', + } as TextDef, + + languageId: { + type: 'text', + default: 'en', + section: 'appearance', + group: 'settings.language', + label: 'settings.language', + } as TextDef, + + // ── Advanced › Dev Mode ─────────────────────────────────────────────────── + devModeEnabled: { + type: 'toggle', + default: false, + section: 'advanced', + group: 'settings.devMode', + label: 'settings.devModeLabel', + hint: 'settings.devModeHint', + } as ToggleDef, + + // ── Advanced › REST API ─────────────────────────────────────────────────── + restApiEnabled: { + type: 'toggle', + default: false, + section: 'advanced', + group: 'settings.restApi', + label: 'settings.restApiLabel', + hint: 'settings.restApiHint', + hintParams: { port: String(REST_API_CONFIG.defaultPort) }, + } as ToggleDef, + + restApiPort: { + type: 'number', + default: REST_API_CONFIG.defaultPort, + section: 'advanced', + group: 'settings.restApi', + label: 'settings.restApiPort', + hint: 'settings.restApiPortHint', + sub: true, + min: 1024, + max: 65535, + step: 1, + showWhen: (s: any) => s.restApiEnabled, + } as NumberDef, + + restApiNote: { + type: 'note', + section: 'advanced', + group: 'settings.restApi', + showWhen: (s: any) => s.restApiEnabled, + content: (s: any) => [ + `http://${REST_API_CONFIG.host}:${s.restApiPort}/api`, + '/status · /profiles · /processes · /logs · /settings', + ], + accentPattern: /https?:\/\/[^\s]+/, + } as NoteDef, +} satisfies Record; + +// ─── Derived exports ─────────────────────────────────────────────────────── + +export type AppSettings = InferSettings; +export const DEFAULT_SETTINGS = extractDefaults(SETTINGS_SCHEMA); diff --git a/src/main/shared/types/App.types.ts b/src/main/shared/types/App.types.ts index 0b5a010..5522d08 100644 --- a/src/main/shared/types/App.types.ts +++ b/src/main/shared/types/App.types.ts @@ -1,20 +1,3 @@ -export interface AppSettings { - launchOnStartup: boolean; - startMinimized: boolean; - minimizeToTray: boolean; - consoleFontSize: number; - consoleMaxLines: number; - consoleWordWrap: boolean; - consoleLineNumbers: boolean; - consoleTimestamps: boolean; - consoleHistorySize: number; - themeId: string; - languageId: string; - restApiEnabled: boolean; - restApiPort: number; - devModeEnabled: boolean; -} - export interface JRCEnvironment { isReady: boolean; devMode: boolean; diff --git a/src/main/shared/types/Settings.types.ts b/src/main/shared/types/Settings.types.ts new file mode 100644 index 0000000..bdcc2ae --- /dev/null +++ b/src/main/shared/types/Settings.types.ts @@ -0,0 +1,73 @@ +// ─── Field definition types ──────────────────────────────────────────────── + +type BaseField = { + section: string; // valid values: see SettingSection in Settings.config.ts + group: string; + label: string; + hint?: string; + hintParams?: Record; + sub?: boolean; + showWhen?: (s: any) => boolean; + disabledWhen?: (s: any) => boolean; +}; + +export type ToggleDef = BaseField & { type: 'toggle'; default: boolean }; +export type NumberDef = BaseField & { + type: 'number'; + default: number; + min: number; + max: number; + step: number; +}; +export type RangeDef = BaseField & { + type: 'range'; + default: number; + min: number; + max: number; + step: number; + unit?: string; +}; +export type TextDef = BaseField & { type: 'text'; default: string }; +export type NoteDef = { + type: 'note'; + section: string; + group: string; + showWhen?: (s: any) => boolean; + content: (s: any) => string[]; + accentPattern?: RegExp; +}; + +export type SettingFieldDef = ToggleDef | NumberDef | RangeDef | TextDef; +export type AnyFieldDef = SettingFieldDef | NoteDef; + +// ─── Sidebar topic ───────────────────────────────────────────────────────── +// Generic so Settings.config can bind S = SettingSection without a circular dep. + +export type SettingSidebarTopic = { + id: S; + label: string; +}; + +// ─── Inference helpers ───────────────────────────────────────────────────── + +export type InferSettings> = { + [K in keyof Schema as Schema[K] extends NoteDef ? never : K]: Schema[K] extends ToggleDef + ? boolean + : Schema[K] extends NumberDef | RangeDef + ? number + : Schema[K] extends TextDef + ? string + : never; +}; + +export function extractDefaults>( + schema: Schema +): InferSettings { + const result: Record = {}; + for (const [key, field] of Object.entries(schema)) { + if (field.type !== 'note') { + result[key] = (field as SettingFieldDef).default; + } + } + return result as InferSettings; +} diff --git a/src/main/shared/types/Sidebar.types.ts b/src/main/shared/types/Sidebar.types.ts deleted file mode 100644 index d191026..0000000 --- a/src/main/shared/types/Sidebar.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface SidebarTopic { - id: string; - label: string; -} diff --git a/src/renderer/AppProvider.tsx b/src/renderer/AppProvider.tsx index 2c41efe..d3f003c 100644 --- a/src/renderer/AppProvider.tsx +++ b/src/renderer/AppProvider.tsx @@ -1,4 +1,4 @@ -import { AppSettings } from '@shared/types/App.types'; +import { AppSettings } from '@shared/config/Settings.config'; import { ConsoleLine, ProcessState } from '@shared/types/Process.types'; import { Profile } from '@shared/types/Profile.types'; import { diff --git a/src/renderer/components/layout/navigation/SidebarLayout.tsx b/src/renderer/components/layout/navigation/SidebarLayout.tsx index 9860a58..93480e0 100644 --- a/src/renderer/components/layout/navigation/SidebarLayout.tsx +++ b/src/renderer/components/layout/navigation/SidebarLayout.tsx @@ -1,10 +1,10 @@ -import type { SidebarTopic } from '@shared/types/Sidebar.types'; +import type { SettingSidebarTopic } from '@shared/types/Settings.types'; import React from 'react'; -export type { SidebarTopic }; +export type { SettingSidebarTopic }; interface Props { - topics: SidebarTopic[]; + topics: SettingSidebarTopic[]; activeTopicId: string; onTopicChange: (id: string) => void; children: React.ReactNode; diff --git a/src/renderer/components/layout/navigation/index.ts b/src/renderer/components/layout/navigation/index.ts index d7489b6..be77eea 100644 --- a/src/renderer/components/layout/navigation/index.ts +++ b/src/renderer/components/layout/navigation/index.ts @@ -1,5 +1,5 @@ export { PanelHeader } from './PanelHeader'; export { SidebarLayout } from './SidebarLayout'; -export type { SidebarTopic } from './SidebarLayout'; +export type { SettingSidebarTopic } from './SidebarLayout'; export { TabBar } from './TabBar'; export type { Tab } from './TabBar'; diff --git a/src/renderer/components/settings/SettingsSectionRenderer.tsx b/src/renderer/components/settings/SettingsSectionRenderer.tsx new file mode 100644 index 0000000..99b9a49 --- /dev/null +++ b/src/renderer/components/settings/SettingsSectionRenderer.tsx @@ -0,0 +1,147 @@ +import type { AppSettings } from '@shared/config/Settings.config'; +import { SETTINGS_SCHEMA } from '@shared/config/Settings.config'; +import type { AnyFieldDef, NoteDef, NumberDef, RangeDef, ToggleDef } from '@shared/types/Settings.types'; +import React, { useMemo } from 'react'; +import { useTranslation } from '../../i18n/I18nProvider'; +import type { TranslationKey } from '../../i18n/TranslationKeys'; +import { Toggle } from '../common/inputs'; +import { Section } from '../layout/containers'; +import { NumInput, Row } from './SettingsRow'; + +type SetFn = (patch: Partial) => void; + +// ─── Note renderer ───────────────────────────────────────────────────────── + +function renderLine(line: string, pattern?: RegExp): React.ReactNode { + if (!pattern) return line; + const match = pattern.exec(line); + if (!match) return line; + return ( + <> + {line.slice(0, match.index)} + {match[0]} + {line.slice(match.index + match[0].length)} + + ); +} + +// ─── Single field ────────────────────────────────────────────────────────── + +function SettingsField({ + fieldKey, + field, + draft, + set, +}: { + fieldKey: string; + field: AnyFieldDef; + draft: AppSettings; + set: SetFn; +}) { + const { t } = useTranslation(); + const s = draft as Record; + + if (field.showWhen && !field.showWhen(s)) return null; + + if (field.type === 'note') { + const note = field as NoteDef; + return ( +
+ {note.content(s).map((line, i) => ( +

+ {renderLine(line, note.accentPattern)} +

+ ))} +
+ ); + } + + const disabled = field.disabledWhen?.(s) ?? false; + const label = t(field.label as TranslationKey); + const hint = field.hint ? t(field.hint as TranslationKey, field.hintParams) : undefined; + + return ( + + {field.type === 'toggle' && ( + set({ [fieldKey]: v } as Partial)} + disabled={disabled} + /> + )} + + {field.type === 'number' && (() => { + const f = field as NumberDef; + return ( + set({ [fieldKey]: v } as Partial)} + /> + ); + })()} + + {field.type === 'range' && (() => { + const f = field as RangeDef; + const val = s[fieldKey] as number; + return ( +
+ + set({ [fieldKey]: Number(e.target.value) } as Partial) + } + className="w-24 accent-accent cursor-pointer" + /> + + {val} + {f.unit ?? ''} + +
+ ); + })()} +
+ ); +} + +// ─── Section renderer ────────────────────────────────────────────────────── + +export function SettingsSectionRenderer({ + section, + draft, + set, +}: { + section: string; + draft: AppSettings; + set: SetFn; +}) { + const { t } = useTranslation(); + + const groups = useMemo(() => { + const map = new Map(); + for (const [key, field] of Object.entries(SETTINGS_SCHEMA)) { + if (field.section !== section) continue; + if (!map.has(field.group!)) map.set(field.group!, []); + map.get(field.group!)!.push([key, field]); + } + return map; + }, [section]); + + return ( + <> + {[...groups.entries()].map(([groupKey, fields]) => ( +
+ {fields.map(([key, field]) => ( + + ))} +
+ ))} + + ); +} diff --git a/src/renderer/components/settings/SettingsTab.tsx b/src/renderer/components/settings/SettingsTab.tsx index 04126e8..565382b 100644 --- a/src/renderer/components/settings/SettingsTab.tsx +++ b/src/renderer/components/settings/SettingsTab.tsx @@ -1,5 +1,5 @@ import { SETTINGS_TOPICS } from '@shared/config/Settings.config'; -import { AppSettings } from '@shared/types/App.types'; +import { AppSettings } from '@shared/config/Settings.config'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useApp } from '../../AppProvider'; import { useTranslation } from '../../i18n/I18nProvider'; diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx index ee66146..5045466 100644 --- a/src/renderer/components/settings/sections/AdvancedSection.tsx +++ b/src/renderer/components/settings/sections/AdvancedSection.tsx @@ -1,8 +1,5 @@ -import { REST_API_CONFIG } from '@shared/config/API.config'; -import { AppSettings } from '@shared/types/App.types'; -import { useTranslation } from '../../../i18n/I18nProvider'; -import { Toggle } from '../../common/inputs'; -import { NumInput, Row, Section } from '../SettingsRow'; +import type { AppSettings } from '@shared/config/Settings.config'; +import { SettingsSectionRenderer } from '../SettingsSectionRenderer'; interface Props { draft: AppSettings; @@ -10,47 +7,5 @@ interface Props { } export function AdvancedSection({ draft, set }: Props) { - const { t } = useTranslation(); - return ( - <> -
- - set({ devModeEnabled: v })} /> - -
- -
- - set({ restApiEnabled: v })} /> - - {draft.restApiEnabled && ( - - set({ restApiPort: v })} - /> - - )} - {draft.restApiEnabled && ( -
-

- {t('settings.listeningOn')}{' '} - - http://{REST_API_CONFIG.host}:{draft.restApiPort}/api - -

-

- {t('settings.endpoints')}: /status · /profiles · /processes · /logs · /settings -

-
- )} -
- - ); + return ; } diff --git a/src/renderer/components/settings/sections/ConsoleSection.tsx b/src/renderer/components/settings/sections/ConsoleSection.tsx index cb4036e..fda890b 100644 --- a/src/renderer/components/settings/sections/ConsoleSection.tsx +++ b/src/renderer/components/settings/sections/ConsoleSection.tsx @@ -1,7 +1,5 @@ -import { AppSettings } from '@shared/types/App.types'; -import { useTranslation } from '../../../i18n/I18nProvider'; -import { Toggle } from '../../common/inputs'; -import { NumInput, Row, Section } from '../SettingsRow'; +import type { AppSettings } from '@shared/config/Settings.config'; +import { SettingsSectionRenderer } from '../SettingsSectionRenderer'; interface Props { draft: AppSettings; @@ -9,55 +7,5 @@ interface Props { } export function ConsoleSection({ draft, set }: Props) { - const { t } = useTranslation(); - - return ( -
- -
- set({ consoleFontSize: Number(e.target.value) })} - className="w-24 accent-accent cursor-pointer" - /> - - {draft.consoleFontSize}px - -
-
- - set({ consoleLineNumbers: v })} - /> - - - set({ consoleTimestamps: v })} /> - - - set({ consoleWordWrap: v })} /> - - - set({ consoleMaxLines: v })} - /> - - - set({ consoleHistorySize: v })} - /> - -
- ); + return ; } diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index bec261b..409fe43 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -1,7 +1,5 @@ -import { AppSettings } from '@shared/types/App.types'; -import { useTranslation } from '../../../i18n/I18nProvider'; -import { Toggle } from '../../common/inputs'; -import { Row, Section } from '../SettingsRow'; +import type { AppSettings } from '@shared/config/Settings.config'; +import { SettingsSectionRenderer } from '../SettingsSectionRenderer'; interface Props { draft: AppSettings; @@ -9,25 +7,5 @@ interface Props { } export function GeneralSection({ draft, set }: Props) { - const { t } = useTranslation(); - - return ( - <> -
- - set({ launchOnStartup: v })} /> - - - set({ startMinimized: v })} - disabled={!draft.launchOnStartup} - /> - - - set({ minimizeToTray: v })} /> - -
- - ); + return ; }