import { join, resolve, sep } from 'node:path' import { readFileSync, statSync } from 'node:fs' 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 { 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' const isDev = !!process.env['ELECTRON_RENDERER_URL'] const isMac = process.platform === 'darwin' const WATCH_IGNORE = new Set([ 'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt', '.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo', ]) let watcher: FSWatcher | null = null let configWatcher: FSWatcher | null = null let gitWatcher: FSWatcher | null = null let watchTimer: ReturnType | null = null let gitTimer: ReturnType | null = null function broadcast(channel: string): void { for (const w of BrowserWindow.getAllWindows()) w.webContents.send(channel) } /** * Each Helder window is one project, and one project owns global main-process * state (root + FS/git/config watchers + PTYs). To run several projects beside * each other we therefore spawn a *separate, detached* Helder process per window * rather than opening a second BrowserWindow in this process. * * Spawning `process.execPath` directly also sidesteps macOS LaunchServices, which * otherwise just re-activates the running instance when the bundle is launched * again from Finder/Dock. `HELDER_PROJECT` points the child at a project (the * child's `resolveInitialRoot` reads it); with no path it lands on the launcher. */ function spawnInstance(projectPath?: string): void { const env = { ...process.env } if (projectPath && safeIsDir(projectPath)) env.HELDER_PROJECT = resolve(projectPath) else delete env.HELDER_PROJECT // Packaged: execPath IS the app, no app path needed. Dev: replay our own argv // (the electron-vite main entry) so the child loads the same app. const args = app.isPackaged ? [] : process.argv.slice(1) const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env }) child.unref() } function safeIsDir(p: string): boolean { try { return statSync(p).isDirectory() } catch { return false } } function startConfigWatcher(): void { if (configWatcher) { configWatcher.close(); configWatcher = null } const root = getRoot() if (!root) return const dir = join(root, '.helder') configWatcher = watch([join(dir, 'config.json'), join(dir, 'theme.css')], { ignoreInitial: true }) const reload = (): void => { resolveConfig(root).then(() => broadcast('config:changed')).catch(() => {}) } configWatcher.on('add', reload).on('change', reload).on('unlink', reload) } /** Resolve the real git directory for a project root. Normally this is * `/.git`, but for worktrees/submodules `.git` is a file containing * `gitdir: ` pointing at the actual directory. */ function gitDir(root: string): string { const dot = join(root, '.git') try { if (statSync(dot).isFile()) { const m = readFileSync(dot, 'utf8').trim().match(/^gitdir:\s*(.+)$/) if (m) return resolve(root, m[1].trim()) } } catch { /* not a worktree, or .git missing */ } return dot } /** The main watcher ignores `.git`, so branch switches / commits / staging * done by external tools (Sublime Merge, the CLI, …) would never refresh the * git column. Watch the few git files that mark those events so the renderer * re-reads status: HEAD (branch switch), index (staging), refs/heads + logs * (commits), MERGE_HEAD (in-progress merge). */ function startGitWatcher(): void { if (gitWatcher) { gitWatcher.close(); gitWatcher = null } const root = getRoot() if (!root) return const dir = gitDir(root) gitWatcher = watch( [ join(dir, 'HEAD'), join(dir, 'index'), join(dir, 'refs', 'heads'), join(dir, 'logs', 'HEAD'), join(dir, 'MERGE_HEAD'), ], { ignoreInitial: true }, ) const ping = (): void => { if (gitTimer) clearTimeout(gitTimer) gitTimer = setTimeout(() => broadcast('project:changed'), 200) } gitWatcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping) } /** Show the folder picker; if a new folder is chosen, switch the project * (config + watchers). Returns whether the project changed. */ async function openFolderFlow(win: BrowserWindow | null): Promise { const next = await openDialog(win) if (!next) return false await resolveConfig(next) startWatcher() startConfigWatcher() startGitWatcher() return true } function buildAppMenu(): Menu { const template: Electron.MenuItemConstructorOptions[] = [ ...(isMac ? [{ role: 'appMenu' as const }] : []), { label: 'File', submenu: [ { label: 'New Window', accelerator: 'CmdOrCtrl+Shift+N', click: () => spawnInstance(), }, { label: 'Open Folder…', accelerator: 'CmdOrCtrl+O', click: (_m, win) => { const bw = win instanceof BrowserWindow ? win : BrowserWindow.getFocusedWindow() openFolderFlow(bw).then((changed) => { if (changed) broadcast('project:changed') }) }, }, { type: 'separator' }, isMac ? { role: 'close' } : { role: 'quit' }, ], }, { role: 'editMenu' }, { 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. { label: 'Refresh', accelerator: 'CmdOrCtrl+R', click: (_m, win) => { const bw = win instanceof BrowserWindow ? win : BrowserWindow.getFocusedWindow() bw?.webContents.send('project:changed') }, }, { type: 'separator' }, { role: 'toggleDevTools' }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' }, ], }, { role: 'windowMenu' }, ] return Menu.buildFromTemplate(template) } function startWatcher(): void { if (watcher) { watcher.close(); watcher = null } const root = getRoot() if (!root) return watcher = watch(root, { ignoreInitial: true, ignored: (p: string) => p.split(sep).some((seg) => WATCH_IGNORE.has(seg)), }) const ping = (): void => { if (watchTimer) clearTimeout(watchTimer) watchTimer = setTimeout(() => broadcast('project:changed'), 250) } watcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping) } function registerIpc(): void { ipcMain.handle('project:current', () => ({ root: getRoot(), name: getName() })) ipcMain.handle('project:open', async (e) => { await openFolderFlow(BrowserWindow.fromWebContents(e.sender)) return { root: getRoot(), name: getName() } }) ipcMain.handle('projects:recent', () => getRecentProjects()) ipcMain.handle('project:openPath', async (_e, path: string) => { setRoot(path) const r = getRoot() if (r) { await resolveConfig(r); startWatcher(); startConfigWatcher(); startGitWatcher() } 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)) }) 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) }) ipcMain.handle('pty:available', () => ptyAvailable()) ipcMain.handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows)) ipcMain.on('pty:write', (_e, id: number, data: string) => writePty(id, data)) ipcMain.on('pty:resize', (_e, id: number, cols: number, rows: number) => resizePty(id, cols, rows)) ipcMain.on('pty:kill', (_e, id: number) => killPty(id)) ipcMain.handle('config:get', () => getConfig()) ipcMain.handle('config:theme', () => getThemeCss()) ipcMain.handle('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) }) 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) : [] }) ipcMain.handle('dialog:unsavedClose', async (e, path: string) => { const win = BrowserWindow.fromWebContents(e.sender) const opts: Electron.MessageBoxOptions = { type: 'warning', buttons: ['Save', "Don't Save", 'Cancel'], defaultId: 0, cancelId: 2, message: `Save changes to ${path}?`, detail: 'Your changes will be lost if you don’t save them.', } const { response } = win ? await dialog.showMessageBox(win, opts) : await dialog.showMessageBox(opts) return response === 0 ? 'save' : response === 1 ? 'discard' : 'cancel' }) } function createWindow(): void { const win = new BrowserWindow({ width: 1680, height: 1040, minWidth: 1100, minHeight: 680, show: false, backgroundColor: '#16171a', titleBarStyle: isMac ? 'hiddenInset' : 'default', trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined, webPreferences: { preload: join(__dirname, '../preload/index.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: false, }, }) win.on('ready-to-show', () => win.show()) win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url) return { action: 'deny' } }) if (isDev) { win.loadURL(process.env['ELECTRON_RENDERER_URL'] as string) } else { win.loadFile(join(__dirname, '../renderer/index.html')) } } app.whenReady().then(async () => { app.setName('Helder') Menu.setApplicationMenu(buildAppMenu()) registerIpc() const initialRoot = getRoot() if (initialRoot) { await resolveConfig(initialRoot) await addRecentProject(initialRoot) startWatcher() startConfigWatcher() startGitWatcher() } createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', () => { if (watcher) { watcher.close(); watcher = null } if (configWatcher) { configWatcher.close(); configWatcher = null } if (gitWatcher) { gitWatcher.close(); gitWatcher = null } killAllPtys() if (!isMac) app.quit() }) app.on('before-quit', () => killAllPtys())