190 lines
6.9 KiB
TypeScript
190 lines
6.9 KiB
TypeScript
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 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())
|