update
This commit is contained in:
+8
-2
@@ -10,18 +10,24 @@ import { join } from 'node:path'
|
||||
* 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 type DiffMode = 'original' | 'updated' | 'diff'
|
||||
|
||||
export interface HelderConfig {
|
||||
ai: { command: string; autoLaunch: boolean }
|
||||
editor: { autoSave: boolean; tabSize: number }
|
||||
git: { confirmDiscard: boolean }
|
||||
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode }
|
||||
files: { exclude: string[]; followGitignore: boolean }
|
||||
terminal: { shell: string | null }
|
||||
session: { restoreOnLaunch: boolean }
|
||||
}
|
||||
|
||||
export const DEFAULTS: HelderConfig = {
|
||||
ai: { command: 'claude', autoLaunch: true },
|
||||
editor: { autoSave: false, tabSize: 4 },
|
||||
git: { confirmDiscard: true },
|
||||
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff' },
|
||||
files: { exclude: [], followGitignore: true },
|
||||
terminal: { shell: null },
|
||||
session: { restoreOnLaunch: true },
|
||||
}
|
||||
|
||||
const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in dark theme.
|
||||
|
||||
+85
-16
@@ -1,5 +1,6 @@
|
||||
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { listFiles } from './search-service'
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
@@ -9,7 +10,7 @@ export interface FileNode {
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
/** Directories never walked — noise or huge, and not part of "the project". */
|
||||
/** Directories never walked by the fallback (rg already honors these as globs). */
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
|
||||
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
|
||||
@@ -22,10 +23,62 @@ function ignored(name: string): boolean {
|
||||
return IGNORE_DIRS.has(name) || name === '.DS_Store'
|
||||
}
|
||||
|
||||
/** Recursive project tree, dirs first then files, alphabetical. */
|
||||
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
|
||||
}
|
||||
|
||||
// ---- tree from a flat path list (the rg-backed primary path) ----------------
|
||||
|
||||
function sortTree(node: FileNode): void {
|
||||
if (!node.children) return
|
||||
node.children.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
for (const c of node.children) sortTree(c)
|
||||
}
|
||||
|
||||
/** Build a nested tree from relative file paths (dirs first, alphabetical). */
|
||||
export function buildTreeFromPaths(rootName: string, paths: string[]): FileNode {
|
||||
const root: FileNode = { name: rootName, type: 'dir', path: '', open: true, children: [] }
|
||||
const dirs = new Map<string, FileNode>([['', root]])
|
||||
for (const rel of paths) {
|
||||
const parts = rel.split('/').filter(Boolean)
|
||||
let parentPath = ''
|
||||
let parent = root
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const isFile = i === parts.length - 1
|
||||
const curPath = parentPath ? `${parentPath}/${parts[i]}` : parts[i]
|
||||
if (isFile) {
|
||||
parent.children!.push({ name: parts[i], type: 'file', path: curPath })
|
||||
} else {
|
||||
let dir = dirs.get(curPath)
|
||||
if (!dir) {
|
||||
dir = { name: parts[i], type: 'dir', path: curPath, open: parts.slice(0, i + 1).length <= 1, children: [] }
|
||||
dirs.set(curPath, dir)
|
||||
parent.children!.push(dir)
|
||||
}
|
||||
parent = dir
|
||||
parentPath = curPath
|
||||
}
|
||||
}
|
||||
}
|
||||
sortTree(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function rootName(root: string): string {
|
||||
return root.split(sep).filter(Boolean).pop() || root
|
||||
}
|
||||
|
||||
/** Project tree. Primary: rg file list (honors gitignore + excludes). Fallback:
|
||||
* a plain recursive walk (when ripgrep is unavailable). */
|
||||
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) }
|
||||
const paths = await listFiles(root).catch(() => [] as string[])
|
||||
if (paths.length) return buildTreeFromPaths(rootName(root), paths)
|
||||
return { name: rootName(root), type: 'dir', path: '', open: true, children: await readDir(root, root, 0) }
|
||||
}
|
||||
|
||||
async function readDir(abs: string, root: string, depth: number): Promise<FileNode[]> {
|
||||
@@ -55,12 +108,6 @@ async function readDir(abs: string, root: string, depth: number): Promise<FileNo
|
||||
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))
|
||||
@@ -74,14 +121,38 @@ export async function writeProjectFile(root: string, rel: string, content: strin
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* In-memory content index of all (small, text) files — powers content viewing.
|
||||
* Primary: read the rg file list; fallback: walk. Capped for large repos.
|
||||
*/
|
||||
export async function readAll(root: string): Promise<Record<string, string>> {
|
||||
const paths = await listFiles(root).catch(() => [] as string[])
|
||||
if (paths.length) return readListed(root, paths)
|
||||
return readAllWalk(root)
|
||||
}
|
||||
|
||||
async function readListed(root: string, paths: string[]): Promise<Record<string, string>> {
|
||||
const out: Record<string, string> = {}
|
||||
let count = 0
|
||||
for (const rel of paths) {
|
||||
if (count >= MAX_INDEXED_FILES) break
|
||||
try {
|
||||
const abs = join(root, rel)
|
||||
const s = await stat(abs)
|
||||
if (s.size > MAX_FILE_BYTES) continue
|
||||
const buf = await readFile(abs)
|
||||
if (looksBinary(buf)) continue
|
||||
out[rel] = buf.toString('utf8')
|
||||
count++
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function readAllWalk(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[]
|
||||
@@ -102,8 +173,7 @@ export async function readAll(root: string): Promise<Record<string, string>> {
|
||||
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')
|
||||
out[relative(root, childAbs).split(sep).join('/')] = buf.toString('utf8')
|
||||
count++
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
@@ -111,7 +181,6 @@ export async function readAll(root: string): Promise<Record<string, string>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
+17
-3
@@ -1,4 +1,4 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { readFile, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { simpleGit, type SimpleGit } from 'simple-git'
|
||||
|
||||
@@ -22,7 +22,7 @@ function git(root: string): SimpleGit {
|
||||
}
|
||||
|
||||
/** Map a porcelain code pair to our display letter + staged flag. */
|
||||
function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } {
|
||||
export function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } {
|
||||
const staged = index !== ' ' && index !== '?'
|
||||
const code = staged ? index : working
|
||||
let letter: GitStatusLetter
|
||||
@@ -103,6 +103,20 @@ export async function commit(root: string, message: string): Promise<void> {
|
||||
await git(root).commit(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard working-tree changes for each path:
|
||||
* - exists in HEAD → restore index + worktree to the last commit
|
||||
* - not in HEAD → a new file (staged or untracked): unstage + delete from disk
|
||||
*/
|
||||
export async function discard(root: string, paths: string[]): Promise<void> {
|
||||
await git(root).checkout(['--', ...paths])
|
||||
const g = git(root)
|
||||
for (const p of paths) {
|
||||
const inHead = await g.raw(['cat-file', '-e', `HEAD:${p}`]).then(() => true).catch(() => false)
|
||||
if (inHead) {
|
||||
await g.checkout(['HEAD', '--', p])
|
||||
} else {
|
||||
try { await g.raw(['reset', '-q', 'HEAD', '--', p]) } catch { /* no HEAD / not staged */ }
|
||||
await rm(join(root, p), { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -1,5 +1,5 @@
|
||||
import { join, sep } from 'node:path'
|
||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import { app, dialog, 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'
|
||||
@@ -84,6 +84,20 @@ function registerIpc(): void {
|
||||
|
||||
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 {
|
||||
@@ -97,7 +111,7 @@ function createWindow(): void {
|
||||
titleBarStyle: isMac ? 'hiddenInset' : 'default',
|
||||
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
preload: join(__dirname, '../preload/index.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { relative, sep } from 'node:path'
|
||||
import { getConfig } from './config'
|
||||
|
||||
/** Content search via ripgrep; file-name list via `rg --files`. Substring
|
||||
* (fixed-string), smart-case — matching the prototype's search semantics. */
|
||||
/** ripgrep is the single source of "what files are in the project": it powers
|
||||
* content search, the file-name list, AND the Explorer tree / content index
|
||||
* (via fs-service) — so gitignore + files.exclude are honored everywhere the
|
||||
* same way. Substring (fixed-string), smart-case search. */
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
let rgPath: string | null = null
|
||||
try {
|
||||
rgPath = (require('@vscode/ripgrep') as { rgPath: string }).rgPath
|
||||
// When packaged the binary is unpacked from the asar; rgPath still points
|
||||
// inside app.asar, so redirect it. No-op in dev (path has no app.asar).
|
||||
if (rgPath) rgPath = rgPath.replace(/\bapp\.asar\b/, 'app.asar.unpacked')
|
||||
} catch (e) {
|
||||
console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message)
|
||||
}
|
||||
@@ -16,12 +22,29 @@ try {
|
||||
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}`])
|
||||
/** Always-excluded heavy/noise dirs, on top of gitignore + user excludes. */
|
||||
const BASE_IGNORE = [
|
||||
'node_modules', '.git', 'out', 'dist', 'build', '.cache',
|
||||
'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode',
|
||||
]
|
||||
|
||||
const MAX_FILES = 400
|
||||
const MAX_LINE = 1000
|
||||
|
||||
export function rgAvailable(): boolean {
|
||||
return !!rgPath
|
||||
}
|
||||
|
||||
/** Glob/ignore args derived from config (files.exclude, files.followGitignore). */
|
||||
function ignoreArgs(): string[] {
|
||||
const cfg = getConfig()
|
||||
const args: string[] = []
|
||||
for (const d of BASE_IGNORE) args.push('--glob', `!${d}`)
|
||||
for (const g of cfg.files.exclude) if (g) args.push('--glob', `!${g}`)
|
||||
if (!cfg.files.followGitignore) args.push('--no-ignore')
|
||||
return args
|
||||
}
|
||||
|
||||
function toRel(root: string, p: string): string {
|
||||
return relative(root, p).split(sep).join('/')
|
||||
}
|
||||
@@ -30,9 +53,9 @@ 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',
|
||||
'--json', '--fixed-strings', '--smart-case', '--hidden',
|
||||
'--max-count', '50', '--max-columns', '2000',
|
||||
...IGNORE_GLOBS, '-e', query, '--', root,
|
||||
...ignoreArgs(), '-e', query, '--', root,
|
||||
])
|
||||
const order: string[] = []
|
||||
const groups = new Map<string, ContentGroup>()
|
||||
@@ -65,10 +88,12 @@ export function searchContent(root: string, query: string): Promise<ContentGroup
|
||||
})
|
||||
}
|
||||
|
||||
/** All project files (relative paths), honoring gitignore + excludes. Includes
|
||||
* dotfiles (--hidden) so .env etc. show up unless ignored. */
|
||||
export function listFiles(root: string): Promise<string[]> {
|
||||
return new Promise((resolve) => {
|
||||
if (!rgPath) return resolve([])
|
||||
const child = spawn(rgPath, ['--files', ...IGNORE_GLOBS, '--', root])
|
||||
const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', root])
|
||||
let buf = ''
|
||||
const out: string[] = []
|
||||
let done = false
|
||||
|
||||
Reference in New Issue
Block a user