430 lines
18 KiB
TypeScript
430 lines
18 KiB
TypeScript
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, 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 { readNote, writeNote } from './notes-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',
|
||
])
|
||
|
||
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()
|
||
syncWindowTitle()
|
||
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 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('view:refresh')
|
||
},
|
||
},
|
||
{ type: 'separator' },
|
||
{ role: 'toggleDevTools' },
|
||
{ type: 'separator' },
|
||
{ role: 'resetZoom' },
|
||
{ role: 'zoomIn' },
|
||
{ role: 'zoomOut' },
|
||
{ type: 'separator' },
|
||
{ role: 'togglefullscreen' },
|
||
],
|
||
},
|
||
{ 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()
|
||
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)
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
handle('project:current', () => ({ root: getRoot(), name: getName() }))
|
||
handle('project:open', async (e) => {
|
||
await openFolderFlow(BrowserWindow.fromWebContents(e.sender))
|
||
return { root: getRoot(), name: getName() }
|
||
})
|
||
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() }
|
||
})
|
||
|
||
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) })
|
||
// Scratch note: <project>/.notes.txt, saved when the window loses focus.
|
||
handle('notes:read', () => { const r = getRoot(); return r ? readNote(r) : '' })
|
||
handle('notes:write', (_e, text: string) => { const r = getRoot(); if (r) return writeNote(r, text) })
|
||
|
||
handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
|
||
|
||
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) })
|
||
|
||
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))
|
||
|
||
handle('config:get', () => getConfig())
|
||
handle('config:theme', () => getThemeCss())
|
||
|
||
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) })
|
||
|
||
handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
|
||
handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
|
||
|
||
// 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',
|
||
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'
|
||
})
|
||
}
|
||
|
||
/** 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,
|
||
height: 1040,
|
||
minWidth: 1100,
|
||
minHeight: 680,
|
||
show: false,
|
||
backgroundColor: '#16171a',
|
||
title: getName() || 'Helder',
|
||
titleBarStyle: isMac ? 'hiddenInset' : 'default',
|
||
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
|
||
webPreferences: {
|
||
preload: join(__dirname, '../preload/index.cjs'),
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
sandbox: false,
|
||
},
|
||
})
|
||
|
||
// Keep the renderer's <title>Helder</title> from clobbering the folder name.
|
||
win.on('page-title-updated', (e) => e.preventDefault())
|
||
win.on('ready-to-show', () => win.show())
|
||
|
||
// macOS hides the traffic lights in fullscreen, so the title bar can drop the
|
||
// 82px it reserves for them. Only the main process knows this state, hence IPC.
|
||
function sendFullscreen(): void {
|
||
if (win.isDestroyed()) return
|
||
win.webContents.send('window:fullscreen', win.isFullScreen())
|
||
}
|
||
win.on('enter-full-screen', sendFullscreen)
|
||
win.on('leave-full-screen', sendFullscreen)
|
||
win.webContents.on('did-finish-load', sendFullscreen)
|
||
|
||
watchWindow(win)
|
||
|
||
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 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)
|
||
startWatcher()
|
||
startConfigWatcher()
|
||
startGitWatcher()
|
||
}
|
||
createWindow()
|
||
|
||
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', () => {
|
||
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())
|