205 lines
8.2 KiB
TypeScript
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
|
|
}
|