handling files when stages and dirty at once
This commit is contained in:
@@ -4,15 +4,22 @@ 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 { 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 { 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',
|
||||
@@ -114,6 +121,7 @@ async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> {
|
||||
startWatcher()
|
||||
startConfigWatcher()
|
||||
startGitWatcher()
|
||||
syncWindowTitle()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -144,16 +152,20 @@ function buildAppMenu(): Menu {
|
||||
{
|
||||
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.
|
||||
// ⌘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('project:changed')
|
||||
bw?.webContents.send('view:refresh')
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
@@ -167,10 +179,28 @@ function buildAppMenu(): Menu {
|
||||
],
|
||||
},
|
||||
{ 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()
|
||||
@@ -186,54 +216,108 @@ function startWatcher(): void {
|
||||
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 {
|
||||
ipcMain.handle('project:current', () => ({ root: getRoot(), name: getName() }))
|
||||
ipcMain.handle('project:open', async (e) => {
|
||||
handle('project:current', () => ({ root: getRoot(), name: getName() }))
|
||||
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) => {
|
||||
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() }
|
||||
})
|
||||
|
||||
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)) })
|
||||
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) })
|
||||
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) })
|
||||
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) })
|
||||
|
||||
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))
|
||||
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))
|
||||
|
||||
ipcMain.handle('config:get', () => getConfig())
|
||||
ipcMain.handle('config:theme', () => getThemeCss())
|
||||
handle('config:get', () => getConfig())
|
||||
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) })
|
||||
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) })
|
||||
|
||||
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) : [] })
|
||||
handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
|
||||
handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
|
||||
|
||||
ipcMain.handle('dialog:unsavedClose', async (e, path: string) => {
|
||||
// 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',
|
||||
@@ -248,6 +332,13 @@ function registerIpc(): void {
|
||||
})
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -256,6 +347,7 @@ function createWindow(): void {
|
||||
minHeight: 680,
|
||||
show: false,
|
||||
backgroundColor: '#16171a',
|
||||
title: getName() || 'Helder',
|
||||
titleBarStyle: isMac ? 'hiddenInset' : 'default',
|
||||
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
|
||||
webPreferences: {
|
||||
@@ -266,7 +358,10 @@ function createWindow(): void {
|
||||
},
|
||||
})
|
||||
|
||||
// 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())
|
||||
watchWindow(win)
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
@@ -281,10 +376,14 @@ function createWindow(): void {
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
app.setName('Helder')
|
||||
// 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)
|
||||
@@ -297,6 +396,10 @@ app.whenReady().then(async () => {
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user