handling files when stages and dirty at once

This commit is contained in:
2026-07-28 08:57:36 +02:00
parent d3bcdb74c2
commit 03e16d49a1
29 changed files with 1597 additions and 191 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ 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 }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null }
session: { restoreOnLaunch: boolean }
@@ -24,7 +24,7 @@ export interface HelderConfig {
export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: false },
terminal: { shell: null },
session: { restoreOnLaunch: true },
+188
View File
@@ -0,0 +1,188 @@
import { app, crashReporter, dialog, shell, BrowserWindow, type WebContents } from 'electron'
import { formatErr, getLogDir, getLogPath, initLogger, log, logger } from './logger'
/**
* Everything that turns a silent death into a log line. Wires the process-,
* app- and window-level failure hooks Electron gives us, none of which were
* connected before — which is why crashes left no trace.
*
* The hooks, and the crash each one actually catches:
* uncaughtException / unhandledRejection → a throw in OUR main-process code
* render-process-gone → the renderer died (OOM, segfault):
* the classic "window went blank//white"
* child-process-gone → GPU / utility process died
* preload-error → preload threw: `window.helder` is
* undefined and the app silently falls
* back to MOCK DATA (see CLAUDE.md)
* unresponsive → main thread wedged (the beachball)
* crashReporter minidumps → NATIVE crashes (node-pty is native,
* and a segfault there takes the whole
* process down with no JS hook at all)
*/
let fatalDialogOpen = false
/** Call FIRST, before app.whenReady() — crashReporter must start early to catch
* native crashes, and the log file should exist before anything can fail. */
export function initDiagnostics(isDev: boolean): void {
// app.getPath('logs') is ~/Library/Logs/<name> on macOS, so the name must be
// set before we ask for the path or the folder is called "Electron".
app.setName('Helder')
initLogger({ dir: app.getPath('logs'), mirror: isDev })
// Native minidumps for crashes no JS handler can see. Local only — nothing is
// uploaded anywhere (there is no server, and this is a personal tool).
try {
crashReporter.start({ productName: 'Helder', companyName: 'Helder', uploadToServer: false })
} catch (e) {
logger.warn('crash', 'crashReporter failed to start', { err: formatErr(e) })
}
logger.info('session', 'starting', {
version: app.getVersion(),
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
platform: `${process.platform} ${process.arch}`,
packaged: app.isPackaged,
dev: isDev,
crashDumps: app.getPath('crashDumps'),
argv: process.argv.slice(1),
project: process.env.HELDER_PROJECT ?? null,
})
installProcessHooks()
installAppHooks()
}
function installProcessHooks(): void {
process.on('uncaughtException', (err, origin) => {
logger.error('fatal', `uncaughtException (${origin})`, err)
showFatal(err)
})
process.on('unhandledRejection', (reason) => {
// Not fatal in itself, but it's how a forgotten `await` on a failing IPC
// handler shows up — and the stack here is the only place the cause exists.
logger.error('fatal', 'unhandledRejection', reason)
})
process.on('warning', (w) => {
// Surfaces the "MaxListenersExceeded" / deprecation warnings that precede
// a leak-driven crash.
logger.warn('node', w.name, { message: w.message, stack: w.stack })
})
app.on('before-quit', () => logger.info('session', 'quitting'))
}
function installAppHooks(): void {
// THE renderer-crash hook. `reason` is the useful bit: 'crashed', 'oom',
// 'killed', 'launch-failed'.
app.on('render-process-gone', (_e, contents, details) => {
logger.error('renderer', `render process gone: ${details.reason}`, undefined, {
exitCode: details.exitCode,
reason: details.reason,
url: safeUrl(contents),
})
if (details.reason !== 'clean-exit') {
showFatal(new Error(`The window crashed (${details.reason}, exit ${details.exitCode}). See the log for details.`))
}
})
app.on('child-process-gone', (_e, details) => {
logger.error('child', `${details.type} process gone: ${details.reason}`, undefined, {
exitCode: details.exitCode,
serviceName: details.serviceName,
name: details.name,
})
})
// A preload failure is silent-by-design in Electron and the single nastiest
// failure mode this app has: no window.helder → the renderer quietly serves
// mock data and "terminal not available", as if nothing were wrong.
app.on('web-contents-created', (_e, contents) => {
contents.on('preload-error', (_ev, preloadPath, error) => {
logger.error('preload', 'preload script threw — window.helder will be undefined (mock-data fallback)', error, { preloadPath })
})
contents.on('console-message', (...a: unknown[]) => {
// Electron ≥36 passes a single event object; older versions pass
// (event, level, message, line, sourceId). Support both so a version bump
// doesn't quietly stop capturing renderer console output.
const d = normaliseConsoleMessage(a)
if (!d || d.level < 2) return // warnings + errors only; skip log/info noise
log(d.level >= 3 ? 'error' : 'warn', 'console', d.message, { source: d.source, line: d.line })
})
})
}
/** Electron changed the console-message signature in v36; accept both shapes. */
export function normaliseConsoleMessage(args: unknown[]): { level: number; message: string; source: string; line: number } | null {
const first = args[0] as Record<string, unknown> | undefined
if (first && typeof first === 'object' && 'message' in first && 'level' in first) {
const lvl = first.level
const asNum = typeof lvl === 'string' ? { debug: 0, info: 1, verbose: 1, warning: 2, error: 3 }[lvl] ?? 1 : Number(lvl)
return {
level: asNum,
message: String(first.message),
source: String(first.sourceId ?? ''),
line: Number(first.lineNumber ?? 0),
}
}
if (args.length >= 3 && typeof args[1] === 'number') {
return { level: args[1] as number, message: String(args[2]), source: String(args[4] ?? ''), line: Number(args[3] ?? 0) }
}
return null
}
function safeUrl(contents: WebContents | null): string {
try { return contents?.getURL() ?? '' } catch { return '' }
}
/** Watch for the beachball: log it (with a stack-free note) rather than let the
* user guess whether the app is hung or just slow. */
export function watchWindow(win: BrowserWindow): void {
win.on('unresponsive', () => logger.warn('window', 'became unresponsive (main thread blocked)'))
win.on('responsive', () => logger.info('window', 'responsive again'))
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
logger.error('window', 'did-fail-load', undefined, { code, desc, url })
})
}
/**
* Tell the user something died, and put the log one click away — a crash the
* user can't report is a crash we can't fix. Guarded so a crash loop doesn't
* stack a hundred dialogs.
*/
function showFatal(err: unknown): void {
if (fatalDialogOpen) return
fatalDialogOpen = true
const { message } = formatErr(err)
const logPath = getLogPath()
Promise.resolve(dialog.showMessageBox({
type: 'error',
buttons: logPath ? ['Open Log', 'Ignore'] : ['Ignore'],
defaultId: 0,
cancelId: logPath ? 1 : 0,
message: 'Helder hit an error',
detail: `${message}\n\n${logPath ? `Logged to ${logPath}` : ''}`,
})).then(({ response }) => {
if (logPath && response === 0) openLog()
}).catch(() => { /* dialog can fail pre-ready; the log line is what matters */ })
.finally(() => { fatalDialogOpen = false })
}
/** Open the log in the default text editor. */
export function openLog(): void {
const p = getLogPath()
if (p) shell.openPath(p).catch(() => {})
}
/** Reveal the log folder (all rotated files + siblings) in Finder. */
export function revealLog(): void {
const p = getLogPath()
if (p) shell.showItemInFolder(p)
}
export { getLogDir, getLogPath }
+26
View File
@@ -202,6 +202,32 @@ export async function readProjectFile(root: string, rel: string): Promise<string
return buf.toString('utf8')
}
const IMAGE_MIME: Record<string, string> = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', jfif: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp',
ico: 'image/x-icon', avif: 'image/avif', apng: 'image/apng',
}
const MAX_IMAGE_BYTES = 25_000_000
/** Read an image file as a `data:` URL for the viewer's <img> — the renderer
* can't touch the filesystem, and a data URL sidesteps file:// path/escaping
* concerns entirely. Returns '' for a non-image extension, a path escaping the
* root, or an oversized/unreadable file. */
export async function readImageDataUrl(root: string, rel: string): Promise<string> {
const ext = rel.split('.').pop()?.toLowerCase() ?? ''
const mime = IMAGE_MIME[ext]
if (!mime) return ''
const target = join(root, rel)
if (relative(root, target).startsWith('..')) return ''
try {
const buf = await readFile(target)
if (buf.length > MAX_IMAGE_BYTES) return ''
return `data:${mime};base64,${buf.toString('base64')}`
} catch {
return ''
}
}
/** 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')
+67 -19
View File
@@ -76,19 +76,41 @@ async function git(root: string, args: string[]): Promise<string> {
return stdout
}
/** Map a porcelain code pair to our display letter + staged flag. */
export function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } {
const staged = index !== ' ' && index !== '?'
const code = staged ? index : working
let letter: GitStatusLetter
/** Map one porcelain status code to our display letter. */
function letterFor(code: string): 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
case 'A': case 'C': case '?': return 'A'
case 'D': return 'D'
case 'R': return 'R'
case 'U': return 'M'
default: return 'M'
}
return { letter, staged }
}
/** A merge conflict. Git reports both sides, but neither half can be staged on
* its own, so a conflict stays one row. */
function isConflict(index: string, working: string): boolean {
return index === 'U' || working === 'U'
|| (index === 'A' && working === 'A')
|| (index === 'D' && working === 'D')
}
export interface GitRowSpec { letter: GitStatusLetter; staged: boolean }
/**
* Split a porcelain code pair into the rows the git panel shows.
*
* A file can be staged AND changed again on disk. Git reports that as "MM".
* That is two rows: one staged (HEAD vs index) and one unstaged (index vs
* disk). Folding it into a single row hid the newer edit completely.
*/
export function classify(index: string, working: string): GitRowSpec[] {
if (isConflict(index, working)) return [{ letter: 'M', staged: true }]
const rows: GitRowSpec[] = []
if (index !== ' ' && index !== '?') rows.push({ letter: letterFor(index), staged: true })
if (working !== ' ') rows.push({ letter: letterFor(working), staged: false })
// Should not happen (git does not report a clean file), but never drop an entry.
return rows.length ? rows : [{ letter: letterFor(index), staged: true }]
}
/** Parse a `## ...` porcelain branch header into a display branch name. */
@@ -135,6 +157,15 @@ async function headText(root: string, path: string): Promise<string> {
}
}
/** The staged copy of a file: the blob sitting in the index. */
async function indexText(root: string, path: string): Promise<string> {
try {
return await git(root, ['show', `:${path}`])
} catch {
return ''
}
}
async function diskText(root: string, path: string): Promise<string> {
try {
const buf = await readFile(join(root, path))
@@ -214,14 +245,31 @@ async function doLoad(root: string): Promise<GitLoad | null> {
// re-reads ALL of them, which is the dominant cost of a reload. Run them with
// bounded concurrency instead so the spawns overlap (cap keeps us well under
// macOS's low default FD limit). Order is preserved by index.
const changes = await mapLimit(files, 12, async (f) => {
const { letter, staged } = classify(f.index, f.working)
const isNew = f.index === '?' || f.index === 'A'
const isDeleted = letter === 'D'
const original = isNew ? '' : await headText(root, f.path)
const updated = isDeleted ? '' : await diskText(root, f.path)
return { path: f.path, status: letter, staged, original, updated } as GitChange
})
const changes = (await mapLimit(files, 12, async (f) => {
const rows = classify(f.index, f.working)
const stagedRow = rows.find((r) => r.staged)
const workRow = rows.find((r) => !r.staged)
// The index blob is only needed when a file sits in BOTH groups. With one
// row the index copy equals HEAD (unstaged only) or the disk copy (staged
// only), so the common case still costs no extra `git show`.
const both = !!stagedRow && !!workRow
const idx = both ? await indexText(root, f.path) : ''
const out: GitChange[] = []
if (stagedRow) {
// Staged row: HEAD -> index.
const original = stagedRow.letter === 'A' ? '' : await headText(root, f.path)
const updated = stagedRow.letter === 'D' ? '' : both ? idx : await diskText(root, f.path)
out.push({ path: f.path, status: stagedRow.letter, staged: true, original, updated })
}
if (workRow) {
// Unstaged row: index -> disk. An untracked file has no index copy.
const untracked = f.index === '?'
const original = untracked ? '' : both ? idx : await headText(root, f.path)
const updated = workRow.letter === 'D' ? '' : await diskText(root, f.path)
out.push({ path: f.path, status: workRow.letter, staged: false, original, updated })
}
return out
})).flat()
return { branch, changes }
}
+141 -38
View File
@@ -4,15 +4,22 @@ import { spawn } from 'node:child_process'
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
import { watch, type FSWatcher } from 'chokidar'
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readImageDataUrl, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { commit, discard, load, push, stage, unstage } from './git-service'
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
import { listFiles, searchContent } from './search-service'
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
import { getLogPath, log, logger, type LogLevel } from './logger'
const isDev = !!process.env['ELECTRON_RENDERER_URL']
const isMac = process.platform === 'darwin'
// Before anything else can fail: start the crash reporter, open the log file and
// hook uncaughtException / render-process-gone / preload-error. Everything below
// (including a throw at module load) is logged from here on.
initDiagnostics(isDev)
const WATCH_IGNORE = new Set([
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
@@ -114,6 +121,7 @@ async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> {
startWatcher()
startConfigWatcher()
startGitWatcher()
syncWindowTitle()
return true
}
@@ -144,16 +152,20 @@ function buildAppMenu(): Menu {
{
label: 'View',
submenu: [
// ⌘R refreshes git status + the file explorer instead of reloading the
// window. We send the same "project changed" ping the disk watchers use,
// which makes the renderer re-read git + the file tree. Reload / Force
// Reload are intentionally omitted so ⌘R never blows away app state.
// ⌘R refreshes the three left columns instead of reloading the window:
// git status (Col A), the file explorer (Col B), and the open file in the
// viewer re-read from disk (Col C). We send a dedicated `view:refresh`
// ping — distinct from the disk watchers' `project:changed` — so only an
// explicit ⌘R force-reloads the viewer from disk; background watcher pings
// keep refreshing git + tree without blowing away the editor buffer.
// Reload / Force Reload are intentionally omitted so ⌘R never blows away
// app state.
{
label: 'Refresh',
accelerator: 'CmdOrCtrl+R',
click: (_m, win) => {
const bw = win instanceof BrowserWindow ? win : BrowserWindow.getFocusedWindow()
bw?.webContents.send('project:changed')
bw?.webContents.send('view:refresh')
},
},
{ type: 'separator' },
@@ -167,10 +179,28 @@ function buildAppMenu(): Menu {
],
},
{ role: 'windowMenu' },
{
role: 'help',
submenu: [
// A crash you can't read is a crash you can't fix — keep the log one
// click away rather than buried in ~/Library/Logs.
{ label: 'Open Log', click: () => openLog() },
{ label: 'Reveal Log in Finder', click: () => revealLog() },
],
},
]
return Menu.buildFromTemplate(template)
}
/** macOS Dock right-click menu. Sits above the system items (Show All Windows,
* Hide, Quit) that macOS appends itself. It mirrors File → New Window so a new
* project window is one right-click away, even with no window focused. */
function buildDockMenu(): Menu {
return Menu.buildFromTemplate([
{ label: 'New Window', click: () => spawnInstance() },
])
}
function startWatcher(): void {
if (watcher) { watcher.close(); watcher = null }
const root = getRoot()
@@ -186,54 +216,108 @@ function startWatcher(): void {
watcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping)
}
/**
* ipcMain.handle + logging. Every IPC failure used to die in a renderer-side
* `catch {}` that showed a generic toast ("Create failed") and dropped the
* actual cause, so the log records the channel, its args and the error, then
* RETHROWS so the renderer keeps behaving exactly as before.
*
* Slow calls get a line too: an FS/git handler blocking for seconds is the
* symptom that precedes a beachball, and it's invisible otherwise.
*/
const SLOW_MS = 1000
function handle(channel: string, fn: (e: Electron.IpcMainInvokeEvent, ...args: never[]) => unknown): void {
ipcMain.handle(channel, async (e, ...args) => {
const started = Date.now()
try {
const out = await fn(e, ...(args as never[]))
const ms = Date.now() - started
if (ms >= SLOW_MS) logger.warn('ipc', `${channel} slow`, { ms, args: previewArgs(args) })
return out
} catch (err) {
logger.error('ipc', `${channel} failed`, err, { args: previewArgs(args), ms: Date.now() - started })
throw err
}
})
}
/** Log-safe args: file CONTENT (fs:write) would swamp the log, so cap length. */
function previewArgs(args: unknown[]): unknown[] {
return args.map((a) => (typeof a === 'string' && a.length > 120 ? `${a.slice(0, 120)}… (${a.length} chars)` : a))
}
/** Same, for the fire-and-forget `ipcMain.on` channels. */
function on(channel: string, fn: (e: Electron.IpcMainEvent, ...args: never[]) => void): void {
ipcMain.on(channel, (e, ...args) => {
try {
fn(e, ...(args as never[]))
} catch (err) {
logger.error('ipc', `${channel} failed`, err, { args: previewArgs(args) })
}
})
}
function registerIpc(): void {
ipcMain.handle('project:current', () => ({ root: getRoot(), name: getName() }))
ipcMain.handle('project:open', async (e) => {
handle('project:current', () => ({ root: getRoot(), name: getName() }))
handle('project:open', async (e) => {
await openFolderFlow(BrowserWindow.fromWebContents(e.sender))
return { root: getRoot(), name: getName() }
})
ipcMain.handle('projects:recent', () => getRecentProjects())
ipcMain.handle('project:openPath', async (_e, path: string) => {
handle('projects:recent', () => getRecentProjects())
handle('project:openPath', async (_e, path: string) => {
setRoot(path)
const r = getRoot()
if (r) { await resolveConfig(r); startWatcher(); startConfigWatcher(); startGitWatcher() }
syncWindowTitle()
broadcast('project:changed')
return { root: getRoot(), name: getName() }
})
ipcMain.handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null })
ipcMain.handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
ipcMain.handle('fs:readDir', (_e, rel: string) => { const r = getRoot(); return r ? readDirChildren(r, rel) : [] })
ipcMain.handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' })
ipcMain.handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) })
ipcMain.handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
ipcMain.handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) })
ipcMain.handle('fs:mkdir', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectDir(r, rel) })
ipcMain.handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null })
handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
handle('fs:readDir', (_e, rel: string) => { const r = getRoot(); return r ? readDirChildren(r, rel) : [] })
handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' })
handle('fs:imageDataUrl', (_e, rel: string) => { const r = getRoot(); return r ? readImageDataUrl(r, rel) : '' })
handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) })
handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) })
handle('fs:mkdir', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectDir(r, rel) })
handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
ipcMain.handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
ipcMain.handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
ipcMain.handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) })
ipcMain.handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) })
ipcMain.handle('git:push', () => { const r = getRoot(); return r ? push(r) : { ok: false, message: 'No project open' } })
ipcMain.handle('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, paths) })
handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) })
handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) })
handle('git:push', () => { const r = getRoot(); return r ? push(r) : { ok: false, message: 'No project open' } })
handle('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, 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))
handle('pty:available', () => ptyAvailable())
handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows))
on('pty:write', (_e, id: number, data: string) => writePty(id, data))
on('pty:resize', (_e, id: number, cols: number, rows: number) => resizePty(id, cols, rows))
on('pty:kill', (_e, id: number) => killPty(id))
ipcMain.handle('config:get', () => getConfig())
ipcMain.handle('config:theme', () => getThemeCss())
handle('config:get', () => getConfig())
handle('config:theme', () => getThemeCss())
ipcMain.handle('recent:get', () => { const r = getRoot(); return r ? getRecent(r) : [] })
ipcMain.handle('recent:set', (_e, list: string[]) => { const r = getRoot(); if (r) return setRecent(r, list) })
handle('recent:get', () => { const r = getRoot(); return r ? getRecent(r) : [] })
handle('recent:set', (_e, list: string[]) => { const r = getRoot(); if (r) return setRecent(r, list) })
ipcMain.handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
ipcMain.handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
ipcMain.handle('dialog:unsavedClose', async (e, path: string) => {
// The renderer's window into the same log file (see renderer/src/log.ts): its
// uncaught errors, promise rejections and ErrorBoundary catches land here, so
// main-process and renderer failures interleave in ONE chronological file.
on('log:write', (_e, level: LogLevel, scope: string, msg: string, ctx?: unknown) => {
log(level, scope, msg, ctx)
})
handle('log:path', () => getLogPath())
handle('log:open', () => openLog())
handle('log:reveal', () => revealLog())
handle('dialog:unsavedClose', async (e, path: string) => {
const win = BrowserWindow.fromWebContents(e.sender)
const opts: Electron.MessageBoxOptions = {
type: 'warning',
@@ -248,6 +332,13 @@ function registerIpc(): void {
})
}
/** The window title is the open project's folder name (falling back to the app
* name when nothing is open). Push it to every window after a project change. */
function syncWindowTitle(): void {
const title = getName() || 'Helder'
for (const w of BrowserWindow.getAllWindows()) w.setTitle(title)
}
function createWindow(): void {
const win = new BrowserWindow({
width: 1680,
@@ -256,6 +347,7 @@ function createWindow(): void {
minHeight: 680,
show: false,
backgroundColor: '#16171a',
title: getName() || 'Helder',
titleBarStyle: isMac ? 'hiddenInset' : 'default',
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
webPreferences: {
@@ -266,7 +358,10 @@ function createWindow(): void {
},
})
// Keep the renderer's <title>Helder</title> from clobbering the folder name.
win.on('page-title-updated', (e) => e.preventDefault())
win.on('ready-to-show', () => win.show())
watchWindow(win)
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
@@ -281,10 +376,14 @@ function createWindow(): void {
}
app.whenReady().then(async () => {
app.setName('Helder')
// app.setName already ran in initDiagnostics — it has to happen before
// app.getPath('logs') resolves, or the log lands in ~/Library/Logs/Electron.
Menu.setApplicationMenu(buildAppMenu())
// app.dock exists on macOS only.
app.dock?.setMenu(buildDockMenu())
registerIpc()
const initialRoot = getRoot()
logger.info('session', 'ready', { root: initialRoot, logPath: getLogPath() })
if (initialRoot) {
await resolveConfig(initialRoot)
await addRecentProject(initialRoot)
@@ -297,6 +396,10 @@ app.whenReady().then(async () => {
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
}).catch((e) => {
// A throw in startup (bad config, unreadable project) otherwise leaves a
// window-less app with no message at all.
logger.error('session', 'startup failed', e)
})
app.on('window-all-closed', () => {
+169
View File
@@ -0,0 +1,169 @@
import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
/**
* The app's one log sink: a plain-text file under the OS log dir, written
* SYNCHRONOUSLY so a line survives the process dying moments later.
*
* Why a file at all: `console.*` goes nowhere in real use. Helder runs one
* process per project window, and every window past the first is spawned by
* `spawnInstance()` with `stdio: 'ignore'` — its output is discarded. Launched
* from Finder there's no terminal attached either. Before this, a crash left
* literally no trace; that's what made "it crashed sometimes" undebuggable.
*
* This module deliberately does NOT import electron, so it stays unit-testable.
* `initLogger()` is handed the directory by the caller (see diagnostics.ts).
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
/** Rotate at 2 MB, keep 3 old files (~8 MB worst case for a dev tool's log). */
const MAX_BYTES = 2 * 1024 * 1024
const KEEP = 3
interface LoggerState {
file: string | null
dir: string | null
min: number
mirror: boolean
/** Byte size tracked in-process so the common path avoids a stat() per line. */
size: number
}
const state: LoggerState = { file: null, dir: null, min: LEVELS.debug, mirror: false, size: 0 }
/** Absolute path of the active log file, or null before initLogger(). */
export function getLogPath(): string | null {
return state.file
}
export function getLogDir(): string | null {
return state.dir
}
/**
* Point the logger at `dir` (created if needed). Safe to call once per process.
* `mirror` also echoes to the console, which is useful in `npm run dev` where a
* terminal IS attached. `level` gates the floor (default: everything).
*/
export function initLogger(opts: { dir: string; mirror?: boolean; level?: LogLevel }): void {
state.dir = opts.dir
state.file = join(opts.dir, 'helder.log')
state.mirror = !!opts.mirror
state.min = LEVELS[opts.level ?? 'debug']
try {
mkdirSync(opts.dir, { recursive: true })
state.size = statSync(state.file).size
} catch {
// Missing file is the normal first-run case (size stays 0). A genuinely
// unwritable dir surfaces on the first write() instead, which no-ops.
state.size = 0
}
}
/**
* `helder.log` → `helder.1.log` → … → dropped after KEEP. Called when the live
* file crosses MAX_BYTES. Several project processes share one file and could in
* principle rotate at the same moment; the renames are best-effort and a lost
* race costs at most some log lines, never a crash — hence the blanket catch.
*/
function rotate(): void {
const dir = state.dir
const file = state.file
if (!dir || !file) return
try {
const oldest = join(dir, `helder.${KEEP}.log`)
try { unlinkSync(oldest) } catch { /* wasn't there */ }
for (let i = KEEP - 1; i >= 1; i--) {
try { renameSync(join(dir, `helder.${i}.log`), join(dir, `helder.${i + 1}.log`)) } catch { /* gap in the chain */ }
}
renameSync(file, join(dir, 'helder.1.log'))
state.size = 0
} catch { /* another process rotated first; keep appending */ }
}
/** JSON that can't throw on cycles/BigInt — a logger must never be the crash. */
function safeJson(value: unknown): string {
const seen = new WeakSet<object>()
try {
return JSON.stringify(value, (_k, v) => {
if (typeof v === 'bigint') return `${v}n`
if (typeof v === 'function') return `[Function ${v.name || 'anonymous'}]`
if (typeof v === 'object' && v !== null) {
if (seen.has(v as object)) return '[Circular]'
seen.add(v as object)
}
return v
}) ?? String(value)
} catch {
return '[unserializable]'
}
}
/**
* Normalise anything thrown into a loggable shape. Non-Errors get stringified
* (people throw strings), and `cause` is followed so wrapped errors keep their
* root cause — usually the line that actually explains the failure.
*/
export function formatErr(e: unknown): { message: string; stack?: string; cause?: string } {
if (e instanceof Error) {
const out: { message: string; stack?: string; cause?: string } = { message: e.message }
if (e.stack) out.stack = e.stack
if (e.cause !== undefined) out.cause = e.cause instanceof Error ? (e.cause.stack || e.cause.message) : safeJson(e.cause)
return out
}
return { message: typeof e === 'string' ? e : safeJson(e) }
}
/**
* One log line: ISO ts · level · pid · scope · message · context JSON.
*
* Continuation lines are indented, never bare: a message can carry newlines of
* its own (Electron's console warnings do, and so does any stack passed as the
* message), and an unindented second line is indistinguishable from a new entry
* to both a human skimming the file and to `grep`.
*/
export function formatLine(level: LogLevel, scope: string, msg: string, ctx: unknown, pid: number, now: string): string {
const head = `${now} ${level.toUpperCase().padEnd(5)} ${String(pid).padStart(5)} ${scope.padEnd(9)} ${indent(msg)}`
if (ctx === undefined) return head + '\n'
return head + ' ' + indent(safeJson(ctx)) + '\n'
}
function indent(s: string): string {
return s.replace(/\r?\n/g, '\n ')
}
export function log(level: LogLevel, scope: string, msg: string, ctx?: unknown): void {
if (LEVELS[level] < state.min) return
const line = formatLine(level, scope, msg, ctx, process.pid, new Date().toISOString())
if (state.mirror) {
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
fn(line.trimEnd())
}
const file = state.file
if (!file) return
if (state.size + line.length > MAX_BYTES) rotate()
try {
// Sync + O_APPEND: the write lands before an imminent crash can eat it, and
// concurrent appends from sibling project processes don't interleave.
appendFileSync(file, line, { encoding: 'utf8' })
state.size += Buffer.byteLength(line)
} catch { /* disk full / no permission — never let logging break the app */ }
}
export const logger = {
debug: (scope: string, msg: string, ctx?: unknown): void => log('debug', scope, msg, ctx),
info: (scope: string, msg: string, ctx?: unknown): void => log('info', scope, msg, ctx),
warn: (scope: string, msg: string, ctx?: unknown): void => log('warn', scope, msg, ctx),
error: (scope: string, msg: string, err?: unknown, ctx?: Record<string, unknown>): void =>
log('error', scope, msg, err === undefined ? ctx : { ...ctx, err: formatErr(err) }),
}
/** Reset for tests. Not used by the app. */
export function _resetLogger(): void {
state.file = null; state.dir = null; state.min = LEVELS.debug; state.mirror = false; state.size = 0
}
+33 -6
View File
@@ -2,6 +2,7 @@ import { createRequire } from 'node:module'
import type { WebContents } from 'electron'
import { getRoot } from './project'
import { getConfig } from './config'
import { logger } from './logger'
/**
* Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the
@@ -19,10 +20,14 @@ 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)
logger.error('pty', 'node-pty unavailable — run `npm run rebuild`', e)
}
const terms = new Map<number, import('node-pty').IPty>()
/** Ids we killed on purpose (pane closed, window quitting, StrictMode remount).
* Their exit is expected, so it must NOT be logged as a warning — a log full of
* false alarms is a log nobody reads. */
const killing = new Set<number>()
let seq = 0
/**
@@ -45,6 +50,13 @@ function ptyEnv(): { [key: string]: string } {
if (process.platform !== 'win32' && !env.LC_ALL && !env.LC_CTYPE && !env.LANG) {
env.LANG = 'en_US.UTF-8'
}
// Same inheritance gap as locale, but for color: a terminal launch leaks
// COLORTERM=truecolor so `claude` renders its UI backgrounds as exact 24-bit
// colors; the GUI-launched packaged app has none, so claude falls back to a
// 256/16-color approximation and the same backgrounds shift shade. Match both.
if (process.platform !== 'win32' && !env.COLORTERM) {
env.COLORTERM = 'truecolor'
}
return env
}
@@ -60,7 +72,10 @@ export function ptyAvailable(): boolean {
}
export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number {
if (!pty) return -1
if (!pty) {
logger.warn('pty', `create(${kind}) refused — node-pty never loaded`)
return -1
}
const cwd = getRoot() || process.env.HOME || process.cwd()
const shell = defaultShell()
const ai = getConfig().ai
@@ -71,7 +86,7 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
// echoed command cluttering the pane; claude takes over a clean terminal.
const args = launchAgent ? ['-i', '-c', ai.command] : []
const proc = pty.spawn(shell, args, {
name: 'xterm-color',
name: 'xterm-256color',
cols: cols || 80,
rows: rows || 24,
cwd,
@@ -79,9 +94,21 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
})
const id = ++seq
terms.set(id, proc)
logger.info('pty', `spawned ${kind}`, { id, pid: proc.pid, shell, args, cwd })
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 }) })
proc.onExit(({ exitCode, signal }) => {
terms.delete(id)
// The agent pane dying on its own (`claude` not on PATH, OOM-killed,
// segfault) looks from the UI like "the terminal just went blank" — the exit
// code and signal are the only evidence of what actually happened. An exit we
// asked for is routine, so only an unrequested one is a warning.
const expected = killing.delete(id)
const abnormal = !expected && (exitCode !== 0 || (signal != null && signal !== 0))
if (abnormal) logger.warn('pty', `${kind} exited unexpectedly`, { id, exitCode, signal })
else logger.info('pty', `${kind} exited`, { id, exitCode, expected })
if (!sender.isDestroyed()) sender.send('pty:exit', { id })
})
// Windows path keeps the type-into-shell launch (no `-i -c` semantics there).
if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') {
@@ -100,10 +127,10 @@ export function resizePty(id: number, cols: number, rows: number): void {
export function killPty(id: number): void {
const p = terms.get(id)
if (p) { try { p.kill() } catch { /* already gone */ } terms.delete(id) }
if (p) { killing.add(id); try { p.kill() } catch { /* already gone */ } terms.delete(id) }
}
export function killAllPtys(): void {
for (const p of terms.values()) { try { p.kill() } catch { /* noop */ } }
for (const [id, p] of terms) { killing.add(id); try { p.kill() } catch { /* noop */ } }
terms.clear()
}