From 03e16d49a1a48a2069e1c969ed72a7ce85748a36 Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Tue, 28 Jul 2026 08:57:36 +0200 Subject: [PATCH] handling files when stages and dirty at once --- .helder/config.default.json | 3 +- CLAUDE.md | 14 +++ build/adhoc-sign.cjs | 44 +++++++ build/entitlements.mac.plist | 17 +++ electron-builder.yml | 15 +++ eslint.config.js | 6 + src/main/config.ts | 4 +- src/main/diagnostics.ts | 188 ++++++++++++++++++++++++++++ src/main/fs-service.ts | 26 ++++ src/main/git-service.ts | 86 ++++++++++--- src/main/index.ts | 179 ++++++++++++++++++++------ src/main/logger.ts | 169 +++++++++++++++++++++++++ src/main/pty-service.ts | 39 +++++- src/preload/index.ts | 20 +++ src/renderer/src/App.tsx | 129 ++++++++++++++----- src/renderer/src/components.tsx | 32 ++--- src/renderer/src/data.ts | 4 +- src/renderer/src/editor.tsx | 125 +++++++++++++----- src/renderer/src/env.d.ts | 8 ++ src/renderer/src/error-boundary.tsx | 30 +++-- src/renderer/src/highlight.ts | 8 +- src/renderer/src/log.ts | 75 +++++++++++ src/renderer/src/main.tsx | 4 + src/renderer/src/project.tsx | 104 +++++++++++---- src/renderer/src/styles.css | 45 ++++++- src/renderer/src/types.ts | 19 ++- test/git-two-rows.test.tsx | 167 ++++++++++++++++++++++++ test/git.test.ts | 77 ++++++++++-- test/logger.test.ts | 151 ++++++++++++++++++++++ 29 files changed, 1597 insertions(+), 191 deletions(-) create mode 100644 build/adhoc-sign.cjs create mode 100644 build/entitlements.mac.plist create mode 100644 src/main/diagnostics.ts create mode 100644 src/main/logger.ts create mode 100644 src/renderer/src/log.ts create mode 100644 test/git-two-rows.test.tsx create mode 100644 test/logger.test.ts diff --git a/.helder/config.default.json b/.helder/config.default.json index 3fdcbb9..c6e29c5 100644 --- a/.helder/config.default.json +++ b/.helder/config.default.json @@ -11,7 +11,8 @@ "confirmDiscard": true, "confirmStage": false, "confirmUnstage": false, - "defaultDiffMode": "diff" + "defaultDiffMode": "diff", + "refreshInterval": 10000 }, "files": { "exclude": [], diff --git a/CLAUDE.md b/CLAUDE.md index 15499a3..b088c15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ A dark-only (no light mode, no theme toggle) Electron desktop code workbench for - **The four diff view modes (Original / Updated / Diff / Split) all derive from one original-text + updated-text pair per changed file.** The prototype computes this with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output but keep the same four derived views and the same color language everywhere: **red = removed/changed-from, green = added/changed-to**, syntax highlighting on in all modes. - **The agent pane is just a terminal running the `claude` CLI** (`ai.command`, default `claude`, auto-launched when `ai.autoLaunch` is on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (`agentSeed`, `runAgent`, `bootAgent` in `terminals.jsx`) exists only to show the visual style — keep the styling, drop the fakery. - **Chrome budget:** title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height. Keep it minimal. +- **`console.*` is not a log — use the logger.** Helder runs one process per project window, and every window past the first is spawned by `spawnInstance()` with `stdio: 'ignore'`; launched from Finder there's no terminal either. Console output is therefore discarded in real use. Log through `src/main/logger.ts` (main) or `src/renderer/src/log.ts` → `rlog` (renderer, forwarded over IPC to the same file). Never add a bare `catch {}` on an IPC/FS/git path: log the cause, then handle it. ## Confirmed decisions (the "Open assumptions" in DESIGN.md are resolved — do not re-ask) @@ -74,6 +75,17 @@ Settings are project-scoped, living in a `.helder/` folder in the opened project - Effective value = `config.json` if present, else `config.default.json`, merged key by key. - `.helder/theme.css` — custom CSS theme applied over the built-in dark theme; **code font and font size live here**, not in the config files. +## Logging & crash diagnostics + +One file, `~/Library/Logs/Helder/helder.log` (rotates at 2 MB, keeps 3), written **synchronously** so a line survives the process dying right after it. Reachable from **Help → Open Log** and from the crash panel's *Open Log* button. Main and renderer both write to it, so a failure reads as one chronological story. + +- `src/main/logger.ts` — the sink. Deliberately imports NO electron so it stays unit-testable (`test/logger.test.ts`); `initLogger({dir})` is handed the path by the caller. Every process logs its pid, since sibling project windows share the file. +- `src/main/diagnostics.ts` — `initDiagnostics()` runs **before** `app.whenReady()` (crashReporter must start early; `app.setName` must precede `app.getPath('logs')` or logs land in `~/Library/Logs/Electron`). Hooks `uncaughtException`, `unhandledRejection`, `render-process-gone` (the blank-window crash), `child-process-gone`, `preload-error`, `unresponsive`, and renderer console warnings/errors. Native minidumps (node-pty can segfault) go to `app.getPath('crashDumps')`, local only — nothing is uploaded. +- `src/main/index.ts` — the `handle()` / `on()` wrappers around `ipcMain`: every IPC failure is logged with channel + args, then **rethrown** so renderer behaviour is unchanged. Calls over 1 s log a `slow` warning. Note both wrappers must call `ipcMain.handle`/`ipcMain.on` — a rename that rewrites those lines makes the wrappers infinitely recursive and silently registers **no handlers at all** (every IPC then fails with "No handler registered"). +- `src/renderer/src/log.ts` — `rlog` + `installErrorLogging()` (window `error`, `unhandledrejection`). Called from `main.tsx` before first render. `ErrorBoundary` logs the component stack, which exists nowhere else. + +Keep warnings honest: an expected event must not log as WARN (see `killing` in `pty-service.ts` — deliberate kills log INFO). A log full of false alarms is a log nobody reads. + ## Design tokens Canonical source is the `:root` block in `design_handoff_helder_workbench/design/styles.css`. Surfaces are cool charcoal (`--bg-0` editor `#16171a` → `--bg-3` headers/tabs `#23262b`); single cool-blue accent `--accent #4d8dff`; git status `--add #5cbd6b` / `--del #e0696a` / `--mod #d8a85c` / `--ren #5aa6d6`. File-type icons are 15×15 monogram chips (no brand logos). Recreate UI icons as a small inline-SVG set (or Lucide), keeping the monogram chips for file types. Respect `prefers-reduced-motion`; keep motion subtle. @@ -98,4 +110,6 @@ Canonical source is the `:root` block in `design_handoff_helder_workbench/design - `npm run lint` — ESLint (flat config in `eslint.config.js`). `.prettierrc.json` defines formatting (not auto-applied). - `npm run pack` — unpacked app into `dist/` (electron-builder, unsigned). `npm run dist` / `dist:mac` for distributables. App icon comes from `build/icon.png`. Native `node-pty` + `rg` are asar-unpacked so they load when packaged. +**The mac build must be ad-hoc signed — `build/adhoc-sign.cjs` (the `afterPack` hook) does this.** `mac.identity: null` skips signing, which leaves the .app carrying only the linker signature Apple put on the prebuilt Electron binary: it reports `Identifier=Electron`, seals no resources, and does not bind our Info.plist. macOS reads that as a tampered bundle and kills it with *"Malware Blocked and Moved to Trash"*. A real ad-hoc signature over the whole bundle (with `build/entitlements.mac.plist` for JIT + library validation) fixes it. Still not notarized, so a copy opened from the DMG carries a quarantine flag — clear it with `xattr -dr com.apple.quarantine /Applications/Helder.app` or ship a Developer ID build. + Keep all five green (typecheck · lint · test · build, and pack when touching main/packaging) when changing code. diff --git a/build/adhoc-sign.cjs b/build/adhoc-sign.cjs new file mode 100644 index 0000000..777033f --- /dev/null +++ b/build/adhoc-sign.cjs @@ -0,0 +1,44 @@ +// Ad-hoc sign the macOS bundle after electron-builder packs it. +// +// Why this exists: `mac.identity: null` tells electron-builder to skip signing +// altogether. The .app then keeps only the linker signature that Apple put on +// the prebuilt Electron binary. That signature says `Identifier=Electron`, +// seals no resources, and does not bind our Info.plist. macOS reads a bundle +// like that as tampered-with and shows "Malware Blocked and Moved to Trash". +// +// A real ad-hoc signature over the whole bundle fixes it. The app stays +// unsigned in the Developer ID sense (no notarization, so a *downloaded* copy +// still needs the quarantine flag cleared), but it is no longer flagged as +// malware and runs fine locally. +// +// Replace this with a Developer ID identity + notarization when the app ships. +const { execFileSync } = require('node:child_process') +const path = require('node:path') + +exports.default = async function adhocSign(context) { + if (context.electronPlatformName !== 'darwin') return + + const appName = context.packager.appInfo.productFilename + const appPath = path.join(context.appOutDir, `${appName}.app`) + const entitlements = path.join(__dirname, 'entitlements.mac.plist') + + console.log(` • ad-hoc signing ${appPath}`) + execFileSync( + 'codesign', + [ + '--force', + '--deep', + '--sign', + '-', + '--options', + 'runtime', + '--entitlements', + entitlements, + appPath + ], + { stdio: 'inherit' } + ) + + // Fail the build rather than ship a bundle macOS will quarantine again. + execFileSync('codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'inherit' }) +} diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 0000000..e7efce8 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,17 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.allow-dyld-environment-variables + + + com.apple.security.cs.disable-library-validation + + + diff --git a/electron-builder.yml b/electron-builder.yml index b0e07b4..a09ed4c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -14,6 +14,9 @@ asarUnpack: - '**/node_modules/node-pty/**' - '**/node_modules/@vscode/ripgrep/**' - '**/node_modules/@vscode/ripgrep-*/**' +# Ad-hoc signs the .app on macOS. Without it the bundle keeps only Electron's +# linker signature, seals no resources, and macOS blocks it as malware. +afterPack: build/adhoc-sign.cjs mac: category: public.app-category.developer-tools target: @@ -22,8 +25,20 @@ mac: # Local/unsigned build: ad-hoc signed by electron-builder, no notarization. identity: null artifactName: ${productName}-${version}-${arch}.${ext} + files: + # Keep the cross-build leftovers (see win:) out of the mac package. + - '!**/node_modules/@vscode/ripgrep-win32-*/**' win: target: nsis + # Cross-built from macOS with `-c.npmRebuild=false`: node-pty can't be + # cross-compiled, but it ships prebuilds/win32-* and its loader falls back + # to those when build/Release is absent. The win32 rg binary is provided by + # manually extracting @vscode/ripgrep-win32-x64 into node_modules (npm + # refuses to install it on darwin). + files: + - '!**/node_modules/node-pty/build/**' + - '!**/node_modules/@vscode/ripgrep-darwin-*/**' + - '!**/node_modules/@vscode/ripgrep-linux-*/**' linux: target: AppImage category: Development diff --git a/eslint.config.js b/eslint.config.js index 70fc80f..e4202c0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -29,6 +29,12 @@ export default tseslint.config( files: ['test/**/*.{ts,tsx}'], languageOptions: { globals: { ...globals.node, ...globals.browser } }, }, + { + // electron-builder hooks: plain CommonJS, run by node outside the app. + files: ['build/**/*.cjs'], + languageOptions: { globals: { ...globals.node }, sourceType: 'commonjs' }, + rules: { '@typescript-eslint/no-require-imports': 'off' }, + }, { rules: { '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], diff --git a/src/main/config.ts b/src/main/config.ts index ce0b151..a6626a6 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -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 }, diff --git a/src/main/diagnostics.ts b/src/main/diagnostics.ts new file mode 100644 index 0000000..54ec811 --- /dev/null +++ b/src/main/diagnostics.ts @@ -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/ 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 | 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 } diff --git a/src/main/fs-service.ts b/src/main/fs-service.ts index 3cf6ab9..f382a56 100644 --- a/src/main/fs-service.ts +++ b/src/main/fs-service.ts @@ -202,6 +202,32 @@ export async function readProjectFile(root: string, rel: string): Promise = { + 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 — 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 { + 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 { await writeFile(join(root, rel), content, 'utf8') diff --git a/src/main/git-service.ts b/src/main/git-service.ts index 1c7c076..0953bc1 100644 --- a/src/main/git-service.ts +++ b/src/main/git-service.ts @@ -76,19 +76,41 @@ async function git(root: string, args: string[]): Promise { 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 { } } +/** The staged copy of a file: the blob sitting in the index. */ +async function indexText(root: string, path: string): Promise { + try { + return await git(root, ['show', `:${path}`]) + } catch { + return '' + } +} + async function diskText(root: string, path: string): Promise { try { const buf = await readFile(join(root, path)) @@ -214,14 +245,31 @@ async function doLoad(root: string): Promise { // 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 } } diff --git a/src/main/index.ts b/src/main/index.ts index 9898933..0328d2a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { 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 Helder 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', () => { diff --git a/src/main/logger.ts b/src/main/logger.ts new file mode 100644 index 0000000..f9248fa --- /dev/null +++ b/src/main/logger.ts @@ -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 = { 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() + 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): 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 +} diff --git a/src/main/pty-service.ts b/src/main/pty-service.ts index 7d51e01..abe6700 100644 --- a/src/main/pty-service.ts +++ b/src/main/pty-service.ts @@ -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() +/** 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() 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() } diff --git a/src/preload/index.ts b/src/preload/index.ts index 748ff1b..ef273bc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -25,6 +25,7 @@ const api = { readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path), files: () => ipcRenderer.invoke('fs:files'), read: (path: string) => ipcRenderer.invoke('fs:read', path), + imageDataUrl: (path: string): Promise => ipcRenderer.invoke('fs:imageDataUrl', path), write: (path: string, content: string): Promise => ipcRenderer.invoke('fs:write', path, content), delete: (path: string): Promise => ipcRenderer.invoke('fs:delete', path), create: (path: string): Promise => ipcRenderer.invoke('fs:create', path), @@ -83,6 +84,18 @@ const api = { ipcRenderer.invoke('dialog:unsavedClose', path), }, + /** Renderer errors → the main process log file (see renderer/src/log.ts). + * `write` is fire-and-forget on purpose: logging must never await, and must + * never be able to reject into the very handler that's reporting a failure. */ + log: { + write: (level: 'debug' | 'info' | 'warn' | 'error', scope: string, msg: string, ctx?: unknown): void => { + ipcRenderer.send('log:write', level, scope, msg, ctx) + }, + path: (): Promise => ipcRenderer.invoke('log:path'), + open: (): Promise => ipcRenderer.invoke('log:open'), + reveal: (): Promise => ipcRenderer.invoke('log:reveal'), + }, + /** Subscribe to "the project changed on disk" pings. Returns an unsubscribe. */ onProjectChanged: (cb: () => void): (() => void) => { const handler = (): void => cb() @@ -96,6 +109,13 @@ const api = { ipcRenderer.on('config:changed', handler) return () => ipcRenderer.removeListener('config:changed', handler) }, + + /** Subscribe to explicit ⌘R refresh requests (git + tree + viewer). Returns an unsubscribe. */ + onRefresh: (cb: () => void): (() => void) => { + const handler = (): void => cb() + ipcRenderer.on('view:refresh', handler) + return () => ipcRenderer.removeListener('view:refresh', handler) + }, } if (process.contextIsolated) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a95222a..f2777ac 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,9 +8,10 @@ import { Terminal, lid } from './terminals' import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, PassPopup, ProjectsModal, SearchModal, Toasts } from './overlays' import { ProjectLauncher } from './launcher' import type { Menu, Toast } from './overlays' -import type { FileNode, GitStatus } from './types' +import type { DiffSide, FileNode, GitStatus } from './types' import { useProject, useProjectActions } from './project' import { HL } from './highlight' +import { rlog } from './log' import { loadJson, loadNum, saveJson, saveNum } from './persist' const NO_COMMITTED = new Set() @@ -76,6 +77,9 @@ export function App(): React.ReactElement { const [active, setActive] = useState(null) const [tabMode, setTabMode] = useState>({}) + // Which git row opened each tab. Only Diff and Split follow it: Original and + // Actual always show HEAD and the file on disk. + const [tabSide, setTabSide] = useState>({}) const [openDirs, setOpenDirs] = useState>(new Set()) const [cursor, setCursor] = useState(null) const [selection, setSelection] = useState(null) @@ -100,6 +104,7 @@ export function App(): React.ReactElement { const [buffers, setBuffers] = useState>({}) const buffersRef = useRef(buffers); buffersRef.current = buffers const projRef = useRef(proj); projRef.current = proj + const activeRef = useRef(active); activeRef.current = active const saveTimer = useRef | null>(null) function diskText(path: string): string { return proj.files[path] ?? '' } @@ -124,7 +129,10 @@ export function App(): React.ReactElement { }).catch(() => {}) actions.refreshGit() }) - .catch(() => toast('Save failed', path)) + // A failed save is the one error here that can lose work, so it gets the + // reason logged (permissions, read-only volume, file vanished) — the toast + // alone can't say why. + .catch((e) => { rlog.error('save', 'write failed', e, { path, bytes: text.length }); toast('Save failed', path) }) } function saveActive(): void { if (!active) return @@ -208,10 +216,11 @@ export function App(): React.ReactElement { // currently-visible nodes (honouring expansion + the hidden-files toggle). const gitNav = useMemo(() => { const visible = proj.changes.filter((c) => !NO_COMMITTED.has(c.path)) - const stagedRows = visible.filter((c) => proj.staged.has(c.path)) - const changeRows = visible.filter((c) => !proj.staged.has(c.path)) - return [...stagedRows, ...changeRows].map((c) => c.path) - }, [proj.changes, proj.staged]) + const stagedRows = visible.filter((c) => c.staged) + const changeRows = visible.filter((c) => !c.staged) + // Rows, not paths: one file can sit in both groups (staged, then edited again). + return [...stagedRows, ...changeRows].map((c) => ({ id: c.id, path: c.path, staged: c.staged })) + }, [proj.changes]) const treeNav = useMemo(() => { const out: { path: string; type: 'dir' | 'file' }[] = [] const walk = (node: FileNode): void => { @@ -226,7 +235,8 @@ export function App(): React.ReactElement { if (proj.tree) walk(proj.tree) return out }, [proj.tree, openDirs, showHidden]) - const gitSelPath = gitNav[gitSel] ?? null + const gitSelRow = gitNav[gitSel] ?? null + const gitSelPath = gitSelRow?.path ?? null const treeSelItem = treeNav[treeSel] ?? null // Keep the row cursors in range as the lists shrink/grow. @@ -280,10 +290,10 @@ export function App(): React.ReactElement { if (!proj.ready || sessionRoot.current === proj.root) return sessionRoot.current = proj.root recentReady.current = false - setHistory([]); setActive(null); setTabMode({}) + setHistory([]); setActive(null); setTabMode({}); setTabSide({}) if (proj.config.session.restoreOnLaunch) { - const saved = loadJson<{ active: string | null; tabMode: Record } | null>(`helder.session:${proj.root}`, null) - if (saved) { setActive(saved.active ?? null); setTabMode(saved.tabMode ?? {}) } + const saved = loadJson<{ active: string | null; tabMode: Record; tabSide?: Record } | null>(`helder.session:${proj.root}`, null) + if (saved) { setActive(saved.active ?? null); setTabMode(saved.tabMode ?? {}); setTabSide(saved.tabSide ?? {}) } } const bridge = window.helder if (bridge) bridge.recent.get().then((list) => { setHistory(list); recentReady.current = true }).catch(() => { recentReady.current = true }) @@ -299,8 +309,8 @@ export function App(): React.ReactElement { useEffect(() => { if (sessionRoot.current !== proj.root || !proj.config.session.restoreOnLaunch) return - saveJson(`helder.session:${proj.root}`, { active, tabMode }) - }, [active, tabMode, proj.root, proj.config.session.restoreOnLaunch]) + saveJson(`helder.session:${proj.root}`, { active, tabMode, tabSide }) + }, [active, tabMode, tabSide, proj.root, proj.config.session.restoreOnLaunch]) function toast(title: string, ref?: string): void { const id = lid() @@ -351,7 +361,7 @@ export function App(): React.ReactElement { async function deleteEntry(path: string, isDir: boolean): Promise { const bridge = window.helder if (bridge) { - try { await bridge.fs.delete(path) } catch { toast('Delete failed', path); return } + try { await bridge.fs.delete(path) } catch (e) { rlog.error('fs', 'delete failed', e, { path, isDir }); toast('Delete failed', path); return } } const inside = (p: string): boolean => p === path || (isDir && p.startsWith(path + '/')) setHistory((h) => h.filter((p) => !inside(p))) @@ -370,7 +380,7 @@ export function App(): React.ReactElement { const rel = (dir ? dir + '/' : '') + name.replace(/^\/+/, '') const bridge = window.helder if (bridge) { - try { await bridge.fs.create(rel) } catch { toast('Create failed', rel); return } + try { await bridge.fs.create(rel) } catch (e) { rlog.error('fs', 'create file failed', e, { path: rel }); toast('Create failed', rel); return } } if (dir) setOpenDirs((d) => { const n = new Set(d); n.add(dir); return n }) actions.refresh() @@ -385,7 +395,7 @@ export function App(): React.ReactElement { const rel = (dir ? dir + '/' : '') + name.replace(/^\/+/, '').replace(/\/+$/, '') const bridge = window.helder if (bridge) { - try { await bridge.fs.mkdir(rel) } catch { toast('Create failed', rel); return } + try { await bridge.fs.mkdir(rel) } catch (e) { rlog.error('fs', 'create folder failed', e, { path: rel }); toast('Create failed', rel); return } } setOpenDirs((d) => { const n = new Set(d); if (dir) n.add(dir); n.add(rel); return n }) actions.refresh() @@ -411,7 +421,7 @@ export function App(): React.ReactElement { actions.unstage(p) } - function openFile(path: string, opts: { diff?: boolean; line?: number } = {}): void { + function openFile(path: string, opts: { diff?: boolean; line?: number; side?: DiffSide } = {}): void { const changed = !!proj.diffs[path] setFocusZone('editor') setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100)) @@ -421,6 +431,14 @@ export function App(): React.ReactElement { // Unchanged files only have the plain editable "code" view. const openMode: Mode = changed ? (opts.diff ? 'diff' : 'updated') : 'code' setTabMode((m) => ({ ...m, [path]: openMode })) + // Remember which git row this came from, so Diff/Split show that half. An + // explorer click carries no side and falls back to the whole file. + setTabSide((m) => { + const n = { ...m } + if (opts.side) n[path] = opts.side + else delete n[path] + return n + }) reveal(path) if (opts.line) { // The updated/code views render in the CodeEditor (a textarea over a
),
@@ -506,7 +524,8 @@ export function App(): React.ReactElement {
     }
     if (target.kind === 'git') {
       items.push({ sep: true })
-      const isStaged = proj.staged.has(target.path)
+      // The row carries its own flag: a file can have a staged and an unstaged row.
+      const isStaged = target.staged ?? proj.staged.has(target.path)
       items.push(isStaged
         ? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
         : { icon: Icon.plus({ style: { color: 'var(--add)' } }), label: 'Stage changes', onClick: () => stageGuarded(target.path) })
@@ -546,7 +565,7 @@ export function App(): React.ReactElement {
     if (activePanel === 'git' && gitSelPath) {
       const row = document.querySelector('.git-row.kbd') as HTMLElement | null
       const r = row?.getBoundingClientRect()
-      openMenuAt(row ? rowAnchorX(row) : 220, r ? r.top + 4 : 120, { path: gitSelPath, kind: 'git', staged: proj.staged.has(gitSelPath) })
+      openMenuAt(row ? rowAnchorX(row) : 220, r ? r.top + 4 : 120, { path: gitSelPath, kind: 'git', staged: gitSelRow?.staged })
       return true
     }
     if (activePanel === 'tree' && treeSelItem) {
@@ -559,7 +578,7 @@ export function App(): React.ReactElement {
   }
   // ↵ inside Git/Explorer: open the selected file (git → diff), toggle a folder.
   function openPanelSelection(): boolean {
-    if (activePanel === 'git' && gitSelPath) { openFile(gitSelPath, { diff: true }); return true }
+    if (activePanel === 'git' && gitSelRow) { openFile(gitSelRow.path, { diff: true, side: gitSelRow.staged ? 'staged' : 'unstaged' }); return true }
     if (activePanel === 'tree' && treeSelItem) {
       if (treeSelItem.type === 'dir') toggleDir(treeSelItem.path)
       else openFile(treeSelItem.path)
@@ -712,7 +731,7 @@ export function App(): React.ReactElement {
       else if (meta && e.key === 'Enter') {
         if (ae && ae.classList.contains('commit-input')) return
         e.preventDefault()
-        if (commitMsg.trim() && proj.changes.some((c) => proj.staged.has(c.path))) commit()
+        if (commitMsg.trim() && proj.changes.some((c) => c.staged)) commit()
       }
       // ⌘C focuses the commit message (but let native copy run when there's a selection).
       else if (meta && e.key.toLowerCase() === 'c') {
@@ -733,7 +752,61 @@ export function App(): React.ReactElement {
     return () => window.removeEventListener('keydown', onKey)
   }, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm, menu, activePanel, gitNav, treeNav, gitSel, treeSel])
 
+  // ⌘R (View → Refresh, main process sends `view:refresh`) reloads the three
+  // left columns: git status (A) + the file tree (B) via a full project reload,
+  // and the open file in the viewer (C) re-read from disk. The viewer reload
+  // drops any in-memory buffer so the editable view shows on-disk truth — an
+  // explicit refresh is exactly when the user wants whatever the agent just
+  // wrote, the same "on-disk wins" rule used on file open / mode change.
+  // A refresh reads from disk, so nothing may visibly change — the columns then
+  // look inert and the keypress feels lost. Flash A/B/C light grey for ~250 ms so
+  // ⌘R always reads as "that landed". Two class names (a/b) alternate because a
+  // CSS animation only restarts when the animation-name changes: on a second ⌘R
+  // inside the window, re-adding the same class would replay nothing.
+  const [flashTick, setFlashTick] = useState(0)
+  const flashTimer = useRef | null>(null)
+  const flashClass = flashTick === 0 ? '' : (flashTick % 2 ? ' refresh-flash-a' : ' refresh-flash-b')
+  useEffect(() => () => { if (flashTimer.current) clearTimeout(flashTimer.current) }, [])
+
+  useEffect(() => {
+    if (!window.helder) return
+    return window.helder.onRefresh(() => {
+      actions.refresh()
+      const path = activeRef.current
+      if (path) reloadFromDisk(path)
+      setFlashTick((n) => n + 1)
+      if (flashTimer.current) clearTimeout(flashTimer.current)
+      flashTimer.current = setTimeout(() => setFlashTick(0), 400)
+    })
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  // Re-read git status the moment the Git column (Col A) gains focus, so it
+  // reflects on-disk truth whenever the user turns to it — e.g. after the agent
+  // rewrote files while focus was elsewhere. Git-only fast path (no tree/index
+  // re-walk); the FS watcher stays the backstop for everything else.
+  useEffect(() => {
+    if (activePanel === 'git') actions.refreshGit()
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [activePanel])
+
+  // Poll git status on an interval as a backstop for the FS watcher: an external
+  // tool (the agent, the git CLI) rewriting files *should* fire the watcher's
+  // project:changed, but a missed filesystem event would otherwise leave the Git
+  // column stale until the next manual ⌘R. Interval is config-driven
+  // (git.refreshInterval ms; 0 disables). Fast path — git status only.
+  useEffect(() => {
+    if (!window.helder) return
+    const ms = proj.config.git.refreshInterval
+    if (!ms || ms <= 0) return
+    const id = setInterval(() => actions.refreshGit(), ms)
+    return () => clearInterval(id)
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [proj.config.git.refreshInterval])
+
   const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
+  // Which half the open tab shows. Also lights up the matching git row.
+  const activeSide: DiffSide | null = (active && tabSide[active]) || null
 
   const crumb = active ? active.split('/') : []
 
@@ -789,17 +862,17 @@ export function App(): React.ReactElement {
 
       {/* workbench */}
       
-
setActivePanel('git')}> - 300} /> + onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null} + kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} showDir={gitW > 300} />
{ setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} /> -
setActivePanel('tree')}> {proj.tree ? ( { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} /> -
{ setFocusZone('editor'); setActivePanel('editor') }}> - { setFocusZone('editor'); setActivePanel('editor') }}> + { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }} onContext={openMenu} onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active} @@ -831,7 +904,7 @@ export function App(): React.ReactElement {
{/* overlays */} - {splitFor && setSplitFor(null)} onContext={openMenu} />} + {splitFor && setSplitFor(null)} onContext={openMenu} />} {passPopup && { window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload })) diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index 854fcee..0dbc12a 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -1,6 +1,6 @@ /* Shared icons, FileIcon, GitPanel, FileTree */ import React, { Fragment } from 'react' -import type { Change, FileNode, GitStatus } from './types' +import type { Change, DiffSide, FileNode, GitStatus } from './types' import { HL } from './highlight' type SvgProps = React.SVGProps @@ -55,7 +55,7 @@ export function FileIcon({ path }: { path: string }): React.ReactElement { } /* Shared callback signatures used across panels. */ -export type OpenFile = (path: string, opts?: { diff?: boolean; line?: number }) => void +export type OpenFile = (path: string, opts?: { diff?: boolean; line?: number; side?: DiffSide }) => void export interface ContextTarget { path: string kind: 'editor' | 'dir' | 'file' | 'git' @@ -67,23 +67,27 @@ export interface ContextTarget { export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void /* ============ Git / Source Control panel ============ */ -function GitRow({ c, staged, activePath, ctxPath, kbdPath, showDir, onOpen, onContext, onToggleStage }: { +function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, onContext, onToggleStage }: { c: Change - staged: boolean activePath: string | null + /** Which half the open tab is showing, so only that row lights up. */ + activeSide: DiffSide | null ctxPath: string | null - kbdPath: string | null + kbdId: string | null showDir: boolean onOpen: OpenFile onContext: OnContext onToggleStage: (path: string) => void }): React.ReactElement { + const staged = c.staged + const side: DiffSide = staged ? 'staged' : 'unstaged' const name = c.path.split('/').pop() const dir = c.path.split('/').slice(0, -1).join('/') const dirShown = showDir && !!dir + const isActive = activePath === c.path && (!activeSide || activeSide === side) return ( -
onOpen(c.path, { diff: true })} +
onOpen(c.path, { diff: true, side })} onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })} title={c.path}> {c.status} @@ -98,10 +102,9 @@ function GitRow({ c, staged, activePath, ctxPath, kbdPath, showDir, onOpen, onCo ) } -export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, ctxPath, kbdPath, showDir }: { +export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId, showDir }: { branch: string changes: Change[] - staged: Set committed: Set commitMsg: string setCommitMsg: (v: string) => void @@ -114,13 +117,14 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom onOpen: OpenFile onContext: OnContext activePath: string | null + activeSide: DiffSide | null ctxPath: string | null - kbdPath: string | null + kbdId: string | null showDir: boolean }): React.ReactElement { const visible = changes.filter((c) => !committed.has(c.path)) - const stagedList = visible.filter((c) => staged.has(c.path)) - const changesList = visible.filter((c) => !staged.has(c.path)) + const stagedList = visible.filter((c) => c.staged) + const changesList = visible.filter((c) => !c.staged) const totals = visible.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 }) const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0 @@ -144,7 +148,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom {stagedList.length > 0 && }
{stagedList.length > 0 ? stagedList.map((c) => ( - )) : (
Nothing staged — use + to stage a file
@@ -157,7 +161,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom {changesList.length > 0 && }
{changesList.length > 0 ? changesList.map((c) => ( - )) : (
All changes staged
diff --git a/src/renderer/src/data.ts b/src/renderer/src/data.ts index ddeb12a..5a51411 100644 --- a/src/renderer/src/data.ts +++ b/src/renderer/src/data.ts @@ -6,6 +6,7 @@ * same original/updated text pair per changed file, so keep buildDiff()'s * output shape. */ import type { Change, Diff, FileNode, Project } from './types' +import { rowId } from './types' import { buildDiff } from './diff' // ---- working-tree (current / updated) file contents ---------------- @@ -651,7 +652,8 @@ const changes: Change[] = changeDefs.map((c) => { original: orig, updated: upd, }) - return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D' } + // Mock rows are all unstaged here; project.tsx flips the staged ones over. + return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D', staged: false, id: rowId(c.path, false) } }) export const PROJECT: Project = { diff --git a/src/renderer/src/editor.tsx b/src/renderer/src/editor.tsx index 922c05c..f3dc0b8 100644 --- a/src/renderer/src/editor.tsx +++ b/src/renderer/src/editor.tsx @@ -1,6 +1,7 @@ /* Editor: four view modes (Original / Updated / Diff / Split) + line selection */ -import React, { Fragment, useMemo, useRef } from 'react' -import type { Diff, ViewLine } from './types' +import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react' +import type { Diff, DiffSide, ViewLine } from './types' +import { rowId } from './types' import { useProject } from './project' import { HL } from './highlight' import { renderMarkdown } from './markdown' @@ -91,6 +92,32 @@ function MarkdownView({ path, text, onContext }: { path: string; text: string; o ) } +/* Image preview: fetches the file as a data: URL from main (the renderer can't + * read the filesystem) and shows it centred on the editor surface. Read-only. */ +function ImageView({ path, onContext }: { path: string; onContext: OnContext }): React.ReactElement { + const [src, setSrc] = useState('') + const [failed, setFailed] = useState(false) + useEffect(() => { + let alive = true + setSrc(''); setFailed(false) + const bridge = window.helder + if (!bridge) { setFailed(true); return } + bridge.fs.imageDataUrl(path) + .then((url) => { if (alive) { if (url) setSrc(url); else setFailed(true) } }) + .catch(() => { if (alive) setFailed(true) }) + return () => { alive = false } + }, [path]) + return ( +
{ e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}> + {src + ? {path.split('/').pop()} + : failed + ?
Can’t preview this image
+ : null} +
+ ) +} + /* Generic pane: renders an array of line descriptors with selection + caret + context. */ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, setCursor, setSelection, onContext }: { cacheKey: string @@ -197,7 +224,9 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se ) } -/* Build the line descriptors for a given mode. */ +/* Build the line descriptors for a given mode. The caller picks which diff to + * pass: Original and Actual always get the file-level HEAD-vs-disk pair, while + * Diff gets the pair of the git row you clicked. */ function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } { if (mode === 'original' && diff) return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'del' ? 'bar-del' : null })), showSign: false } if (mode === 'updated' && diff) return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'add' ? 'bar-add' : null })), showSign: false } @@ -218,9 +247,11 @@ function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label: return segs } -export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: { +export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: { active: string | null mode: Mode + /** Which git row opened this tab. Only Diff and Split follow it. */ + side: DiffSide | null setMode: (m: Mode) => void onContext: OnContext onSplit: (path: string) => void @@ -234,10 +265,16 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c }): React.ReactElement { const PROJECT = useProject() const tab = active ? { path: active } : null + const isImage = tab ? HL.isImage(tab.path) : false const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null + // Original / Actual always show the whole file: HEAD vs disk. const diff = tab ? PROJECT.diffs[tab.path] : null + // Diff / Split show the half you clicked in the git panel. A file with only + // one row has an identical pair either way. + const rowDiff = (tab && side ? PROJECT.rowDiffs[rowId(tab.path, side === 'staged')] : null) || diff + const bothSides = !!tab && !!PROJECT.rowDiffs[rowId(tab.path, true)] && !!PROJECT.rowDiffs[rowId(tab.path, false)] const lang = tab ? HL.langFor(tab.path) : null - const hasDiff = !!(change && diff) + const hasDiff = !isImage && !!(change && diff) const isMarkdown = lang === 'markdown' const segments = segmentsFor(hasDiff, isMarkdown) @@ -249,16 +286,24 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c else if (hasDiff) effMode = mode === 'code' || mode === 'preview' ? 'updated' : mode else effMode = 'code' + // The diff actually on screen: the row pair for Diff, the whole file otherwise. + const shown = effMode === 'diff' ? rowDiff : diff let built: { lines: ViewLine[]; showSign: boolean } | null = null if (tab && effMode !== 'preview') { - if (hasDiff) built = buildLines(effMode, diff, PROJECT.files[tab.path]) + if (hasDiff) built = buildLines(effMode, shown, PROJECT.files[tab.path]) else built = buildLines('code', null, PROJECT.files[tab.path]) } - const statusWord = change ? (change.status === 'A' ? 'Added' : change.status === 'D' ? 'Deleted' : 'Modified') : '' + // File-level status, taken from the whole-file diff rather than a single row: + // a file can be staged as modified and deleted on disk at the same time. + const fileStatus = diff ? (diff.deleted ? 'D' : diff.added ? 'A' : 'M') : change?.status + const statusWord = fileStatus === 'A' ? 'Added' : fileStatus === 'D' ? 'Deleted' : 'Modified' const activeSeg = splitOpen ? 'split' : effMode - const emptyUpdated = effMode === 'updated' && built && built.lines.length === 0 - const emptyOriginal = effMode === 'original' && built && built.lines.length === 0 + // Keyed off the git status, not the line count: an empty file that still exists + // (a just-created one, or one emptied by hand) has zero lines too, and must get + // the editor rather than the "deleted" placeholder. + const emptyUpdated = effMode === 'updated' && fileStatus === 'D' + const emptyOriginal = effMode === 'original' && fileStatus === 'A' // Editable in the live-buffer modes; Original/Diff/Preview stay read-only views. const editable = effMode === 'code' || effMode === 'updated' @@ -282,26 +327,35 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
{change ? ( - {statusWord} - {change.add > 0 && +{change.add}} - {change.del > 0 && −{change.del}} + {statusWord} + {!!shown && shown.add > 0 && +{shown.add}} + {!!shown && shown.del > 0 && −{shown.del}} + {/* Only ambiguous when the file is staged AND edited again: say + which pair the diff is comparing. */} + {bothSides && effMode === 'diff' && ( + {side === 'unstaged' ? 'staged → actual' : 'HEAD → staged'} + )} ) : ( - {HL.langLabel(tab.path)} + {isImage ? 'Image' : HL.langLabel(tab.path)} + )} + {!isImage && ( +
+ {segments.map((s) => ( + + ))} + {hasDiff && ( + + )} +
)} -
- {segments.map((s) => ( - - ))} - {hasDiff && ( - - )} -
- {effMode === 'preview' ? ( + {isImage ? ( + + ) : effMode === 'preview' ? ( ) : emptyUpdated ? (
No updated version
This file was deleted in the change.
@@ -310,7 +364,7 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c ) : editable ? ( ) : ( - built && )} @@ -321,20 +375,24 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c } /* Full-screen side-by-side split view */ -export function SplitView({ path, onClose, onContext }: { +export function SplitView({ path, side, onClose, onContext }: { path: string + /** Which git row opened this file. Split compares that row's pair. */ + side: DiffSide | null onClose: () => void onContext: OnContext }): React.ReactElement { const PROJECT = useProject() - const diff = PROJECT.diffs[path] + const diff = (side ? PROJECT.rowDiffs[rowId(path, side === 'staged')] : null) || PROJECT.diffs[path] const lang = HL.langFor(path) const leftRef = useRef(null), rightRef = useRef(null) const lock = useRef(false) const change = PROJECT.changes.find((c) => c.path === path) + const splitStatus = diff.deleted ? 'D' : diff.added ? 'A' : 'M' + const bothSides = !!PROJECT.rowDiffs[rowId(path, true)] && !!PROJECT.rowDiffs[rowId(path, false)] - const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ''), [path]) - const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ''), [path]) + const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ''), [path, side]) + const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ''), [path, side]) function sync(from: HTMLDivElement | null, to: HTMLDivElement | null): void { if (lock.current || !from || !to) return @@ -356,9 +414,10 @@ export function SplitView({ path, onClose, onContext }: {
{path} - {change && {change.status === 'A' ? 'Added' : change.status === 'D' ? 'Deleted' : 'Modified'}} - {change && change.add > 0 && +{change.add}} - {change && change.del > 0 && −{change.del}} + {change && {splitStatus === 'A' ? 'Added' : splitStatus === 'D' ? 'Deleted' : 'Modified'}} + {diff.add > 0 && +{diff.add}} + {diff.del > 0 && −{diff.del}} + {bothSides && {side === 'unstaged' ? 'staged → actual' : 'HEAD → staged'}}
- + }}>{(error.stack || error.message) + (stack ? '\n' + stack : '')} +
+ + {/* The panel shows this one error; the log has what led up to it. */} + +
) } diff --git a/src/renderer/src/highlight.ts b/src/renderer/src/highlight.ts index 5cf42b2..388f7db 100644 --- a/src/renderer/src/highlight.ts +++ b/src/renderer/src/highlight.ts @@ -34,6 +34,12 @@ function ext(path: string): string { return i >= 0 ? base.slice(i + 1).toLowerCase() : '' } +// Files the viewer renders as a picture () rather than as text/code. +const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng', 'jfif']) +function isImage(path: string): boolean { + return IMAGE_EXT.has(ext(path)) +} + function langFor(path: string): string | null { return EXT_LANG[ext(path)] || null } @@ -112,4 +118,4 @@ function iconFor(path: string): IconMeta { return ICONS[ext(path)] || { c: '#7d838c', t: base.slice(0, 2) || '·' } } -export const HL = { ext, langFor, langLabel, hlLine, hlText, iconFor, escapeHtml } +export const HL = { ext, langFor, langLabel, isImage, hlLine, hlText, iconFor, escapeHtml } diff --git a/src/renderer/src/log.ts b/src/renderer/src/log.ts new file mode 100644 index 0000000..726b012 --- /dev/null +++ b/src/renderer/src/log.ts @@ -0,0 +1,75 @@ +/** + * Renderer → main log bridge. Everything here ends up in the SAME file the main + * process writes, so a crash reads as one chronological story ("git:load failed + * … then the render process went OOM") instead of two disconnected halves. + * + * Without this, a renderer exception only ever reached DevTools — which nobody + * has open at the moment things actually break. + */ + +type Level = 'debug' | 'info' | 'warn' | 'error' + +interface ErrLike { message: string; stack?: string; name?: string } + +/** Structured-clone-safe: an Error survives IPC as `{}` unless unpacked here. */ +function pack(e: unknown): ErrLike | { value: string } { + if (e instanceof Error) { + const out: ErrLike = { message: e.message, name: e.name } + if (e.stack) out.stack = e.stack + return out + } + if (typeof e === 'string') return { value: e } + try { return { value: JSON.stringify(e) ?? String(e) } } catch { return { value: String(e) } } +} + +function send(level: Level, scope: string, msg: string, ctx?: unknown): void { + const bridge = window.helder + // No bridge = browser preview (or a broken preload). Console is all we have. + if (!bridge?.log) { + const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log + fn(`[helder] ${scope}: ${msg}`, ctx ?? '') + return + } + try { bridge.log.write(level, scope, msg, ctx) } catch { /* never let logging throw */ } +} + +export const rlog = { + debug: (scope: string, msg: string, ctx?: unknown): void => send('debug', scope, msg, ctx), + info: (scope: string, msg: string, ctx?: unknown): void => send('info', scope, msg, ctx), + warn: (scope: string, msg: string, ctx?: unknown): void => send('warn', scope, msg, ctx), + error: (scope: string, msg: string, err?: unknown, ctx?: Record): void => + send('error', scope, msg, err === undefined ? ctx : { ...ctx, err: pack(err) }), +} + +let installed = false + +/** Hook the renderer's global failure paths. Call once, as early as possible. */ +export function installErrorLogging(): void { + if (installed) return + installed = true + + // Uncaught throws outside React's render phase: event handlers, timers, and + // the async IPC callbacks that make up most of this app. + window.addEventListener('error', (e) => { + // Resource load failures (a missing font/image) arrive here with no `error` + // and target the element — worth a line, but they aren't exceptions. + if (e.error === undefined && e.target && e.target !== window) { + const el = e.target as HTMLElement & { src?: string; href?: string } + rlog.warn('resource', `failed to load ${el.tagName?.toLowerCase?.() ?? 'resource'}`, { url: el.src || el.href || '' }) + return + } + rlog.error('renderer', e.message || 'uncaught error', e.error, { source: e.filename, line: e.lineno, col: e.colno }) + }, true) + + // The one that matters most here: every window.helder.* call is a promise, so + // a rejected IPC with no .catch() lands here and nowhere else. + window.addEventListener('unhandledrejection', (e) => { + rlog.error('renderer', 'unhandled promise rejection', e.reason) + }) + + rlog.info('renderer', 'window loaded', { + url: location.href, + bridge: !!window.helder, + screen: `${window.innerWidth}x${window.innerHeight}`, + }) +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index b570535..c179849 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -14,6 +14,10 @@ import './styles.css' import { App } from './App' import { ProjectProvider } from './project' import { ErrorBoundary } from './error-boundary' +import { installErrorLogging } from './log' + +// Before the first render, so an exception during mount is already captured. +installErrorLogging() createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/src/renderer/src/project.tsx b/src/renderer/src/project.tsx index 639650b..061f04b 100644 --- a/src/renderer/src/project.tsx +++ b/src/renderer/src/project.tsx @@ -3,9 +3,10 @@ * already consumed from the mock. When window.helder is absent (e.g. a plain * browser preview) it falls back to the mock so the UI still renders. */ import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' -import type { Change, Diff, FileNode, HelderConfig } from './types' -import { DEFAULT_CONFIG } from './types' +import type { Change, Diff, FileNode, GitStatus, HelderConfig } from './types' +import { DEFAULT_CONFIG, rowId } from './types' import { makeDiff } from './diff' +import { rlog } from './log' import { PROJECT as MOCK } from './data' export interface RecentProject { path: string; name: string } @@ -16,8 +17,13 @@ export interface ProjectData { branch: string tree: FileNode | null files: Record + /** Git rows. One file can appear twice: staged and unstaged (see Change.id). */ changes: Change[] + /** Per file: HEAD vs disk. Drives the Original and Actual views. */ diffs: Record + /** Per row id: that row's own pair. Drives Diff and Split. */ + rowDiffs: Record + /** Paths that have a staged row. */ staged: Set config: HelderConfig isRepo: boolean @@ -72,18 +78,36 @@ export interface ProjectActions { const MOCK_STAGED = ['src/Service/PaymentService.php', 'config/app.json'] +/** Preview mode has no index, so each mock file is one row. The staged set says + * which group it lands in. */ +function mockChanges(staged: Set): Change[] { + return MOCK.changes.map((c) => { + const s = staged.has(c.path) + return { ...c, staged: s, id: rowId(c.path, s) } + }) +} + +function mockRowDiffs(changes: Change[]): Record { + const out: Record = {} + for (const c of changes) out[c.id] = MOCK.diffs[c.path] + return out +} + function mockData(): ProjectData { + const staged = new Set(MOCK_STAGED) + const changes = mockChanges(staged) return { // non-null root so browser-preview shows the workbench, not the launcher name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch, - tree: MOCK.tree, files: MOCK.files, changes: MOCK.changes, diffs: MOCK.diffs, - staged: new Set(MOCK_STAGED), config: DEFAULT_CONFIG, isRepo: true, ready: true, recents: [], + tree: MOCK.tree, files: MOCK.files, changes, diffs: MOCK.diffs, + rowDiffs: mockRowDiffs(changes), + staged, config: DEFAULT_CONFIG, isRepo: true, ready: true, recents: [], } } const emptyData: ProjectData = { name: 'Loading…', root: null, branch: '—', tree: null, files: {}, - changes: [], diffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false, recents: [], + changes: [], diffs: {}, rowDiffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false, recents: [], } const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({ @@ -94,19 +118,32 @@ const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({ /** Map a git.load() result into the git-derived slice of ProjectData. Shared by * the full reload and the git-only fast path so the two stay in lockstep. */ type GitLoadResult = Awaited['git']['load']>> -function deriveGit(git: GitLoadResult): Pick { +function deriveGit(git: GitLoadResult): Pick { const changes: Change[] = [] + const rowDiffs: Record = {} const diffs: Record = {} const staged = new Set() + // File-level pair for Original vs Actual. HEAD is the staged row's left side, + // disk is the unstaged row's right side. With a single row both come from it, + // because the index then matches whichever end is missing. + const whole = new Map() if (git) { for (const c of git.changes) { const d = makeDiff(c.status, c.original, c.updated) - diffs[c.path] = d - changes.push({ path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D' }) + const id = rowId(c.path, c.staged) + rowDiffs[id] = d + changes.push({ id, path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D', staged: c.staged }) if (c.staged) staged.add(c.path) + const prev = whole.get(c.path) + if (!prev) whole.set(c.path, { head: c.original, disk: c.updated, status: c.status }) + else if (c.staged) whole.set(c.path, { ...prev, head: c.original }) + // The unstaged row owns the disk copy and the file-level status: a file + // staged as modified but deleted on disk reads as deleted. + else whole.set(c.path, { head: prev.head, disk: c.updated, status: c.status }) } } - return { branch: git ? git.branch : '—', changes, diffs, staged, isRepo: !!git } + for (const [path, w] of whole) diffs[path] = makeDiff(w.status, w.head, w.disk) + return { branch: git ? git.branch : '—', changes, diffs, rowDiffs, staged, isRepo: !!git } } export function useProject(): ProjectData { @@ -122,6 +159,13 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re const dataRef = useRef(data) dataRef.current = data const loadSeq = useRef(0) + // Git has its OWN counter. It used to share loadSeq, which quietly broke ⌘R: + // the git poll (git.refreshInterval, 10s) and the git-column focus refresh + // both bump the counter, so any full reload still in flight — the tree walk is + // the slow part — saw seq !== loadSeq and bailed out completely. The tree then + // never updated while git kept looking fine. Two counters: a git-only read can + // no longer cancel a tree read, and each still settles on its newest result. + const gitSeq = useRef(0) async function loadReal(): Promise { if (!bridge) return @@ -130,6 +174,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re // apply its result — otherwise a slow/transient mid-checkout read can // resolve last and clobber the correct settled state. const seq = ++loadSeq.current + const gseq = ++gitSeq.current const cur = await bridge.project.current() if (seq !== loadSeq.current) return // Bare launch (Spotlight / no project): flip ready immediately so the @@ -146,10 +191,13 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re ]) if (seq !== loadSeq.current) return applyTheme(theme) + // The git slice is only applied if no newer git-only read has started since; + // otherwise keep the fresher git state and update everything else. + const gitFresh = gseq === gitSeq.current setData((d) => ({ name: cur.name, root: cur.root, tree, files: d.root === cur.root ? d.files : {}, config, ready: true, recents: d.recents, - ...deriveGit(git), + ...(gitFresh ? deriveGit(git) : { branch: d.branch, changes: d.changes, diffs: d.diffs, rowDiffs: d.rowDiffs, staged: d.staged, isRepo: d.isRepo }), })) // The whole-repo content index is only a fallback (real viewing/search go // through fs.read + ripgrep), and reading every file serially costs seconds. @@ -165,13 +213,13 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re // re-read ONLY git status and patch the git fields, skipping the full // loadReal() that also re-walks the file tree + content index. This is the // direct, immediate refresh those actions trigger — the .git watcher stays a - // backstop for git changes made by external tools. Shares loadSeq so a - // concurrent full reload still settles to the newest read. + // backstop for git changes made by external tools. Uses gitSeq only, so it + // never cancels an in-flight full reload (see the gitSeq note above). async function loadGit(): Promise { if (!bridge) return - const seq = ++loadSeq.current + const seq = ++gitSeq.current const git = await bridge.git.load() - if (seq !== loadSeq.current) return + if (seq !== gitSeq.current) return setData((d) => ({ ...d, ...deriveGit(git) })) } @@ -200,7 +248,11 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re if (!bridge) { // ---- mock-mode actions (preview only) ---- const setStaged = (fn: (s: Set) => Set): void => - setData((d) => ({ ...d, staged: fn(new Set(d.staged)) })) + setData((d) => { + const staged = fn(new Set(d.staged)) + const changes = mockChanges(staged).filter((c) => d.changes.some((o) => o.path === c.path)) + return { ...d, staged, changes, rowDiffs: mockRowDiffs(changes) } + }) return { openFolder: () => {}, openProjectPath: () => {}, @@ -209,12 +261,12 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re refreshGit: () => {}, stage: (p) => setStaged((s) => (s.add(p), s)), unstage: (p) => setStaged((s) => (s.delete(p), s)), - stageAll: () => setData((d) => ({ ...d, staged: new Set(d.changes.map((c) => c.path)) })), - unstageAll: () => setData((d) => ({ ...d, staged: new Set() })), + stageAll: () => setStaged(() => new Set(dataRef.current.changes.map((c) => c.path))), + unstageAll: () => setStaged(() => new Set()), commit: async (_msg) => { const cur = dataRef.current - const n = cur.changes.filter((c) => cur.staged.has(c.path)).length - setData((d) => ({ ...d, changes: d.changes.filter((c) => !d.staged.has(c.path)), staged: new Set() })) + const n = new Set(cur.changes.filter((c) => c.staged).map((c) => c.path)).size + setData((d) => ({ ...d, changes: d.changes.filter((c) => !c.staged), staged: new Set() })) return n }, push: async () => ({ ok: true, message: 'Pushed (preview)' }), @@ -242,8 +294,9 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re stage: (p) => after(bridge.git.stage([p])), unstage: (p) => after(bridge.git.unstage([p])), stageAll: () => { - const cur = dataRef.current - const unstaged = cur.changes.filter((c) => !cur.staged.has(c.path)).map((c) => c.path) + // Every path with an unstaged row — including files that already have a + // staged row and were edited again since. + const unstaged = [...new Set(dataRef.current.changes.filter((c) => !c.staged).map((c) => c.path))] if (unstaged.length) after(bridge.git.stage(unstaged)) }, unstageAll: () => { @@ -252,7 +305,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re }, commit: async (msg) => { const cur = dataRef.current - const n = cur.changes.filter((c) => cur.staged.has(c.path)).length + const n = new Set(cur.changes.filter((c) => c.staged).map((c) => c.path)).size await bridge.git.commit(msg) await loadGit() return n @@ -267,14 +320,17 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re if (dataRef.current.files[path] != null) return bridge.fs.read(path).then((txt) => { setData((d) => (d.files[path] != null ? d : { ...d, files: { ...d.files, [path]: txt } })) - }).catch(() => {}) + }).catch((e) => rlog.error('fs', 'prime read failed', e, { path })) }, reloadFile: async (path) => { try { const txt = await bridge.fs.read(path) setData((d) => ({ ...d, files: { ...d.files, [path]: txt } })) return txt - } catch { + } catch (e) { + // Falling back to the cached copy means the editor shows content that + // is NOT what's on disk — a save from here can clobber. Worth a line. + rlog.error('fs', 'reload failed — serving cached content', e, { path }) return dataRef.current.files[path] ?? '' } }, diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 61045c5..6e2f02a 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -119,14 +119,27 @@ body { .workbench { flex:1; display:flex; min-height:0; } -.col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); } +/* --col-bg holds each column's resting background so the ⌘R flash below can + animate back to it, whichever state the column is in. */ +.col { display:flex; flex-direction:column; height:100%; min-width:0; --col-bg:var(--bg-2); background:var(--col-bg); } /* Editor (C) shares the side panels' background (--bg-2), matching B (and A). */ .col.editor-col { flex:1; min-width:240px; } -.col.right-col { background:var(--bg-1); } +.col.right-col { --col-bg:var(--bg-1); background:var(--col-bg); } /* Active panel: subtle lighter-gray tint on the focused column. C uses the same tint as the side panels, so the file view stays in step with B in/out of focus. */ -.col.panel-active { background:#22252a; } -.col.right-col.panel-active { background:#1e2024; } +.col.panel-active { --col-bg:#22252a; background:var(--col-bg); } +.col.right-col.panel-active { --col-bg:#1e2024; background:var(--col-bg); } + +/* ⌘R refresh flash: A, B and C blink light grey and fade back. Two identical + animations (a/b) alternate so a second ⌘R replays it — a CSS animation only + restarts when the animation-name changes. */ +.col.refresh-flash-a { animation:col-refresh-a 260ms ease-out; } +.col.refresh-flash-b { animation:col-refresh-b 260ms ease-out; } +@keyframes col-refresh-a { 0% { background:#3a4048; } 100% { background:var(--col-bg); } } +@keyframes col-refresh-b { 0% { background:#3a4048; } 100% { background:var(--col-bg); } } +@media (prefers-reduced-motion:reduce) { + .col.refresh-flash-a, .col.refresh-flash-b { animation-duration:180ms; animation-timing-function:steps(2); } +} .splitter { flex:0 0 5px; cursor:col-resize; background:transparent; position:relative; z-index:5; } .splitter::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; } @@ -245,6 +258,26 @@ body { .diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); } .diff-bar .seg .split-btn svg { opacity:.85; } .diff-bar .db-lang { font-family:var(--mono); font-size:10.5px; color:var(--fg-3); letter-spacing:.02em; } +/* Which pair the diff compares. Only shown when a file is staged AND edited + again, so the two git rows can be told apart. */ +.db-side { + font-family:var(--mono); font-size:10px; color:var(--fg-3); letter-spacing:.02em; + padding:1px 5px; border:1px solid var(--border); border-radius:4px; white-space:nowrap; +} + +/* image preview (viewer shows a picture, not text) */ +.img-view { flex:1; min-height:0; overflow:auto; display:flex; align-items:center; justify-content:center; padding:24px; background:var(--bg-0); } +.img-view-img { + max-width:100%; max-height:100%; object-fit:contain; border-radius:6px; + /* Checkerboard so transparent PNGs/SVGs read clearly on the dark surface. */ + background-color:#2a2d33; + background-image: + linear-gradient(45deg, #232529 25%, transparent 25%), linear-gradient(-45deg, #232529 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #232529 75%), linear-gradient(-45deg, transparent 75%, #232529 75%); + background-size:20px 20px; + background-position:0 0, 0 10px, 10px -10px, -10px 0; + box-shadow:0 4px 24px rgba(0,0,0,.4); +} /* rendered-markdown preview (Preview view option) */ .md-view { flex:1; overflow:auto; padding:8px 0 48px; } @@ -448,7 +481,9 @@ body { .ce-gutter { padding-top:6px; will-change:transform; } .ce-gutter div { height:20px; line-height:20px; text-align:right; padding-right:14px; color:var(--fg-3); font-family:var(--code-font); font-size:12px; user-select:none; } .ce-scroll { flex:1; min-width:0; overflow:auto; position:relative; } -.ce-inner { position:relative; width:max-content; min-width:100%; } +/* min-height keeps the inset:0 textarea filling the pane on short/empty files, + so a click anywhere in the blank area below the last line still lands. */ +.ce-inner { position:relative; width:max-content; min-width:100%; min-height:100%; } .ce-pre, .ce-ta { margin:0; padding:6px 16px 40px 6px; border:0; font-family:var(--code-font); font-size:var(--code-size); line-height:20px; diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 81ba129..2241c18 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -45,6 +45,21 @@ export interface Change { add: number del: number deleted: boolean + /** True when this row is the staged half of the file. A file that is staged + * and then edited again produces two rows, one in each group. */ + staged: boolean + /** Row identity. Path alone is no longer unique — see `staged`. */ + id: string +} + +/** Which half of a file's changes a diff view shows. `staged` is HEAD vs the + * index, `unstaged` is the index vs disk. Absent means the whole file: HEAD vs + * disk, which is what Original and Actual always show. */ +export type DiffSide = 'staged' | 'unstaged' + +/** Row id for a git change. Keeps the two halves of one file apart. */ +export function rowId(path: string, staged: boolean): string { + return (staged ? 's:' : 'w:') + path } export interface Project { @@ -71,7 +86,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 } @@ -80,7 +95,7 @@ export interface HelderConfig { export const DEFAULT_CONFIG: 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: true }, terminal: { shell: null }, session: { restoreOnLaunch: true }, diff --git a/test/git-two-rows.test.tsx b/test/git-two-rows.test.tsx new file mode 100644 index 0000000..30a0e95 --- /dev/null +++ b/test/git-two-rows.test.tsx @@ -0,0 +1,167 @@ +// @vitest-environment jsdom +// +// A file can be staged and then edited again. Git calls that "MM": two rows, +// one per group. These tests drive the renderer with such a payload and check +// that Diff follows the row you clicked, while Original and Actual do not. +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import React from 'react' + +vi.mock('../src/renderer/src/terminals', () => { + let n = 0 + return { Terminal: () => null, lid: () => ++n } +}) + +import { App } from '../src/renderer/src/App' +import { ProjectProvider } from '../src/renderer/src/project' + +const HEAD = 'a\nb\nc\n' +const INDEX = 'a\nSTAGED\nc\n' +const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n' + +/** Minimal preload bridge: enough for the store to boot with real git rows. */ +function stubBridge(): void { + const noop = (): void => {} + const off = (): (() => void) => noop + ;(window as unknown as { helder: unknown }).helder = { + platform: 'darwin', + clipboard: { writeText: noop, readText: () => '' }, + project: { + current: async () => ({ root: '/repo', name: 'repo' }), + open: async () => ({ root: '/repo', name: 'repo' }), + openPath: async () => ({ root: '/repo', name: 'repo' }), + recent: async () => [], + }, + fs: { + tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [{ name: 'demo.txt', type: 'file', path: 'demo.txt' }] }), + readDir: async () => [], + files: async () => ({ 'demo.txt': DISK }), + read: async () => DISK, + imageDataUrl: async () => '', + write: async () => {}, + delete: async () => {}, + create: async () => {}, + mkdir: async () => {}, + }, + shell: { reveal: noop }, + git: { + // Exactly what git-service now returns for porcelain "MM". + load: async () => ({ + branch: 'main', + changes: [ + { path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX }, + { path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK }, + ], + }), + stage: async () => {}, unstage: async () => {}, commit: async () => {}, + push: async () => ({ ok: true, message: '' }), discard: async () => {}, + }, + pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off }, + config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' }, + recent: { get: async () => [], set: async () => {} }, + search: { content: async () => [], files: async () => [] }, + dialog: { unsavedClose: async () => 'cancel' }, + log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} }, + onProjectChanged: off, + onConfigChanged: off, + onRefresh: off, + } +} + +beforeAll(() => { + globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never + if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {} +}) +beforeEach(stubBridge) +afterEach(() => { + cleanup() + localStorage.clear() + delete (window as unknown as { helder?: unknown }).helder +}) + +/** Row text of the view currently on screen. */ +function viewText(c: HTMLElement): string { + return Array.from(c.querySelectorAll('.editor .ln-row')).map((el) => el.textContent ?? '').join('\n') +} + +function group(c: HTMLElement, label: 'Staged Changes' | 'Changes'): HTMLElement[] { + const heads = Array.from(c.querySelectorAll('.git-group')) + const head = heads.find((h) => h.textContent?.startsWith(label))! + const rows: HTMLElement[] = [] + for (let el = head.nextElementSibling; el; el = el.nextElementSibling) { + if (el.classList.contains('git-group') || el.classList.contains('git-divider')) break + if (el.classList.contains('git-row')) rows.push(el as HTMLElement) + } + return rows +} + +async function boot(): Promise { + const c = render().container + await waitFor(() => { + if (c.querySelectorAll('.git-row').length < 2) throw new Error('git not ready') + }) + return c +} + +describe('a file that is staged and then edited again', () => { + it('shows up in both groups', async () => { + const c = await boot() + expect(group(c, 'Staged Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt']) + expect(group(c, 'Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt']) + }) + + it('Diff on the staged row compares HEAD with the staged copy', async () => { + const c = await boot() + fireEvent.click(group(c, 'Staged Changes')[0]) + await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy()) + const text = viewText(c) + expect(text).toContain('b') + expect(text).toContain('STAGED') + // The later edit is not part of what is staged, so it must not show here. + expect(text).not.toContain('AFTER-STAGING') + }) + + it('Diff on the unstaged row compares the staged copy with disk', async () => { + const c = await boot() + fireEvent.click(group(c, 'Changes')[0]) + await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy()) + const text = viewText(c) + expect(text).toContain('AFTER-STAGING') + // 'b' was already replaced before staging, so this half must not mention it. + expect(text.split('\n').some((l) => l.trim() === 'b')).toBe(false) + }) + + it('labels which pair the diff is comparing', async () => { + const c = await boot() + fireEvent.click(group(c, 'Staged Changes')[0]) + await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('HEAD → staged')) + fireEvent.click(group(c, 'Changes')[0]) + await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('staged → actual')) + }) + + it('Original stays HEAD and Actual stays the file on disk, from either row', async () => { + const c = await boot() + for (const label of ['Staged Changes', 'Changes'] as const) { + fireEvent.click(group(c, label)[0]) + await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy()) + + const original = Array.from(c.querySelectorAll('.seg button')).find((b) => b.textContent === 'Original')! + fireEvent.click(original) + await waitFor(() => expect(viewText(c)).toContain('b')) + expect(viewText(c)).not.toContain('AFTER-STAGING') + + const actual = Array.from(c.querySelectorAll('.seg button')).find((b) => b.textContent === 'Actual')! + fireEvent.click(actual) + await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy()) + expect((c.querySelector('.ce-ta') as HTMLTextAreaElement).value).toBe(DISK) + } + }) + + it('only the row you opened is highlighted', async () => { + const c = await boot() + fireEvent.click(group(c, 'Changes')[0]) + await waitFor(() => expect(c.querySelector('.git-row.active')).toBeTruthy()) + expect(c.querySelectorAll('.git-row.active')).toHaveLength(1) + expect(group(c, 'Changes')[0].classList.contains('active')).toBe(true) + }) +}) diff --git a/test/git.test.ts b/test/git.test.ts index 8fc7345..93bf627 100644 --- a/test/git.test.ts +++ b/test/git.test.ts @@ -9,17 +9,33 @@ import { classify, discard, load, stage } from '../src/main/git-service' describe('classify', () => { it('reads the index code as staged, working code as unstaged', () => { - expect(classify('M', ' ')).toEqual({ letter: 'M', staged: true }) - expect(classify(' ', 'M')).toEqual({ letter: 'M', staged: false }) - expect(classify('A', ' ')).toEqual({ letter: 'A', staged: true }) - expect(classify('D', ' ')).toEqual({ letter: 'D', staged: true }) - expect(classify('R', ' ')).toEqual({ letter: 'R', staged: true }) + expect(classify('M', ' ')).toEqual([{ letter: 'M', staged: true }]) + expect(classify(' ', 'M')).toEqual([{ letter: 'M', staged: false }]) + expect(classify('A', ' ')).toEqual([{ letter: 'A', staged: true }]) + expect(classify('D', ' ')).toEqual([{ letter: 'D', staged: true }]) + expect(classify('R', ' ')).toEqual([{ letter: 'R', staged: true }]) }) it('treats untracked as a new (A) unstaged file', () => { - expect(classify('?', '?')).toEqual({ letter: 'A', staged: false }) + expect(classify('?', '?')).toEqual([{ letter: 'A', staged: false }]) }) - it('maps unmerged (U) to modified', () => { - expect(classify('U', 'U').letter).toBe('M') + it('splits a staged-then-edited file into two rows', () => { + expect(classify('M', 'M')).toEqual([ + { letter: 'M', staged: true }, + { letter: 'M', staged: false }, + ]) + expect(classify('A', 'M')).toEqual([ + { letter: 'A', staged: true }, + { letter: 'M', staged: false }, + ]) + expect(classify('M', 'D')).toEqual([ + { letter: 'M', staged: true }, + { letter: 'D', staged: false }, + ]) + }) + it('keeps a merge conflict as one row', () => { + expect(classify('U', 'U')).toEqual([{ letter: 'M', staged: true }]) + expect(classify('A', 'A')).toEqual([{ letter: 'M', staged: true }]) + expect(classify('D', 'D')).toEqual([{ letter: 'M', staged: true }]) }) }) @@ -77,6 +93,51 @@ describe('load (integration against a temp repo)', () => { expect(res!.changes.find((c) => c.path === 'a.txt')?.staged).toBe(true) }) + it('shows a staged-then-edited file in both groups, each with its own pair', async () => { + dir = await repo() + await writeFile(join(dir, 'a.txt'), '1\nSTAGED\n3\n') + await stage(dir, ['a.txt']) + await writeFile(join(dir, 'a.txt'), '1\nSTAGED\n3\n4\n') + const res = await load(dir) + const rows = res!.changes.filter((c) => c.path === 'a.txt') + expect(rows).toHaveLength(2) + + // Staged row: HEAD -> index. It must NOT include the newer edit. + const s = rows.find((c) => c.staged)! + expect(s.original).toBe('1\n2\n3\n') + expect(s.updated).toBe('1\nSTAGED\n3\n') + + // Unstaged row: index -> disk. Only the newer edit. + const w = rows.find((c) => !c.staged)! + expect(w.original).toBe('1\nSTAGED\n3\n') + expect(w.updated).toBe('1\nSTAGED\n3\n4\n') + }) + + it('gives a staged-new file that was edited again both rows', async () => { + dir = await repo() + await writeFile(join(dir, 'fresh.txt'), 'one\n') + await stage(dir, ['fresh.txt']) + await writeFile(join(dir, 'fresh.txt'), 'one\ntwo\n') + const res = await load(dir) + const rows = res!.changes.filter((c) => c.path === 'fresh.txt') + expect(rows.map((r) => [r.status, r.staged])).toEqual([['A', true], ['M', false]]) + expect(rows[0].original).toBe('') + expect(rows[0].updated).toBe('one\n') + expect(rows[1].original).toBe('one\n') + expect(rows[1].updated).toBe('one\ntwo\n') + }) + + it('keeps one row when a file is only staged', async () => { + dir = await repo() + await writeFile(join(dir, 'a.txt'), '1\n2\n3\n4\n') + await stage(dir, ['a.txt']) + const rows = (await load(dir))!.changes.filter((c) => c.path === 'a.txt') + expect(rows).toHaveLength(1) + expect(rows[0].staged).toBe(true) + expect(rows[0].original).toBe('1\n2\n3\n') + expect(rows[0].updated).toBe('1\n2\n3\n4\n') + }) + it('discard reverts a modified tracked file to HEAD', async () => { dir = await repo() await writeFile(join(dir, 'a.txt'), '1\nCHANGED\n3\n') diff --git a/test/logger.test.ts b/test/logger.test.ts new file mode 100644 index 0000000..84b320f --- /dev/null +++ b/test/logger.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { _resetLogger, formatErr, formatLine, getLogPath, initLogger, log, logger } from '../src/main/logger' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'helder-log-')) + _resetLogger() +}) +afterEach(() => { + _resetLogger() + rmSync(dir, { recursive: true, force: true }) +}) + +function read(): string { + return readFileSync(join(dir, 'helder.log'), 'utf8') +} + +describe('formatLine', () => { + it('lays out ts, padded level, pid, scope and message', () => { + const line = formatLine('info', 'git', 'loaded', undefined, 42, '2026-07-15T10:00:00.000Z') + expect(line).toBe('2026-07-15T10:00:00.000Z INFO 42 git loaded\n') + }) + + it('appends context as JSON', () => { + const line = formatLine('warn', 'ipc', 'slow', { ms: 1200 }, 7, '2026-07-15T10:00:00.000Z') + expect(line).toContain('{"ms":1200}') + expect(line.endsWith('\n')).toBe(true) + }) + + it('indents a multi-line message so a continuation never looks like a new entry', () => { + // Electron's own console warnings arrive with embedded newlines. + const line = formatLine('warn', 'console', 'line one\nline two', undefined, 1, 'T') + expect(line).toBe('T WARN 1 console line one\n line two\n') + // Every line after the first is indented → an entry always starts at col 0. + for (const l of line.trimEnd().split('\n').slice(1)) expect(l.startsWith(' ')).toBe(true) + }) + + it('indents multi-line context too', () => { + const line = formatLine('error', 'x', 'boom', { stack: 'a\nb' }, 1, 'T') + // JSON escapes the \n inside the string value, so this stays a single line. + expect(line.split('\n').filter(Boolean)).toHaveLength(1) + }) +}) + +describe('formatErr', () => { + it('keeps message and stack', () => { + const e = new Error('nope') + const out = formatErr(e) + expect(out.message).toBe('nope') + expect(out.stack).toContain('nope') + }) + + it('follows cause — the line that usually explains the failure', () => { + const root = new Error('EACCES') + const e = new Error('save failed', { cause: root }) + expect(formatErr(e).cause).toContain('EACCES') + }) + + it('survives a thrown string', () => { + expect(formatErr('just a string').message).toBe('just a string') + }) + + it('survives a thrown non-Error object', () => { + expect(formatErr({ code: 7 }).message).toBe('{"code":7}') + }) + + it('does not throw on circular values', () => { + const a: Record = {} + a.self = a + expect(() => formatErr(a)).not.toThrow() + expect(formatErr(a).message).toContain('Circular') + }) +}) + +describe('log', () => { + it('creates the file and appends lines', () => { + initLogger({ dir }) + logger.info('boot', 'hello') + logger.warn('boot', 'careful') + const out = read() + expect(out).toContain('INFO') + expect(out).toContain('hello') + expect(out).toContain('WARN') + expect(out.trim().split('\n')).toHaveLength(2) + }) + + it('creates a missing directory', () => { + const nested = join(dir, 'a', 'b') + initLogger({ dir: nested }) + logger.info('x', 'y') + expect(existsSync(join(nested, 'helder.log'))).toBe(true) + }) + + it('records the error with its stack', () => { + initLogger({ dir }) + logger.error('save', 'write failed', new Error('EACCES: permission denied'), { path: 'a.ts' }) + const out = read() + expect(out).toContain('EACCES: permission denied') + expect(out).toContain('"path":"a.ts"') + expect(out).toContain('"stack"') + }) + + it('honours the level floor', () => { + initLogger({ dir, level: 'warn' }) + logger.debug('x', 'debug line') + logger.info('x', 'info line') + logger.error('x', 'error line') + const out = read() + expect(out).not.toContain('debug line') + expect(out).not.toContain('info line') + expect(out).toContain('error line') + }) + + it('is a no-op before initLogger rather than throwing', () => { + expect(() => logger.info('x', 'y')).not.toThrow() + expect(getLogPath()).toBeNull() + }) + + it('exposes the active path', () => { + initLogger({ dir }) + expect(getLogPath()).toBe(join(dir, 'helder.log')) + }) + + it('never throws even when the log path is unwritable', () => { + // Point at a path whose parent is a FILE: every append must fail. + const wall = join(dir, 'wall') + writeFileSync(wall, 'x') + initLogger({ dir: join(wall, 'sub') }) + expect(() => logger.error('x', 'still fine')).not.toThrow() + }) +}) + +describe('rotation', () => { + it('rolls the file once it passes the size cap and keeps writing', () => { + initLogger({ dir }) + const big = 'x'.repeat(4000) + // 2MB cap / ~4KB per line → ~525 lines to trip it. 700 is comfortably past. + for (let i = 0; i < 700; i++) log('info', 'bulk', big) + + expect(existsSync(join(dir, 'helder.1.log'))).toBe(true) + // The live file is the post-rotation one and is still being appended to. + logger.info('after', 'still logging') + expect(read()).toContain('still logging') + // And it's small again — proof the rotation actually moved the bytes. + expect(read().length).toBeLessThan(4000 * 700) + }) +})