handling files when stages and dirty at once
This commit is contained in:
+2
-2
@@ -15,7 +15,7 @@ export type DiffMode = 'original' | 'updated' | 'diff'
|
||||
export interface HelderConfig {
|
||||
ai: { command: string; autoLaunch: boolean }
|
||||
editor: { autoSave: boolean; tabSize: number }
|
||||
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode }
|
||||
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
|
||||
files: { exclude: string[]; followGitignore: boolean }
|
||||
terminal: { shell: string | null }
|
||||
session: { restoreOnLaunch: boolean }
|
||||
@@ -24,7 +24,7 @@ export interface HelderConfig {
|
||||
export const DEFAULTS: HelderConfig = {
|
||||
ai: { command: 'claude', autoLaunch: true },
|
||||
editor: { autoSave: false, tabSize: 4 },
|
||||
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff' },
|
||||
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
|
||||
files: { exclude: [], followGitignore: false },
|
||||
terminal: { shell: null },
|
||||
session: { restoreOnLaunch: true },
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { app, crashReporter, dialog, shell, BrowserWindow, type WebContents } from 'electron'
|
||||
import { formatErr, getLogDir, getLogPath, initLogger, log, logger } from './logger'
|
||||
|
||||
/**
|
||||
* Everything that turns a silent death into a log line. Wires the process-,
|
||||
* app- and window-level failure hooks Electron gives us, none of which were
|
||||
* connected before — which is why crashes left no trace.
|
||||
*
|
||||
* The hooks, and the crash each one actually catches:
|
||||
* uncaughtException / unhandledRejection → a throw in OUR main-process code
|
||||
* render-process-gone → the renderer died (OOM, segfault):
|
||||
* the classic "window went blank//white"
|
||||
* child-process-gone → GPU / utility process died
|
||||
* preload-error → preload threw: `window.helder` is
|
||||
* undefined and the app silently falls
|
||||
* back to MOCK DATA (see CLAUDE.md)
|
||||
* unresponsive → main thread wedged (the beachball)
|
||||
* crashReporter minidumps → NATIVE crashes (node-pty is native,
|
||||
* and a segfault there takes the whole
|
||||
* process down with no JS hook at all)
|
||||
*/
|
||||
|
||||
let fatalDialogOpen = false
|
||||
|
||||
/** Call FIRST, before app.whenReady() — crashReporter must start early to catch
|
||||
* native crashes, and the log file should exist before anything can fail. */
|
||||
export function initDiagnostics(isDev: boolean): void {
|
||||
// app.getPath('logs') is ~/Library/Logs/<name> on macOS, so the name must be
|
||||
// set before we ask for the path or the folder is called "Electron".
|
||||
app.setName('Helder')
|
||||
|
||||
initLogger({ dir: app.getPath('logs'), mirror: isDev })
|
||||
|
||||
// Native minidumps for crashes no JS handler can see. Local only — nothing is
|
||||
// uploaded anywhere (there is no server, and this is a personal tool).
|
||||
try {
|
||||
crashReporter.start({ productName: 'Helder', companyName: 'Helder', uploadToServer: false })
|
||||
} catch (e) {
|
||||
logger.warn('crash', 'crashReporter failed to start', { err: formatErr(e) })
|
||||
}
|
||||
|
||||
logger.info('session', 'starting', {
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
chrome: process.versions.chrome,
|
||||
node: process.versions.node,
|
||||
platform: `${process.platform} ${process.arch}`,
|
||||
packaged: app.isPackaged,
|
||||
dev: isDev,
|
||||
crashDumps: app.getPath('crashDumps'),
|
||||
argv: process.argv.slice(1),
|
||||
project: process.env.HELDER_PROJECT ?? null,
|
||||
})
|
||||
|
||||
installProcessHooks()
|
||||
installAppHooks()
|
||||
}
|
||||
|
||||
function installProcessHooks(): void {
|
||||
process.on('uncaughtException', (err, origin) => {
|
||||
logger.error('fatal', `uncaughtException (${origin})`, err)
|
||||
showFatal(err)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
// Not fatal in itself, but it's how a forgotten `await` on a failing IPC
|
||||
// handler shows up — and the stack here is the only place the cause exists.
|
||||
logger.error('fatal', 'unhandledRejection', reason)
|
||||
})
|
||||
|
||||
process.on('warning', (w) => {
|
||||
// Surfaces the "MaxListenersExceeded" / deprecation warnings that precede
|
||||
// a leak-driven crash.
|
||||
logger.warn('node', w.name, { message: w.message, stack: w.stack })
|
||||
})
|
||||
|
||||
app.on('before-quit', () => logger.info('session', 'quitting'))
|
||||
}
|
||||
|
||||
function installAppHooks(): void {
|
||||
// THE renderer-crash hook. `reason` is the useful bit: 'crashed', 'oom',
|
||||
// 'killed', 'launch-failed'.
|
||||
app.on('render-process-gone', (_e, contents, details) => {
|
||||
logger.error('renderer', `render process gone: ${details.reason}`, undefined, {
|
||||
exitCode: details.exitCode,
|
||||
reason: details.reason,
|
||||
url: safeUrl(contents),
|
||||
})
|
||||
if (details.reason !== 'clean-exit') {
|
||||
showFatal(new Error(`The window crashed (${details.reason}, exit ${details.exitCode}). See the log for details.`))
|
||||
}
|
||||
})
|
||||
|
||||
app.on('child-process-gone', (_e, details) => {
|
||||
logger.error('child', `${details.type} process gone: ${details.reason}`, undefined, {
|
||||
exitCode: details.exitCode,
|
||||
serviceName: details.serviceName,
|
||||
name: details.name,
|
||||
})
|
||||
})
|
||||
|
||||
// A preload failure is silent-by-design in Electron and the single nastiest
|
||||
// failure mode this app has: no window.helder → the renderer quietly serves
|
||||
// mock data and "terminal not available", as if nothing were wrong.
|
||||
app.on('web-contents-created', (_e, contents) => {
|
||||
contents.on('preload-error', (_ev, preloadPath, error) => {
|
||||
logger.error('preload', 'preload script threw — window.helder will be undefined (mock-data fallback)', error, { preloadPath })
|
||||
})
|
||||
contents.on('console-message', (...a: unknown[]) => {
|
||||
// Electron ≥36 passes a single event object; older versions pass
|
||||
// (event, level, message, line, sourceId). Support both so a version bump
|
||||
// doesn't quietly stop capturing renderer console output.
|
||||
const d = normaliseConsoleMessage(a)
|
||||
if (!d || d.level < 2) return // warnings + errors only; skip log/info noise
|
||||
log(d.level >= 3 ? 'error' : 'warn', 'console', d.message, { source: d.source, line: d.line })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Electron changed the console-message signature in v36; accept both shapes. */
|
||||
export function normaliseConsoleMessage(args: unknown[]): { level: number; message: string; source: string; line: number } | null {
|
||||
const first = args[0] as Record<string, unknown> | undefined
|
||||
if (first && typeof first === 'object' && 'message' in first && 'level' in first) {
|
||||
const lvl = first.level
|
||||
const asNum = typeof lvl === 'string' ? { debug: 0, info: 1, verbose: 1, warning: 2, error: 3 }[lvl] ?? 1 : Number(lvl)
|
||||
return {
|
||||
level: asNum,
|
||||
message: String(first.message),
|
||||
source: String(first.sourceId ?? ''),
|
||||
line: Number(first.lineNumber ?? 0),
|
||||
}
|
||||
}
|
||||
if (args.length >= 3 && typeof args[1] === 'number') {
|
||||
return { level: args[1] as number, message: String(args[2]), source: String(args[4] ?? ''), line: Number(args[3] ?? 0) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function safeUrl(contents: WebContents | null): string {
|
||||
try { return contents?.getURL() ?? '' } catch { return '' }
|
||||
}
|
||||
|
||||
/** Watch for the beachball: log it (with a stack-free note) rather than let the
|
||||
* user guess whether the app is hung or just slow. */
|
||||
export function watchWindow(win: BrowserWindow): void {
|
||||
win.on('unresponsive', () => logger.warn('window', 'became unresponsive (main thread blocked)'))
|
||||
win.on('responsive', () => logger.info('window', 'responsive again'))
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
logger.error('window', 'did-fail-load', undefined, { code, desc, url })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the user something died, and put the log one click away — a crash the
|
||||
* user can't report is a crash we can't fix. Guarded so a crash loop doesn't
|
||||
* stack a hundred dialogs.
|
||||
*/
|
||||
function showFatal(err: unknown): void {
|
||||
if (fatalDialogOpen) return
|
||||
fatalDialogOpen = true
|
||||
const { message } = formatErr(err)
|
||||
const logPath = getLogPath()
|
||||
Promise.resolve(dialog.showMessageBox({
|
||||
type: 'error',
|
||||
buttons: logPath ? ['Open Log', 'Ignore'] : ['Ignore'],
|
||||
defaultId: 0,
|
||||
cancelId: logPath ? 1 : 0,
|
||||
message: 'Helder hit an error',
|
||||
detail: `${message}\n\n${logPath ? `Logged to ${logPath}` : ''}`,
|
||||
})).then(({ response }) => {
|
||||
if (logPath && response === 0) openLog()
|
||||
}).catch(() => { /* dialog can fail pre-ready; the log line is what matters */ })
|
||||
.finally(() => { fatalDialogOpen = false })
|
||||
}
|
||||
|
||||
/** Open the log in the default text editor. */
|
||||
export function openLog(): void {
|
||||
const p = getLogPath()
|
||||
if (p) shell.openPath(p).catch(() => {})
|
||||
}
|
||||
|
||||
/** Reveal the log folder (all rotated files + siblings) in Finder. */
|
||||
export function revealLog(): void {
|
||||
const p = getLogPath()
|
||||
if (p) shell.showItemInFolder(p)
|
||||
}
|
||||
|
||||
export { getLogDir, getLogPath }
|
||||
@@ -202,6 +202,32 @@ export async function readProjectFile(root: string, rel: string): Promise<string
|
||||
return buf.toString('utf8')
|
||||
}
|
||||
|
||||
const IMAGE_MIME: Record<string, string> = {
|
||||
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', jfif: 'image/jpeg',
|
||||
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp',
|
||||
ico: 'image/x-icon', avif: 'image/avif', apng: 'image/apng',
|
||||
}
|
||||
const MAX_IMAGE_BYTES = 25_000_000
|
||||
|
||||
/** Read an image file as a `data:` URL for the viewer's <img> — the renderer
|
||||
* can't touch the filesystem, and a data URL sidesteps file:// path/escaping
|
||||
* concerns entirely. Returns '' for a non-image extension, a path escaping the
|
||||
* root, or an oversized/unreadable file. */
|
||||
export async function readImageDataUrl(root: string, rel: string): Promise<string> {
|
||||
const ext = rel.split('.').pop()?.toLowerCase() ?? ''
|
||||
const mime = IMAGE_MIME[ext]
|
||||
if (!mime) return ''
|
||||
const target = join(root, rel)
|
||||
if (relative(root, target).startsWith('..')) return ''
|
||||
try {
|
||||
const buf = await readFile(target)
|
||||
if (buf.length > MAX_IMAGE_BYTES) return ''
|
||||
return `data:${mime};base64,${buf.toString('base64')}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a text file (relative path). Used by the editable buffer's save. */
|
||||
export async function writeProjectFile(root: string, rel: string, content: string): Promise<void> {
|
||||
await writeFile(join(root, rel), content, 'utf8')
|
||||
|
||||
+67
-19
@@ -76,19 +76,41 @@ async function git(root: string, args: string[]): Promise<string> {
|
||||
return stdout
|
||||
}
|
||||
|
||||
/** Map a porcelain code pair to our display letter + staged flag. */
|
||||
export function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } {
|
||||
const staged = index !== ' ' && index !== '?'
|
||||
const code = staged ? index : working
|
||||
let letter: GitStatusLetter
|
||||
/** Map one porcelain status code to our display letter. */
|
||||
function letterFor(code: string): GitStatusLetter {
|
||||
switch (code) {
|
||||
case 'A': case 'C': case '?': letter = 'A'; break
|
||||
case 'D': letter = 'D'; break
|
||||
case 'R': letter = 'R'; break
|
||||
case 'U': letter = 'M'; break
|
||||
case 'M': default: letter = 'M'; break
|
||||
case 'A': case 'C': case '?': return 'A'
|
||||
case 'D': return 'D'
|
||||
case 'R': return 'R'
|
||||
case 'U': return 'M'
|
||||
default: return 'M'
|
||||
}
|
||||
return { letter, staged }
|
||||
}
|
||||
|
||||
/** A merge conflict. Git reports both sides, but neither half can be staged on
|
||||
* its own, so a conflict stays one row. */
|
||||
function isConflict(index: string, working: string): boolean {
|
||||
return index === 'U' || working === 'U'
|
||||
|| (index === 'A' && working === 'A')
|
||||
|| (index === 'D' && working === 'D')
|
||||
}
|
||||
|
||||
export interface GitRowSpec { letter: GitStatusLetter; staged: boolean }
|
||||
|
||||
/**
|
||||
* Split a porcelain code pair into the rows the git panel shows.
|
||||
*
|
||||
* A file can be staged AND changed again on disk. Git reports that as "MM".
|
||||
* That is two rows: one staged (HEAD vs index) and one unstaged (index vs
|
||||
* disk). Folding it into a single row hid the newer edit completely.
|
||||
*/
|
||||
export function classify(index: string, working: string): GitRowSpec[] {
|
||||
if (isConflict(index, working)) return [{ letter: 'M', staged: true }]
|
||||
const rows: GitRowSpec[] = []
|
||||
if (index !== ' ' && index !== '?') rows.push({ letter: letterFor(index), staged: true })
|
||||
if (working !== ' ') rows.push({ letter: letterFor(working), staged: false })
|
||||
// Should not happen (git does not report a clean file), but never drop an entry.
|
||||
return rows.length ? rows : [{ letter: letterFor(index), staged: true }]
|
||||
}
|
||||
|
||||
/** Parse a `## ...` porcelain branch header into a display branch name. */
|
||||
@@ -135,6 +157,15 @@ async function headText(root: string, path: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** The staged copy of a file: the blob sitting in the index. */
|
||||
async function indexText(root: string, path: string): Promise<string> {
|
||||
try {
|
||||
return await git(root, ['show', `:${path}`])
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function diskText(root: string, path: string): Promise<string> {
|
||||
try {
|
||||
const buf = await readFile(join(root, path))
|
||||
@@ -214,14 +245,31 @@ async function doLoad(root: string): Promise<GitLoad | null> {
|
||||
// re-reads ALL of them, which is the dominant cost of a reload. Run them with
|
||||
// bounded concurrency instead so the spawns overlap (cap keeps us well under
|
||||
// macOS's low default FD limit). Order is preserved by index.
|
||||
const changes = await mapLimit(files, 12, async (f) => {
|
||||
const { letter, staged } = classify(f.index, f.working)
|
||||
const isNew = f.index === '?' || f.index === 'A'
|
||||
const isDeleted = letter === 'D'
|
||||
const original = isNew ? '' : await headText(root, f.path)
|
||||
const updated = isDeleted ? '' : await diskText(root, f.path)
|
||||
return { path: f.path, status: letter, staged, original, updated } as GitChange
|
||||
})
|
||||
const changes = (await mapLimit(files, 12, async (f) => {
|
||||
const rows = classify(f.index, f.working)
|
||||
const stagedRow = rows.find((r) => r.staged)
|
||||
const workRow = rows.find((r) => !r.staged)
|
||||
// The index blob is only needed when a file sits in BOTH groups. With one
|
||||
// row the index copy equals HEAD (unstaged only) or the disk copy (staged
|
||||
// only), so the common case still costs no extra `git show`.
|
||||
const both = !!stagedRow && !!workRow
|
||||
const idx = both ? await indexText(root, f.path) : ''
|
||||
const out: GitChange[] = []
|
||||
if (stagedRow) {
|
||||
// Staged row: HEAD -> index.
|
||||
const original = stagedRow.letter === 'A' ? '' : await headText(root, f.path)
|
||||
const updated = stagedRow.letter === 'D' ? '' : both ? idx : await diskText(root, f.path)
|
||||
out.push({ path: f.path, status: stagedRow.letter, staged: true, original, updated })
|
||||
}
|
||||
if (workRow) {
|
||||
// Unstaged row: index -> disk. An untracked file has no index copy.
|
||||
const untracked = f.index === '?'
|
||||
const original = untracked ? '' : both ? idx : await headText(root, f.path)
|
||||
const updated = workRow.letter === 'D' ? '' : await diskText(root, f.path)
|
||||
out.push({ path: f.path, status: workRow.letter, staged: false, original, updated })
|
||||
}
|
||||
return out
|
||||
})).flat()
|
||||
|
||||
return { branch, changes }
|
||||
}
|
||||
|
||||
+141
-38
@@ -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', () => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* The app's one log sink: a plain-text file under the OS log dir, written
|
||||
* SYNCHRONOUSLY so a line survives the process dying moments later.
|
||||
*
|
||||
* Why a file at all: `console.*` goes nowhere in real use. Helder runs one
|
||||
* process per project window, and every window past the first is spawned by
|
||||
* `spawnInstance()` with `stdio: 'ignore'` — its output is discarded. Launched
|
||||
* from Finder there's no terminal attached either. Before this, a crash left
|
||||
* literally no trace; that's what made "it crashed sometimes" undebuggable.
|
||||
*
|
||||
* This module deliberately does NOT import electron, so it stays unit-testable.
|
||||
* `initLogger()` is handed the directory by the caller (see diagnostics.ts).
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
|
||||
|
||||
/** Rotate at 2 MB, keep 3 old files (~8 MB worst case for a dev tool's log). */
|
||||
const MAX_BYTES = 2 * 1024 * 1024
|
||||
const KEEP = 3
|
||||
|
||||
interface LoggerState {
|
||||
file: string | null
|
||||
dir: string | null
|
||||
min: number
|
||||
mirror: boolean
|
||||
/** Byte size tracked in-process so the common path avoids a stat() per line. */
|
||||
size: number
|
||||
}
|
||||
|
||||
const state: LoggerState = { file: null, dir: null, min: LEVELS.debug, mirror: false, size: 0 }
|
||||
|
||||
/** Absolute path of the active log file, or null before initLogger(). */
|
||||
export function getLogPath(): string | null {
|
||||
return state.file
|
||||
}
|
||||
|
||||
export function getLogDir(): string | null {
|
||||
return state.dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the logger at `dir` (created if needed). Safe to call once per process.
|
||||
* `mirror` also echoes to the console, which is useful in `npm run dev` where a
|
||||
* terminal IS attached. `level` gates the floor (default: everything).
|
||||
*/
|
||||
export function initLogger(opts: { dir: string; mirror?: boolean; level?: LogLevel }): void {
|
||||
state.dir = opts.dir
|
||||
state.file = join(opts.dir, 'helder.log')
|
||||
state.mirror = !!opts.mirror
|
||||
state.min = LEVELS[opts.level ?? 'debug']
|
||||
try {
|
||||
mkdirSync(opts.dir, { recursive: true })
|
||||
state.size = statSync(state.file).size
|
||||
} catch {
|
||||
// Missing file is the normal first-run case (size stays 0). A genuinely
|
||||
// unwritable dir surfaces on the first write() instead, which no-ops.
|
||||
state.size = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `helder.log` → `helder.1.log` → … → dropped after KEEP. Called when the live
|
||||
* file crosses MAX_BYTES. Several project processes share one file and could in
|
||||
* principle rotate at the same moment; the renames are best-effort and a lost
|
||||
* race costs at most some log lines, never a crash — hence the blanket catch.
|
||||
*/
|
||||
function rotate(): void {
|
||||
const dir = state.dir
|
||||
const file = state.file
|
||||
if (!dir || !file) return
|
||||
try {
|
||||
const oldest = join(dir, `helder.${KEEP}.log`)
|
||||
try { unlinkSync(oldest) } catch { /* wasn't there */ }
|
||||
for (let i = KEEP - 1; i >= 1; i--) {
|
||||
try { renameSync(join(dir, `helder.${i}.log`), join(dir, `helder.${i + 1}.log`)) } catch { /* gap in the chain */ }
|
||||
}
|
||||
renameSync(file, join(dir, 'helder.1.log'))
|
||||
state.size = 0
|
||||
} catch { /* another process rotated first; keep appending */ }
|
||||
}
|
||||
|
||||
/** JSON that can't throw on cycles/BigInt — a logger must never be the crash. */
|
||||
function safeJson(value: unknown): string {
|
||||
const seen = new WeakSet<object>()
|
||||
try {
|
||||
return JSON.stringify(value, (_k, v) => {
|
||||
if (typeof v === 'bigint') return `${v}n`
|
||||
if (typeof v === 'function') return `[Function ${v.name || 'anonymous'}]`
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
if (seen.has(v as object)) return '[Circular]'
|
||||
seen.add(v as object)
|
||||
}
|
||||
return v
|
||||
}) ?? String(value)
|
||||
} catch {
|
||||
return '[unserializable]'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise anything thrown into a loggable shape. Non-Errors get stringified
|
||||
* (people throw strings), and `cause` is followed so wrapped errors keep their
|
||||
* root cause — usually the line that actually explains the failure.
|
||||
*/
|
||||
export function formatErr(e: unknown): { message: string; stack?: string; cause?: string } {
|
||||
if (e instanceof Error) {
|
||||
const out: { message: string; stack?: string; cause?: string } = { message: e.message }
|
||||
if (e.stack) out.stack = e.stack
|
||||
if (e.cause !== undefined) out.cause = e.cause instanceof Error ? (e.cause.stack || e.cause.message) : safeJson(e.cause)
|
||||
return out
|
||||
}
|
||||
return { message: typeof e === 'string' ? e : safeJson(e) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One log line: ISO ts · level · pid · scope · message · context JSON.
|
||||
*
|
||||
* Continuation lines are indented, never bare: a message can carry newlines of
|
||||
* its own (Electron's console warnings do, and so does any stack passed as the
|
||||
* message), and an unindented second line is indistinguishable from a new entry
|
||||
* to both a human skimming the file and to `grep`.
|
||||
*/
|
||||
export function formatLine(level: LogLevel, scope: string, msg: string, ctx: unknown, pid: number, now: string): string {
|
||||
const head = `${now} ${level.toUpperCase().padEnd(5)} ${String(pid).padStart(5)} ${scope.padEnd(9)} ${indent(msg)}`
|
||||
if (ctx === undefined) return head + '\n'
|
||||
return head + ' ' + indent(safeJson(ctx)) + '\n'
|
||||
}
|
||||
|
||||
function indent(s: string): string {
|
||||
return s.replace(/\r?\n/g, '\n ')
|
||||
}
|
||||
|
||||
export function log(level: LogLevel, scope: string, msg: string, ctx?: unknown): void {
|
||||
if (LEVELS[level] < state.min) return
|
||||
const line = formatLine(level, scope, msg, ctx, process.pid, new Date().toISOString())
|
||||
|
||||
if (state.mirror) {
|
||||
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
|
||||
fn(line.trimEnd())
|
||||
}
|
||||
|
||||
const file = state.file
|
||||
if (!file) return
|
||||
if (state.size + line.length > MAX_BYTES) rotate()
|
||||
try {
|
||||
// Sync + O_APPEND: the write lands before an imminent crash can eat it, and
|
||||
// concurrent appends from sibling project processes don't interleave.
|
||||
appendFileSync(file, line, { encoding: 'utf8' })
|
||||
state.size += Buffer.byteLength(line)
|
||||
} catch { /* disk full / no permission — never let logging break the app */ }
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (scope: string, msg: string, ctx?: unknown): void => log('debug', scope, msg, ctx),
|
||||
info: (scope: string, msg: string, ctx?: unknown): void => log('info', scope, msg, ctx),
|
||||
warn: (scope: string, msg: string, ctx?: unknown): void => log('warn', scope, msg, ctx),
|
||||
error: (scope: string, msg: string, err?: unknown, ctx?: Record<string, unknown>): void =>
|
||||
log('error', scope, msg, err === undefined ? ctx : { ...ctx, err: formatErr(err) }),
|
||||
}
|
||||
|
||||
/** Reset for tests. Not used by the app. */
|
||||
export function _resetLogger(): void {
|
||||
state.file = null; state.dir = null; state.min = LEVELS.debug; state.mirror = false; state.size = 0
|
||||
}
|
||||
+33
-6
@@ -2,6 +2,7 @@ import { createRequire } from 'node:module'
|
||||
import type { WebContents } from 'electron'
|
||||
import { getRoot } from './project'
|
||||
import { getConfig } from './config'
|
||||
import { logger } from './logger'
|
||||
|
||||
/**
|
||||
* Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the
|
||||
@@ -19,10 +20,14 @@ let pty: PtyModule | null = null
|
||||
try {
|
||||
pty = require('node-pty') as PtyModule
|
||||
} catch (e) {
|
||||
console.error('[helder] node-pty unavailable — run `npm run rebuild`:', (e as Error).message)
|
||||
logger.error('pty', 'node-pty unavailable — run `npm run rebuild`', e)
|
||||
}
|
||||
|
||||
const terms = new Map<number, import('node-pty').IPty>()
|
||||
/** Ids we killed on purpose (pane closed, window quitting, StrictMode remount).
|
||||
* Their exit is expected, so it must NOT be logged as a warning — a log full of
|
||||
* false alarms is a log nobody reads. */
|
||||
const killing = new Set<number>()
|
||||
let seq = 0
|
||||
|
||||
/**
|
||||
@@ -45,6 +50,13 @@ function ptyEnv(): { [key: string]: string } {
|
||||
if (process.platform !== 'win32' && !env.LC_ALL && !env.LC_CTYPE && !env.LANG) {
|
||||
env.LANG = 'en_US.UTF-8'
|
||||
}
|
||||
// Same inheritance gap as locale, but for color: a terminal launch leaks
|
||||
// COLORTERM=truecolor so `claude` renders its UI backgrounds as exact 24-bit
|
||||
// colors; the GUI-launched packaged app has none, so claude falls back to a
|
||||
// 256/16-color approximation and the same backgrounds shift shade. Match both.
|
||||
if (process.platform !== 'win32' && !env.COLORTERM) {
|
||||
env.COLORTERM = 'truecolor'
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
@@ -60,7 +72,10 @@ export function ptyAvailable(): boolean {
|
||||
}
|
||||
|
||||
export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number {
|
||||
if (!pty) return -1
|
||||
if (!pty) {
|
||||
logger.warn('pty', `create(${kind}) refused — node-pty never loaded`)
|
||||
return -1
|
||||
}
|
||||
const cwd = getRoot() || process.env.HOME || process.cwd()
|
||||
const shell = defaultShell()
|
||||
const ai = getConfig().ai
|
||||
@@ -71,7 +86,7 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
|
||||
// echoed command cluttering the pane; claude takes over a clean terminal.
|
||||
const args = launchAgent ? ['-i', '-c', ai.command] : []
|
||||
const proc = pty.spawn(shell, args, {
|
||||
name: 'xterm-color',
|
||||
name: 'xterm-256color',
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
cwd,
|
||||
@@ -79,9 +94,21 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
|
||||
})
|
||||
const id = ++seq
|
||||
terms.set(id, proc)
|
||||
logger.info('pty', `spawned ${kind}`, { id, pid: proc.pid, shell, args, cwd })
|
||||
|
||||
proc.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) })
|
||||
proc.onExit(() => { terms.delete(id); if (!sender.isDestroyed()) sender.send('pty:exit', { id }) })
|
||||
proc.onExit(({ exitCode, signal }) => {
|
||||
terms.delete(id)
|
||||
// The agent pane dying on its own (`claude` not on PATH, OOM-killed,
|
||||
// segfault) looks from the UI like "the terminal just went blank" — the exit
|
||||
// code and signal are the only evidence of what actually happened. An exit we
|
||||
// asked for is routine, so only an unrequested one is a warning.
|
||||
const expected = killing.delete(id)
|
||||
const abnormal = !expected && (exitCode !== 0 || (signal != null && signal !== 0))
|
||||
if (abnormal) logger.warn('pty', `${kind} exited unexpectedly`, { id, exitCode, signal })
|
||||
else logger.info('pty', `${kind} exited`, { id, exitCode, expected })
|
||||
if (!sender.isDestroyed()) sender.send('pty:exit', { id })
|
||||
})
|
||||
|
||||
// Windows path keeps the type-into-shell launch (no `-i -c` semantics there).
|
||||
if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') {
|
||||
@@ -100,10 +127,10 @@ export function resizePty(id: number, cols: number, rows: number): void {
|
||||
|
||||
export function killPty(id: number): void {
|
||||
const p = terms.get(id)
|
||||
if (p) { try { p.kill() } catch { /* already gone */ } terms.delete(id) }
|
||||
if (p) { killing.add(id); try { p.kill() } catch { /* already gone */ } terms.delete(id) }
|
||||
}
|
||||
|
||||
export function killAllPtys(): void {
|
||||
for (const p of terms.values()) { try { p.kill() } catch { /* noop */ } }
|
||||
for (const [id, p] of terms) { killing.add(id); try { p.kill() } catch { /* noop */ } }
|
||||
terms.clear()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user