Files
helder/src/main/config.ts
Jonathan van Rij 1ebb9a0e74
Some checks failed
CI / check (push) Has been cancelled
fixing lines overlapping
2026-09-10 09:57:06 +02:00

205 lines
8.2 KiB
TypeScript

import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
/**
* Project-scoped settings, living in `.helder/` in the opened project's root.
* - config.default.json full built-in defaults, REGENERATED on every launch
* (live documentation; the app never reads user edits here)
* - config.json sparse — only user-overridden values
* - theme.css custom CSS over the built-in dark theme; the CODE FONT
* and FONT SIZE live here (as CSS vars), not in the JSON
* Effective value = config.json over config.default.json, merged key by key.
*/
/** View a changed file opens in. 'diff' is the full-screen side-by-side overlay. */
export type DiffMode = 'original' | 'updated' | 'diff'
/** Soft wrap of long lines: never, always, or only in Markdown files. */
export type WordWrap = 'off' | 'on' | 'markdown'
/** xterm's colour table. Every value is a CSS colour; the selection entries
* may carry alpha, the rest may not. */
export interface TerminalTheme {
background: string; foreground: string; cursor: string; cursorAccent: string
selectionBackground: string; selectionInactiveBackground: string
black: string; red: string; green: string; yellow: string
blue: string; magenta: string; cyan: string; white: string
brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string
brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string
}
export interface HelderConfig {
ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean }
terminal: {
/** Login shell for both panes. null = $SHELL. */
shell: string | null
/** null = follow the CSS vars (--code-font / --term-size in theme.css). */
fontFamily: string | null
fontSize: number | null
lineHeight: number
letterSpacing: number
cursorStyle: 'bar' | 'block' | 'underline'
cursorBlink: boolean
/** Paint bold text in the bright colour. Off keeps bold in its own hue,
* which stops a CLI's bold labels from washing out. */
boldIsBright: boolean
scrollback: number
/** macOS: send Option as Meta. Needed for a CLI's ⌥↵ binding; it also stops
* Option from typing accented characters, so it is off by default. */
optionIsMeta: boolean
theme: TerminalTheme
}
session: { restoreOnLaunch: boolean }
}
export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'updated', refreshInterval: 10000 },
files: { exclude: [], followGitignore: false },
terminal: {
shell: null,
fontFamily: null,
fontSize: null,
lineHeight: 1.7,
letterSpacing: 0,
cursorStyle: 'bar',
cursorBlink: true,
boldIsBright: false,
scrollback: 8000,
optionIsMeta: false,
// The app's own palette: the editor ground, amber on the caret and the
// selection, and the six muted syntax colours on the ANSI table, so a diff
// in the terminal reads like a diff in the editor. Red is amber-deep and
// green is teal — the same pair the diff views use. A terminal still needs
// eight distinguishable slots, so blue and cyan take two cool tones that
// stay inside the muted register.
theme: {
background: '#101720',
foreground: '#E4E7E6',
cursor: '#E8913A',
cursorAccent: '#101720',
selectionBackground: 'rgba(232,145,58,0.22)',
selectionInactiveBackground: 'rgba(232,145,58,0.12)',
black: '#232C39', red: '#C4741F', green: '#8FBFB4', yellow: '#F0B476',
blue: '#8FA9C4', magenta: '#C3A6CE', cyan: '#8FC4C4', white: '#BAC0C0',
brightBlack: '#6C7783', brightRed: '#E8913A', brightGreen: '#A6D2C7', brightYellow: '#F5C79A',
brightBlue: '#A9BFD6', brightMagenta: '#D6BFDF', brightCyan: '#A9D6D6', brightWhite: '#F4F5F4',
},
},
session: { restoreOnLaunch: true },
}
const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in dark theme.
* This file is created once and never overwritten; edit it freely.
* The code font and font size live here (not in config.json). Uncomment and
* tweak any variable below; you can also override any --token from the built-in
* theme (see the design tokens in the app's styles). */
:root {
/* Code surfaces (editor + terminals) */
/* --code-font: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
/* --code-size: 13px; */ /* editor font size */
/* --term-size: 12px; */ /* terminal font size, unless config.json sets one */
/* The terminal's colours, cursor and scrollback live in config.json, under
"terminal" — the palette is JS options, not CSS, because xterm paints to a
canvas. Edit either file and the running terminals restyle themselves. */
/* Example accent override: */
/* --accent: #E8913A; */
}
`
/** Recently-opened files, newest first. Local machine state — git-ignored. */
const RECENT_FILE = 'recent.json'
const MAX_RECENT = 100
// The whole .helder folder is local, machine-specific state — ignore all of it.
const GITIGNORE_BODY = `# Helder — local, machine-specific state (do not commit).\n*\n`
let current: HelderConfig = DEFAULTS
let themeCss = ''
/** Ensure `.helder/.gitignore` ignores the entire folder; (re)write it when the
* file is missing or out of date (e.g. upgrading from the old recent-only one). */
async function ensureGitignore(dir: string): Promise<void> {
const path = join(dir, '.gitignore')
try {
if ((await readFile(path, 'utf8')) === GITIGNORE_BODY) return
} catch {
/* missing — fall through to write */
}
await writeFile(path, GITIGNORE_BODY)
}
export async function getRecent(root: string): Promise<string[]> {
try {
const arr = JSON.parse(await readFile(join(root, '.helder', RECENT_FILE), 'utf8'))
return Array.isArray(arr) ? arr.filter((p): p is string => typeof p === 'string').slice(0, MAX_RECENT) : []
} catch {
return []
}
}
export async function setRecent(root: string, list: string[]): Promise<void> {
try {
const dir = join(root, '.helder')
await mkdir(dir, { recursive: true })
await ensureGitignore(dir)
await writeFile(join(dir, RECENT_FILE), JSON.stringify(list.slice(0, MAX_RECENT), null, 2) + '\n')
} catch {
/* read-only / inaccessible root — recents just won't persist */
}
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object' && !Array.isArray(v)
}
function deepMerge<T>(base: T, over: unknown): T {
if (!isPlainObject(base) || !isPlainObject(over)) return base
const out: Record<string, unknown> = { ...base }
for (const key of Object.keys(over)) {
const b = (base as Record<string, unknown>)[key]
const o = over[key]
if (isPlainObject(b) && isPlainObject(o)) out[key] = deepMerge(b, o)
else if (o !== undefined) out[key] = o
}
return out as T
}
/** (Re)resolve config + theme for a project root, regenerating the defaults file. */
export async function resolveConfig(root: string): Promise<void> {
const dir = join(root, '.helder')
try {
await mkdir(dir, { recursive: true })
// Always regenerate the defaults file — it documents every setting.
await writeFile(join(dir, 'config.default.json'), JSON.stringify(DEFAULTS, null, 2) + '\n')
let override: unknown = {}
try { override = JSON.parse(await readFile(join(dir, 'config.json'), 'utf8')) } catch { /* none / invalid */ }
current = deepMerge(DEFAULTS, override)
try {
themeCss = await readFile(join(dir, 'theme.css'), 'utf8')
} catch {
themeCss = THEME_TEMPLATE
await writeFile(join(dir, 'theme.css'), THEME_TEMPLATE)
}
await ensureGitignore(dir)
} catch {
// Read-only / inaccessible root: fall back to built-in defaults.
current = DEFAULTS
themeCss = ''
}
}
export function getConfig(): HelderConfig {
return current
}
export function getThemeCss(): string {
return themeCss
}