init helder

This commit is contained in:
2026-06-15 22:33:46 +02:00
parent 3d77fdfeff
commit 3f5078841d
38 changed files with 6349 additions and 2083 deletions
+93
View File
@@ -0,0 +1,93 @@
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 interface HelderConfig {
ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number }
git: { confirmDiscard: boolean }
terminal: { shell: string | null }
}
export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 },
git: { confirmDiscard: true },
terminal: { shell: null },
}
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; */
}
`
let current: HelderConfig = DEFAULTS
let themeCss = ''
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)
}
} 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
}
+117
View File
@@ -0,0 +1,117 @@
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
export interface FileNode {
name: string
type: 'dir' | 'file'
path: string
open?: boolean
children?: FileNode[]
}
/** Directories never walked — noise or huge, and not part of "the project". */
const IGNORE_DIRS = new Set([
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
])
const MAX_FILE_BYTES = 300_000
const MAX_INDEXED_FILES = 6000
function ignored(name: string): boolean {
return IGNORE_DIRS.has(name) || name === '.DS_Store'
}
/** Recursive project tree, dirs first then files, alphabetical. */
export async function readTree(root: string): Promise<FileNode> {
const name = root.split(sep).filter(Boolean).pop() || root
return { name, type: 'dir', path: '', open: true, children: await readDir(root, root, 0) }
}
async function readDir(abs: string, root: string, depth: number): Promise<FileNode[]> {
let entries: import('node:fs').Dirent[]
try {
entries = await readdir(abs, { withFileTypes: true })
} catch {
return []
}
const dirs: FileNode[] = []
const files: FileNode[] = []
for (const e of entries) {
if (ignored(e.name)) continue
const childAbs = join(abs, e.name)
const rel = relative(root, childAbs).split(sep).join('/')
if (e.isDirectory()) {
dirs.push({
name: e.name, type: 'dir', path: rel, open: depth < 1,
children: depth < 12 ? await readDir(childAbs, root, depth + 1) : [],
})
} else if (e.isFile()) {
files.push({ name: e.name, type: 'file', path: rel })
}
}
dirs.sort((a, b) => a.name.localeCompare(b.name))
files.sort((a, b) => a.name.localeCompare(b.name))
return [...dirs, ...files]
}
function looksBinary(buf: Buffer): boolean {
const n = Math.min(buf.length, 8000)
for (let i = 0; i < n; i++) if (buf[i] === 0) return true
return false
}
/** Read a single text file (relative path) → string. */
export async function readProjectFile(root: string, rel: string): Promise<string> {
const buf = await readFile(join(root, rel))
if (looksBinary(buf)) return ''
return buf.toString('utf8')
}
/** Write a text file (relative path). Used by the editable buffer's save. */
export async function writeProjectFile(root: string, rel: string, content: string): Promise<void> {
await writeFile(join(root, rel), content, 'utf8')
}
/**
* Build an in-memory content index of all (small, text) files — powers content
* search and plain-file viewing without touching disk per keystroke. Capped to
* keep large repos sane. PHASE: swap content search to ripgrep when scaling up.
*/
export async function readAll(root: string): Promise<Record<string, string>> {
const out: Record<string, string> = {}
let count = 0
async function walk(abs: string): Promise<void> {
if (count >= MAX_INDEXED_FILES) return
let entries: import('node:fs').Dirent[]
try {
entries = await readdir(abs, { withFileTypes: true })
} catch {
return
}
for (const e of entries) {
if (count >= MAX_INDEXED_FILES) return
if (ignored(e.name)) continue
const childAbs = join(abs, e.name)
if (e.isDirectory()) {
await walk(childAbs)
} else if (e.isFile()) {
try {
const s = await stat(childAbs)
if (s.size > MAX_FILE_BYTES) continue
const buf = await readFile(childAbs)
if (looksBinary(buf)) continue
const rel = relative(root, childAbs).split(sep).join('/')
out[rel] = buf.toString('utf8')
count++
} catch {
/* skip unreadable */
}
}
}
}
await walk(root)
return out
}
+108
View File
@@ -0,0 +1,108 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { simpleGit, type SimpleGit } from 'simple-git'
export type GitStatusLetter = 'A' | 'M' | 'D' | 'R' | 'U'
export interface GitChange {
path: string
status: GitStatusLetter
staged: boolean
original: string
updated: string
}
export interface GitLoad {
branch: string
changes: GitChange[]
}
function git(root: string): SimpleGit {
return simpleGit({ baseDir: root, maxConcurrentProcesses: 4 })
}
/** Map a porcelain code pair to our display letter + staged flag. */
function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } {
const staged = index !== ' ' && index !== '?'
const code = staged ? index : working
let letter: GitStatusLetter
switch (code) {
case 'A': case 'C': case '?': letter = 'A'; break
case 'D': letter = 'D'; break
case 'R': letter = 'R'; break
case 'U': letter = 'M'; break
case 'M': default: letter = 'M'; break
}
return { letter, staged }
}
async function headText(g: SimpleGit, path: string): Promise<string> {
try {
return await g.show([`HEAD:${path}`])
} catch {
return ''
}
}
async function diskText(root: string, path: string): Promise<string> {
try {
const buf = await readFile(join(root, path))
// skip obvious binaries
for (let i = 0; i < Math.min(buf.length, 8000); i++) if (buf[i] === 0) return ''
return buf.toString('utf8')
} catch {
return ''
}
}
export async function isRepo(root: string): Promise<boolean> {
try {
return await git(root).checkIsRepo()
} catch {
return false
}
}
export async function load(root: string): Promise<GitLoad | null> {
const g = git(root)
if (!(await isRepo(root))) return null
const status = await g.status()
const branch = status.current || 'HEAD'
const changes: GitChange[] = []
for (const f of status.files) {
// simple-git uses path "from -> to" for renames; take the destination.
const path = f.path.includes(' -> ') ? f.path.split(' -> ').pop()! : f.path
const { letter, staged } = classify(f.index, f.working_dir)
const isNew = f.index === '?' || f.index === 'A'
const isDeleted = letter === 'D'
const original = isNew ? '' : await headText(g, path)
const updated = isDeleted ? '' : await diskText(root, path)
changes.push({ path, status: letter, staged, original, updated })
}
return { branch, changes }
}
export async function stage(root: string, paths: string[]): Promise<void> {
// `git add` stages modifications, additions AND deletions of the given paths.
await git(root).add(paths)
}
export async function unstage(root: string, paths: string[]): Promise<void> {
try {
await git(root).reset(['--', ...paths])
} catch {
// empty repo (no HEAD yet): fall back to removing from the index.
await git(root).raw(['rm', '--cached', '-r', '--', ...paths])
}
}
export async function commit(root: string, message: string): Promise<void> {
await git(root).commit(message)
}
export async function discard(root: string, paths: string[]): Promise<void> {
await git(root).checkout(['--', ...paths])
}
+141
View File
@@ -0,0 +1,141 @@
import { join, sep } from 'node:path'
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { watch, type FSWatcher } from 'chokidar'
import { getName, getRoot, openDialog } from './project'
import { readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { commit, discard, load, stage, unstage } from './git-service'
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
import { getConfig, getThemeCss, resolveConfig } from './config'
import { listFiles, searchContent } from './search-service'
const isDev = !!process.env['ELECTRON_RENDERER_URL']
const isMac = process.platform === 'darwin'
const WATCH_IGNORE = new Set([
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
])
let watcher: FSWatcher | null = null
let configWatcher: FSWatcher | null = null
let watchTimer: ReturnType<typeof setTimeout> | null = null
function broadcast(channel: string): void {
for (const w of BrowserWindow.getAllWindows()) w.webContents.send(channel)
}
function startConfigWatcher(): void {
if (configWatcher) { configWatcher.close(); configWatcher = null }
const root = getRoot()
if (!root) return
const dir = join(root, '.helder')
configWatcher = watch([join(dir, 'config.json'), join(dir, 'theme.css')], { ignoreInitial: true })
const reload = (): void => { resolveConfig(root).then(() => broadcast('config:changed')).catch(() => {}) }
configWatcher.on('add', reload).on('change', reload).on('unlink', reload)
}
function startWatcher(): void {
if (watcher) { watcher.close(); watcher = null }
const root = getRoot()
if (!root) return
watcher = watch(root, {
ignoreInitial: true,
ignored: (p: string) => p.split(sep).some((seg) => WATCH_IGNORE.has(seg)),
})
const ping = (): void => {
if (watchTimer) clearTimeout(watchTimer)
watchTimer = setTimeout(() => broadcast('project:changed'), 250)
}
watcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping)
}
function registerIpc(): void {
ipcMain.handle('project:current', () => ({ root: getRoot(), name: getName() }))
ipcMain.handle('project:open', async (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
const next = await openDialog(win)
if (next) {
await resolveConfig(getRoot())
startWatcher()
startConfigWatcher()
}
return { root: getRoot(), name: getName() }
})
ipcMain.handle('fs:tree', () => readTree(getRoot()))
ipcMain.handle('fs:files', () => readAll(getRoot()))
ipcMain.handle('fs:read', (_e, rel: string) => readProjectFile(getRoot(), rel))
ipcMain.handle('fs:write', (_e, rel: string, content: string) => writeProjectFile(getRoot(), rel, content))
ipcMain.handle('git:load', () => load(getRoot()))
ipcMain.handle('git:stage', (_e, paths: string[]) => stage(getRoot(), paths))
ipcMain.handle('git:unstage', (_e, paths: string[]) => unstage(getRoot(), paths))
ipcMain.handle('git:commit', (_e, message: string) => commit(getRoot(), message))
ipcMain.handle('git:discard', (_e, paths: string[]) => discard(getRoot(), paths))
ipcMain.handle('pty:available', () => ptyAvailable())
ipcMain.handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows))
ipcMain.on('pty:write', (_e, id: number, data: string) => writePty(id, data))
ipcMain.on('pty:resize', (_e, id: number, cols: number, rows: number) => resizePty(id, cols, rows))
ipcMain.on('pty:kill', (_e, id: number) => killPty(id))
ipcMain.handle('config:get', () => getConfig())
ipcMain.handle('config:theme', () => getThemeCss())
ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query))
ipcMain.handle('search:files', () => listFiles(getRoot()))
}
function createWindow(): void {
const win = new BrowserWindow({
width: 1680,
height: 1040,
minWidth: 1100,
minHeight: 680,
show: false,
backgroundColor: '#16171a',
titleBarStyle: isMac ? 'hiddenInset' : 'default',
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
})
win.on('ready-to-show', () => win.show())
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
if (isDev) {
win.loadURL(process.env['ELECTRON_RENDERER_URL'] as string)
} else {
win.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(async () => {
app.setName('Helder')
registerIpc()
await resolveConfig(getRoot())
startWatcher()
startConfigWatcher()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (watcher) { watcher.close(); watcher = null }
if (configWatcher) { configWatcher.close(); configWatcher = null }
killAllPtys()
if (!isMac) app.quit()
})
app.on('before-quit', () => killAllPtys())
+45
View File
@@ -0,0 +1,45 @@
import { basename } from 'node:path'
import { existsSync, statSync } from 'node:fs'
import { dialog, BrowserWindow } from 'electron'
/**
* One project per window. The root is resolved (in order) from $HELDER_PROJECT,
* a directory passed on argv, or the process working directory — then it can be
* changed at runtime via the Open Folder dialog.
*/
function resolveInitialRoot(): string {
const envRoot = process.env.HELDER_PROJECT
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return envRoot
const argDir = process.argv.slice(1).find((a) => !a.startsWith('-') && existsSync(a) && safeIsDir(a))
if (argDir) return argDir
return process.cwd()
}
function safeIsDir(p: string): boolean {
try { return statSync(p).isDirectory() } catch { return false }
}
let root = resolveInitialRoot()
export function getRoot(): string {
return root
}
export function getName(): string {
return root ? basename(root) || root : 'no project'
}
export function setRoot(next: string): void {
root = next
}
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {
const res = win
? await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
: await dialog.showOpenDialog({ properties: ['openDirectory'] })
if (!res.canceled && res.filePaths[0]) {
root = res.filePaths[0]
return root
}
return null
}
+79
View File
@@ -0,0 +1,79 @@
import { createRequire } from 'node:module'
import type { WebContents } from 'electron'
import { getRoot } from './project'
import { getConfig } from './config'
/**
* Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the
* bottom pane is a plain shell. node-pty is a native module — loaded defensively
* so the app still launches (with a friendly message) if it wasn't rebuilt for
* this Electron via `npm run rebuild`.
*
* Shell + ai command/autoLaunch come from `.helder/config.json` (terminal.shell,
* ai.command, ai.autoLaunch) via the config module.
*/
const require = createRequire(import.meta.url)
type PtyModule = typeof import('node-pty')
let pty: PtyModule | null = null
try {
pty = require('node-pty') as PtyModule
} catch (e) {
console.error('[helder] node-pty unavailable — run `npm run rebuild`:', (e as Error).message)
}
const terms = new Map<number, import('node-pty').IPty>()
let seq = 0
function defaultShell(): string {
const configured = getConfig().terminal.shell
if (configured) return configured
if (process.platform === 'win32') return process.env.COMSPEC || 'powershell.exe'
return process.env.SHELL || '/bin/zsh'
}
export function ptyAvailable(): boolean {
return !!pty
}
export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number {
if (!pty) return -1
const cwd = getRoot() || process.env.HOME || process.cwd()
const proc = pty.spawn(defaultShell(), [], {
name: 'xterm-color',
cols: cols || 80,
rows: rows || 24,
cwd,
env: process.env as { [key: string]: string },
})
const id = ++seq
terms.set(id, proc)
proc.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) })
proc.onExit(() => { terms.delete(id); if (!sender.isDestroyed()) sender.send('pty:exit', { id }) })
const ai = getConfig().ai
if (kind === 'agent' && ai.autoLaunch) {
// small delay so the shell prompt is ready before we type the command
setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350)
}
return id
}
export function writePty(id: number, data: string): void {
terms.get(id)?.write(data)
}
export function resizePty(id: number, cols: number, rows: number): void {
try { terms.get(id)?.resize(cols, rows) } catch { /* race with exit */ }
}
export function killPty(id: number): void {
const p = terms.get(id)
if (p) { try { p.kill() } catch { /* already gone */ } terms.delete(id) }
}
export function killAllPtys(): void {
for (const p of terms.values()) { try { p.kill() } catch { /* noop */ } }
terms.clear()
}
+87
View File
@@ -0,0 +1,87 @@
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
import { relative, sep } from 'node:path'
/** Content search via ripgrep; file-name list via `rg --files`. Substring
* (fixed-string), smart-case — matching the prototype's search semantics. */
const require = createRequire(import.meta.url)
let rgPath: string | null = null
try {
rgPath = (require('@vscode/ripgrep') as { rgPath: string }).rgPath
} catch (e) {
console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message)
}
export interface ContentHit { no: number; ln: string; ix: number }
export interface ContentGroup { path: string; hits: ContentHit[] }
const IGNORE_GLOBS = ['node_modules', '.git', 'out', 'dist', 'build', '.cache', 'vendor', 'coverage', '.helder']
.flatMap((d) => ['--glob', `!${d}`])
const MAX_FILES = 400
const MAX_LINE = 1000
function toRel(root: string, p: string): string {
return relative(root, p).split(sep).join('/')
}
export function searchContent(root: string, query: string): Promise<ContentGroup[]> {
return new Promise((resolve) => {
if (!rgPath || query.trim().length < 2) return resolve([])
const child = spawn(rgPath, [
'--json', '--fixed-strings', '--smart-case',
'--max-count', '50', '--max-columns', '2000',
...IGNORE_GLOBS, '-e', query, '--', root,
])
const order: string[] = []
const groups = new Map<string, ContentGroup>()
let buf = ''
let done = false
const finish = (): void => { if (done) return; done = true; resolve(order.slice(0, MAX_FILES).map((p) => groups.get(p)!)) }
child.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString()
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
if (!line) continue
let msg: { type: string; data: { path?: { text?: string }; lines?: { text?: string }; line_number?: number; submatches?: { start: number }[] } }
try { msg = JSON.parse(line) } catch { continue }
if (msg.type !== 'match') continue
const abs = msg.data.path?.text
const text = msg.data.lines?.text
if (!abs || text == null) continue
const rel = toRel(root, abs)
let g = groups.get(rel)
if (!g) { if (groups.size >= MAX_FILES) continue; g = { path: rel, hits: [] }; groups.set(rel, g); order.push(rel) }
const ln = text.replace(/\n$/, '').slice(0, MAX_LINE)
const ix = msg.data.submatches && msg.data.submatches[0] ? msg.data.submatches[0].start : 0
g.hits.push({ no: msg.data.line_number || 0, ln, ix: Math.min(ix, ln.length) })
}
})
child.on('close', finish)
child.on('error', finish)
})
}
export function listFiles(root: string): Promise<string[]> {
return new Promise((resolve) => {
if (!rgPath) return resolve([])
const child = spawn(rgPath, ['--files', ...IGNORE_GLOBS, '--', root])
let buf = ''
const out: string[] = []
let done = false
const finish = (): void => { if (done) return; done = true; resolve(out) }
child.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString()
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
if (line) out.push(toRel(root, line))
}
})
child.on('close', () => { if (buf.trim()) out.push(toRel(root, buf.trim())); finish() })
child.on('error', finish)
})
}