Files
helder/src/main/index.ts

190 lines
6.9 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, 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<typeof setTimeout> | 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<boolean> {
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 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()
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())