Files
helder/src/main/index.ts
Jonathan van Rij 43131915c0
Some checks failed
CI / check (push) Has been cancelled
faster loading
2026-06-23 08:52:05 +02:00

311 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof setTimeout> | null = null
let gitTimer: ReturnType<typeof setTimeout> | 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
* `<root>/.git`, but for worktrees/submodules `.git` is a file containing
* `gitdir: <path>` 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<boolean> {
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 dont 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())