init helder
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Project-scoped settings, living in `.helder/` in the opened project's root.
|
||||
* - config.default.json full built-in defaults, REGENERATED on every launch
|
||||
* (live documentation; the app never reads user edits here)
|
||||
* - config.json sparse — only user-overridden values
|
||||
* - theme.css custom CSS over the built-in dark theme; the CODE FONT
|
||||
* and FONT SIZE live here (as CSS vars), not in the JSON
|
||||
* Effective value = config.json over config.default.json, merged key by key.
|
||||
*/
|
||||
export interface HelderConfig {
|
||||
ai: { command: string; autoLaunch: boolean }
|
||||
editor: { autoSave: boolean; tabSize: number }
|
||||
git: { confirmDiscard: boolean }
|
||||
terminal: { shell: string | null }
|
||||
}
|
||||
|
||||
export const DEFAULTS: HelderConfig = {
|
||||
ai: { command: 'claude', autoLaunch: true },
|
||||
editor: { autoSave: false, tabSize: 4 },
|
||||
git: { confirmDiscard: true },
|
||||
terminal: { shell: null },
|
||||
}
|
||||
|
||||
const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in dark theme.
|
||||
* This file is created once and never overwritten; edit it freely.
|
||||
* The code font and font size live here (not in config.json). Uncomment and
|
||||
* tweak any variable below; you can also override any --token from the built-in
|
||||
* theme (see the design tokens in the app's styles). */
|
||||
:root {
|
||||
/* Code surfaces (editor + terminals) */
|
||||
/* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
|
||||
/* --code-size: 13px; */ /* editor font size */
|
||||
/* --term-size: 12.5px; */ /* terminal font size */
|
||||
|
||||
/* Example accent override: */
|
||||
/* --accent: #4d8dff; */
|
||||
}
|
||||
`
|
||||
|
||||
let current: HelderConfig = DEFAULTS
|
||||
let themeCss = ''
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return !!v && typeof v === 'object' && !Array.isArray(v)
|
||||
}
|
||||
|
||||
function deepMerge<T>(base: T, over: unknown): T {
|
||||
if (!isPlainObject(base) || !isPlainObject(over)) return base
|
||||
const out: Record<string, unknown> = { ...base }
|
||||
for (const key of Object.keys(over)) {
|
||||
const b = (base as Record<string, unknown>)[key]
|
||||
const o = over[key]
|
||||
if (isPlainObject(b) && isPlainObject(o)) out[key] = deepMerge(b, o)
|
||||
else if (o !== undefined) out[key] = o
|
||||
}
|
||||
return out as T
|
||||
}
|
||||
|
||||
/** (Re)resolve config + theme for a project root, regenerating the defaults file. */
|
||||
export async function resolveConfig(root: string): Promise<void> {
|
||||
const dir = join(root, '.helder')
|
||||
try {
|
||||
await mkdir(dir, { recursive: true })
|
||||
// Always regenerate the defaults file — it documents every setting.
|
||||
await writeFile(join(dir, 'config.default.json'), JSON.stringify(DEFAULTS, null, 2) + '\n')
|
||||
|
||||
let override: unknown = {}
|
||||
try { override = JSON.parse(await readFile(join(dir, 'config.json'), 'utf8')) } catch { /* none / invalid */ }
|
||||
current = deepMerge(DEFAULTS, override)
|
||||
|
||||
try {
|
||||
themeCss = await readFile(join(dir, 'theme.css'), 'utf8')
|
||||
} catch {
|
||||
themeCss = THEME_TEMPLATE
|
||||
await writeFile(join(dir, 'theme.css'), THEME_TEMPLATE)
|
||||
}
|
||||
} catch {
|
||||
// Read-only / inaccessible root: fall back to built-in defaults.
|
||||
current = DEFAULTS
|
||||
themeCss = ''
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): HelderConfig {
|
||||
return current
|
||||
}
|
||||
|
||||
export function getThemeCss(): string {
|
||||
return themeCss
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
type: 'dir' | 'file'
|
||||
path: string
|
||||
open?: boolean
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
/** Directories never walked — noise or huge, and not part of "the project". */
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
|
||||
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
|
||||
])
|
||||
|
||||
const MAX_FILE_BYTES = 300_000
|
||||
const MAX_INDEXED_FILES = 6000
|
||||
|
||||
function ignored(name: string): boolean {
|
||||
return IGNORE_DIRS.has(name) || name === '.DS_Store'
|
||||
}
|
||||
|
||||
/** Recursive project tree, dirs first then files, alphabetical. */
|
||||
export async function readTree(root: string): Promise<FileNode> {
|
||||
const name = root.split(sep).filter(Boolean).pop() || root
|
||||
return { name, type: 'dir', path: '', open: true, children: await readDir(root, root, 0) }
|
||||
}
|
||||
|
||||
async function readDir(abs: string, root: string, depth: number): Promise<FileNode[]> {
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await readdir(abs, { withFileTypes: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const dirs: FileNode[] = []
|
||||
const files: FileNode[] = []
|
||||
for (const e of entries) {
|
||||
if (ignored(e.name)) continue
|
||||
const childAbs = join(abs, e.name)
|
||||
const rel = relative(root, childAbs).split(sep).join('/')
|
||||
if (e.isDirectory()) {
|
||||
dirs.push({
|
||||
name: e.name, type: 'dir', path: rel, open: depth < 1,
|
||||
children: depth < 12 ? await readDir(childAbs, root, depth + 1) : [],
|
||||
})
|
||||
} else if (e.isFile()) {
|
||||
files.push({ name: e.name, type: 'file', path: rel })
|
||||
}
|
||||
}
|
||||
dirs.sort((a, b) => a.name.localeCompare(b.name))
|
||||
files.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return [...dirs, ...files]
|
||||
}
|
||||
|
||||
function looksBinary(buf: Buffer): boolean {
|
||||
const n = Math.min(buf.length, 8000)
|
||||
for (let i = 0; i < n; i++) if (buf[i] === 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Read a single text file (relative path) → string. */
|
||||
export async function readProjectFile(root: string, rel: string): Promise<string> {
|
||||
const buf = await readFile(join(root, rel))
|
||||
if (looksBinary(buf)) return ''
|
||||
return buf.toString('utf8')
|
||||
}
|
||||
|
||||
/** 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')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory content index of all (small, text) files — powers content
|
||||
* search and plain-file viewing without touching disk per keystroke. Capped to
|
||||
* keep large repos sane. PHASE: swap content search to ripgrep when scaling up.
|
||||
*/
|
||||
export async function readAll(root: string): Promise<Record<string, string>> {
|
||||
const out: Record<string, string> = {}
|
||||
let count = 0
|
||||
|
||||
async function walk(abs: string): Promise<void> {
|
||||
if (count >= MAX_INDEXED_FILES) return
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await readdir(abs, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (count >= MAX_INDEXED_FILES) return
|
||||
if (ignored(e.name)) continue
|
||||
const childAbs = join(abs, e.name)
|
||||
if (e.isDirectory()) {
|
||||
await walk(childAbs)
|
||||
} else if (e.isFile()) {
|
||||
try {
|
||||
const s = await stat(childAbs)
|
||||
if (s.size > MAX_FILE_BYTES) continue
|
||||
const buf = await readFile(childAbs)
|
||||
if (looksBinary(buf)) continue
|
||||
const rel = relative(root, childAbs).split(sep).join('/')
|
||||
out[rel] = buf.toString('utf8')
|
||||
count++
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { simpleGit, type SimpleGit } from 'simple-git'
|
||||
|
||||
export type GitStatusLetter = 'A' | 'M' | 'D' | 'R' | 'U'
|
||||
|
||||
export interface GitChange {
|
||||
path: string
|
||||
status: GitStatusLetter
|
||||
staged: boolean
|
||||
original: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface GitLoad {
|
||||
branch: string
|
||||
changes: GitChange[]
|
||||
}
|
||||
|
||||
function git(root: string): SimpleGit {
|
||||
return simpleGit({ baseDir: root, maxConcurrentProcesses: 4 })
|
||||
}
|
||||
|
||||
/** Map a porcelain code pair to our display letter + staged flag. */
|
||||
function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } {
|
||||
const staged = index !== ' ' && index !== '?'
|
||||
const code = staged ? index : working
|
||||
let letter: 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
|
||||
}
|
||||
return { letter, staged }
|
||||
}
|
||||
|
||||
async function headText(g: SimpleGit, path: string): Promise<string> {
|
||||
try {
|
||||
return await g.show([`HEAD:${path}`])
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function diskText(root: string, path: string): Promise<string> {
|
||||
try {
|
||||
const buf = await readFile(join(root, path))
|
||||
// skip obvious binaries
|
||||
for (let i = 0; i < Math.min(buf.length, 8000); i++) if (buf[i] === 0) return ''
|
||||
return buf.toString('utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function isRepo(root: string): Promise<boolean> {
|
||||
try {
|
||||
return await git(root).checkIsRepo()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function load(root: string): Promise<GitLoad | null> {
|
||||
const g = git(root)
|
||||
if (!(await isRepo(root))) return null
|
||||
|
||||
const status = await g.status()
|
||||
const branch = status.current || 'HEAD'
|
||||
|
||||
const changes: GitChange[] = []
|
||||
for (const f of status.files) {
|
||||
// simple-git uses path "from -> to" for renames; take the destination.
|
||||
const path = f.path.includes(' -> ') ? f.path.split(' -> ').pop()! : f.path
|
||||
const { letter, staged } = classify(f.index, f.working_dir)
|
||||
const isNew = f.index === '?' || f.index === 'A'
|
||||
const isDeleted = letter === 'D'
|
||||
const original = isNew ? '' : await headText(g, path)
|
||||
const updated = isDeleted ? '' : await diskText(root, path)
|
||||
changes.push({ path, status: letter, staged, original, updated })
|
||||
}
|
||||
|
||||
return { branch, changes }
|
||||
}
|
||||
|
||||
export async function stage(root: string, paths: string[]): Promise<void> {
|
||||
// `git add` stages modifications, additions AND deletions of the given paths.
|
||||
await git(root).add(paths)
|
||||
}
|
||||
|
||||
export async function unstage(root: string, paths: string[]): Promise<void> {
|
||||
try {
|
||||
await git(root).reset(['--', ...paths])
|
||||
} catch {
|
||||
// empty repo (no HEAD yet): fall back to removing from the index.
|
||||
await git(root).raw(['rm', '--cached', '-r', '--', ...paths])
|
||||
}
|
||||
}
|
||||
|
||||
export async function commit(root: string, message: string): Promise<void> {
|
||||
await git(root).commit(message)
|
||||
}
|
||||
|
||||
export async function discard(root: string, paths: string[]): Promise<void> {
|
||||
await git(root).checkout(['--', ...paths])
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { join, sep } from 'node:path'
|
||||
import { app, shell, BrowserWindow, ipcMain } 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, getThemeCss, resolveConfig } 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)
|
||||
}
|
||||
|
||||
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) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
const next = await openDialog(win)
|
||||
if (next) {
|
||||
await resolveConfig(getRoot())
|
||||
startWatcher()
|
||||
startConfigWatcher()
|
||||
}
|
||||
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('search:content', (_e, query: string) => searchContent(getRoot(), query))
|
||||
ipcMain.handle('search:files', () => listFiles(getRoot()))
|
||||
}
|
||||
|
||||
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.js'),
|
||||
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')
|
||||
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())
|
||||
@@ -0,0 +1,45 @@
|
||||
import { basename } from 'node:path'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { dialog, BrowserWindow } from 'electron'
|
||||
|
||||
/**
|
||||
* One project per window. The root is resolved (in order) from $HELDER_PROJECT,
|
||||
* a directory passed on argv, or the process working directory — then it can be
|
||||
* changed at runtime via the Open Folder dialog.
|
||||
*/
|
||||
function resolveInitialRoot(): string {
|
||||
const envRoot = process.env.HELDER_PROJECT
|
||||
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return envRoot
|
||||
const argDir = process.argv.slice(1).find((a) => !a.startsWith('-') && existsSync(a) && safeIsDir(a))
|
||||
if (argDir) return argDir
|
||||
return process.cwd()
|
||||
}
|
||||
|
||||
function safeIsDir(p: string): boolean {
|
||||
try { return statSync(p).isDirectory() } catch { return false }
|
||||
}
|
||||
|
||||
let root = resolveInitialRoot()
|
||||
|
||||
export function getRoot(): string {
|
||||
return root
|
||||
}
|
||||
|
||||
export function getName(): string {
|
||||
return root ? basename(root) || root : 'no project'
|
||||
}
|
||||
|
||||
export function setRoot(next: string): void {
|
||||
root = next
|
||||
}
|
||||
|
||||
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {
|
||||
const res = win
|
||||
? await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
|
||||
: await dialog.showOpenDialog({ properties: ['openDirectory'] })
|
||||
if (!res.canceled && res.filePaths[0]) {
|
||||
root = res.filePaths[0]
|
||||
return root
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import type { WebContents } from 'electron'
|
||||
import { getRoot } from './project'
|
||||
import { getConfig } from './config'
|
||||
|
||||
/**
|
||||
* Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the
|
||||
* bottom pane is a plain shell. node-pty is a native module — loaded defensively
|
||||
* so the app still launches (with a friendly message) if it wasn't rebuilt for
|
||||
* this Electron via `npm run rebuild`.
|
||||
*
|
||||
* Shell + ai command/autoLaunch come from `.helder/config.json` (terminal.shell,
|
||||
* ai.command, ai.autoLaunch) via the config module.
|
||||
*/
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
type PtyModule = typeof import('node-pty')
|
||||
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)
|
||||
}
|
||||
|
||||
const terms = new Map<number, import('node-pty').IPty>()
|
||||
let seq = 0
|
||||
|
||||
function defaultShell(): string {
|
||||
const configured = getConfig().terminal.shell
|
||||
if (configured) return configured
|
||||
if (process.platform === 'win32') return process.env.COMSPEC || 'powershell.exe'
|
||||
return process.env.SHELL || '/bin/zsh'
|
||||
}
|
||||
|
||||
export function ptyAvailable(): boolean {
|
||||
return !!pty
|
||||
}
|
||||
|
||||
export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number {
|
||||
if (!pty) return -1
|
||||
const cwd = getRoot() || process.env.HOME || process.cwd()
|
||||
const proc = pty.spawn(defaultShell(), [], {
|
||||
name: 'xterm-color',
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
cwd,
|
||||
env: process.env as { [key: string]: string },
|
||||
})
|
||||
const id = ++seq
|
||||
terms.set(id, proc)
|
||||
|
||||
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 }) })
|
||||
|
||||
const ai = getConfig().ai
|
||||
if (kind === 'agent' && ai.autoLaunch) {
|
||||
// small delay so the shell prompt is ready before we type the command
|
||||
setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export function writePty(id: number, data: string): void {
|
||||
terms.get(id)?.write(data)
|
||||
}
|
||||
|
||||
export function resizePty(id: number, cols: number, rows: number): void {
|
||||
try { terms.get(id)?.resize(cols, rows) } catch { /* race with exit */ }
|
||||
}
|
||||
|
||||
export function killPty(id: number): void {
|
||||
const p = terms.get(id)
|
||||
if (p) { try { p.kill() } catch { /* already gone */ } terms.delete(id) }
|
||||
}
|
||||
|
||||
export function killAllPtys(): void {
|
||||
for (const p of terms.values()) { try { p.kill() } catch { /* noop */ } }
|
||||
terms.clear()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { relative, sep } from 'node:path'
|
||||
|
||||
/** Content search via ripgrep; file-name list via `rg --files`. Substring
|
||||
* (fixed-string), smart-case — matching the prototype's search semantics. */
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
let rgPath: string | null = null
|
||||
try {
|
||||
rgPath = (require('@vscode/ripgrep') as { rgPath: string }).rgPath
|
||||
} catch (e) {
|
||||
console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message)
|
||||
}
|
||||
|
||||
export interface ContentHit { no: number; ln: string; ix: number }
|
||||
export interface ContentGroup { path: string; hits: ContentHit[] }
|
||||
|
||||
const IGNORE_GLOBS = ['node_modules', '.git', 'out', 'dist', 'build', '.cache', 'vendor', 'coverage', '.helder']
|
||||
.flatMap((d) => ['--glob', `!${d}`])
|
||||
|
||||
const MAX_FILES = 400
|
||||
const MAX_LINE = 1000
|
||||
|
||||
function toRel(root: string, p: string): string {
|
||||
return relative(root, p).split(sep).join('/')
|
||||
}
|
||||
|
||||
export function searchContent(root: string, query: string): Promise<ContentGroup[]> {
|
||||
return new Promise((resolve) => {
|
||||
if (!rgPath || query.trim().length < 2) return resolve([])
|
||||
const child = spawn(rgPath, [
|
||||
'--json', '--fixed-strings', '--smart-case',
|
||||
'--max-count', '50', '--max-columns', '2000',
|
||||
...IGNORE_GLOBS, '-e', query, '--', root,
|
||||
])
|
||||
const order: string[] = []
|
||||
const groups = new Map<string, ContentGroup>()
|
||||
let buf = ''
|
||||
let done = false
|
||||
const finish = (): void => { if (done) return; done = true; resolve(order.slice(0, MAX_FILES).map((p) => groups.get(p)!)) }
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
buf += chunk.toString()
|
||||
let nl: number
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
|
||||
if (!line) continue
|
||||
let msg: { type: string; data: { path?: { text?: string }; lines?: { text?: string }; line_number?: number; submatches?: { start: number }[] } }
|
||||
try { msg = JSON.parse(line) } catch { continue }
|
||||
if (msg.type !== 'match') continue
|
||||
const abs = msg.data.path?.text
|
||||
const text = msg.data.lines?.text
|
||||
if (!abs || text == null) continue
|
||||
const rel = toRel(root, abs)
|
||||
let g = groups.get(rel)
|
||||
if (!g) { if (groups.size >= MAX_FILES) continue; g = { path: rel, hits: [] }; groups.set(rel, g); order.push(rel) }
|
||||
const ln = text.replace(/\n$/, '').slice(0, MAX_LINE)
|
||||
const ix = msg.data.submatches && msg.data.submatches[0] ? msg.data.submatches[0].start : 0
|
||||
g.hits.push({ no: msg.data.line_number || 0, ln, ix: Math.min(ix, ln.length) })
|
||||
}
|
||||
})
|
||||
child.on('close', finish)
|
||||
child.on('error', finish)
|
||||
})
|
||||
}
|
||||
|
||||
export function listFiles(root: string): Promise<string[]> {
|
||||
return new Promise((resolve) => {
|
||||
if (!rgPath) return resolve([])
|
||||
const child = spawn(rgPath, ['--files', ...IGNORE_GLOBS, '--', root])
|
||||
let buf = ''
|
||||
const out: string[] = []
|
||||
let done = false
|
||||
const finish = (): void => { if (done) return; done = true; resolve(out) }
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
buf += chunk.toString()
|
||||
let nl: number
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
|
||||
if (line) out.push(toRel(root, line))
|
||||
}
|
||||
})
|
||||
child.on('close', () => { if (buf.trim()) out.push(toRel(root, buf.trim())); finish() })
|
||||
child.on('error', finish)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user