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 { 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 { 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 { 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 { return !!v && typeof v === 'object' && !Array.isArray(v) } function deepMerge(base: T, over: unknown): T { if (!isPlainObject(base) || !isPlainObject(over)) return base const out: Record = { ...base } for (const key of Object.keys(over)) { const b = (base as Record)[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 { 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 }