import { join, sep } from 'node:path' import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron' import { watch, type FSWatcher } from 'chokidar' import { getName, getRoot, openDialog } from './project' import { readAll, readProjectFile, readTree, writeProjectFile } from './fs-service' import { commit, discard, load, stage, unstage } from './git-service' import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service' import { getConfig, 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 watchTimer: ReturnType | null = null function broadcast(channel: string): void { for (const w of BrowserWindow.getAllWindows()) w.webContents.send(channel) } function startConfigWatcher(): void { if (configWatcher) { configWatcher.close(); configWatcher = null } const root = getRoot() if (!root) return const dir = join(root, '.helder') configWatcher = watch([join(dir, 'config.json'), join(dir, 'theme.css')], { ignoreInitial: true }) const reload = (): void => { resolveConfig(root).then(() => broadcast('config:changed')).catch(() => {}) } configWatcher.on('add', reload).on('change', reload).on('unlink', reload) } /** 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(getRoot()) startWatcher() startConfigWatcher() return true } function buildAppMenu(): Menu { const template: Electron.MenuItemConstructorOptions[] = [ ...(isMac ? [{ role: 'appMenu' as const }] : []), { label: 'File', submenu: [ { 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' }, { role: 'viewMenu' }, { 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('fs:tree', () => readTree(getRoot())) ipcMain.handle('fs:files', () => readAll(getRoot())) ipcMain.handle('fs:read', (_e, rel: string) => readProjectFile(getRoot(), rel)) ipcMain.handle('fs:write', (_e, rel: string, content: string) => writeProjectFile(getRoot(), rel, content)) ipcMain.handle('git:load', () => load(getRoot())) ipcMain.handle('git:stage', (_e, paths: string[]) => stage(getRoot(), paths)) ipcMain.handle('git:unstage', (_e, paths: string[]) => unstage(getRoot(), paths)) ipcMain.handle('git:commit', (_e, message: string) => commit(getRoot(), message)) ipcMain.handle('git:discard', (_e, paths: string[]) => discard(getRoot(), paths)) ipcMain.handle('pty:available', () => ptyAvailable()) ipcMain.handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows)) ipcMain.on('pty:write', (_e, id: number, data: string) => writePty(id, data)) ipcMain.on('pty:resize', (_e, id: number, cols: number, rows: number) => resizePty(id, cols, rows)) ipcMain.on('pty:kill', (_e, id: number) => killPty(id)) ipcMain.handle('config:get', () => getConfig()) ipcMain.handle('config:theme', () => getThemeCss()) ipcMain.handle('recent:get', () => getRecent(getRoot())) ipcMain.handle('recent:set', (_e, list: string[]) => setRecent(getRoot(), list)) ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query)) ipcMain.handle('search:files', () => listFiles(getRoot())) 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() await resolveConfig(getRoot()) startWatcher() startConfigWatcher() createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', () => { if (watcher) { watcher.close(); watcher = null } if (configWatcher) { configWatcher.close(); configWatcher = null } killAllPtys() if (!isMac) app.quit() }) app.on('before-quit', () => killAllPtys())