137 lines
4.9 KiB
TypeScript
137 lines
4.9 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.
|
|
*/
|
|
export type DiffMode = 'original' | 'updated' | 'diff'
|
|
|
|
export interface HelderConfig {
|
|
ai: { command: string; autoLaunch: boolean }
|
|
editor: { autoSave: boolean; tabSize: number }
|
|
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode }
|
|
files: { exclude: string[]; followGitignore: boolean }
|
|
terminal: { shell: string | null }
|
|
session: { restoreOnLaunch: boolean }
|
|
}
|
|
|
|
export const DEFAULTS: HelderConfig = {
|
|
ai: { command: 'claude', autoLaunch: true },
|
|
editor: { autoSave: false, tabSize: 4 },
|
|
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff' },
|
|
files: { exclude: [], followGitignore: true },
|
|
terminal: { shell: null },
|
|
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: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
|
|
/* --code-size: 13px; */ /* editor font size */
|
|
/* --term-size: 12.5px; */ /* terminal font size */
|
|
|
|
/* Example accent override: */
|
|
/* --accent: #4d8dff; */
|
|
}
|
|
`
|
|
|
|
/** Recently-opened files, newest first. Local machine state — git-ignored. */
|
|
const RECENT_FILE = 'recent.json'
|
|
const MAX_RECENT = 100
|
|
const GITIGNORE_BODY = `# Helder — local, machine-specific state (do not commit)\n${RECENT_FILE}\n`
|
|
|
|
let current: HelderConfig = DEFAULTS
|
|
let themeCss = ''
|
|
|
|
/** Create `.helder/.gitignore` (ignoring recent.json) only when it's missing. */
|
|
async function ensureGitignore(dir: string): Promise<void> {
|
|
const path = join(dir, '.gitignore')
|
|
try {
|
|
await readFile(path, 'utf8')
|
|
} catch {
|
|
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
|
|
}
|