This commit is contained in:
2026-06-16 06:18:42 +02:00
parent 3f5078841d
commit 66248c4736
39 changed files with 6699 additions and 94 deletions

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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 })
}
}
}

View File

@@ -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 dont 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,

View File

@@ -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

View File

@@ -61,6 +61,11 @@ const api = {
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
},
dialog: {
unsavedClose: (path: string): Promise<'save' | 'discard' | 'cancel'> =>
ipcRenderer.invoke('dialog:unsavedClose', path),
},
/** Subscribe to "the project changed on disk" pings. Returns an unsubscribe. */
onProjectChanged: (cb: () => void): (() => void) => {
const handler = (): void => cb()

View File

@@ -10,6 +10,7 @@ import { ContextMenu, PassPopup, SearchModal, Toasts } from './overlays'
import type { Menu, Toast } from './overlays'
import type { FileNode, GitStatus } from './types'
import { useProject, useProjectActions } from './project'
import { loadJson, loadNum, saveJson, saveNum } from './persist'
const NO_COMMITTED = new Set<string>()
@@ -36,8 +37,9 @@ function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onD
}
function RightColumn({ width }: { width: number }): React.ReactElement {
const [topFrac, setTopFrac] = useState(0.52)
const [topFrac, setTopFrac] = useState(() => loadNum('helder.topFrac', 0.52))
const ref = useRef<HTMLDivElement>(null)
useEffect(() => saveNum('helder.topFrac', topFrac), [topFrac])
function delta(_dx: number, dy: number): void {
const h = ref.current ? ref.current.clientHeight : 600
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)))
@@ -94,6 +96,7 @@ export function App(): React.ReactElement {
// Editable buffers: path → current text (absent = clean, showing on-disk content).
const [buffers, setBuffers] = useState<Record<string, string>>({})
const buffersRef = useRef(buffers); buffersRef.current = buffers
const projRef = useRef(proj); projRef.current = proj
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
function diskText(path: string): string { return proj.files[path] ?? '' }
@@ -127,9 +130,12 @@ export function App(): React.ReactElement {
toast('Discarded changes', path)
}
const [gitW, setGitW] = useState(232)
const [treeW, setTreeW] = useState(244)
const [rightW, setRightW] = useState(444)
const [gitW, setGitW] = useState(() => loadNum('helder.gitW', 232))
const [treeW, setTreeW] = useState(() => loadNum('helder.treeW', 244))
const [rightW, setRightW] = useState(() => loadNum('helder.rightW', 444))
useEffect(() => saveNum('helder.gitW', gitW), [gitW])
useEffect(() => saveNum('helder.treeW', treeW), [treeW])
useEffect(() => saveNum('helder.rightW', rightW), [rightW])
// Seed explorer expansion from the tree's `open` flags once per opened project.
const seededRoot = useRef<string | null | undefined>(undefined)
@@ -140,6 +146,26 @@ export function App(): React.ReactElement {
}
}, [proj.tree, proj.root])
// Session restore (session.restoreOnLaunch): bring back the open tabs, active
// tab and per-tab view modes for this project, then keep them persisted.
const sessionRoot = useRef<string | null | undefined>(undefined)
useEffect(() => {
if (!proj.ready || sessionRoot.current === proj.root) return
sessionRoot.current = proj.root
if (!proj.config.session.restoreOnLaunch) return
const saved = loadJson<{ tabs: string[]; active: string | null; tabMode: Record<string, Mode> } | null>(`helder.session:${proj.root}`, null)
if (saved && Array.isArray(saved.tabs)) {
setTabs(saved.tabs.map((p) => ({ path: p })))
setActive(saved.active ?? null)
setTabMode(saved.tabMode ?? {})
}
}, [proj.ready, proj.root, proj.config.session.restoreOnLaunch])
useEffect(() => {
if (sessionRoot.current !== proj.root || !proj.config.session.restoreOnLaunch) return
saveJson(`helder.session:${proj.root}`, { tabs: tabs.map((t) => t.path), active, tabMode })
}, [tabs, active, tabMode, proj.root, proj.config.session.restoreOnLaunch])
function toast(title: string, ref?: string): void {
const id = lid()
setToasts((t) => [...t, { id, title, ref }])
@@ -158,7 +184,7 @@ export function App(): React.ReactElement {
}
const toggleDir = useCallback((p: string) => {
setOpenDirs((s) => { const n = new Set(s); n.has(p) ? n.delete(p) : n.add(p); return n })
setOpenDirs((s) => { const n = new Set(s); if (n.has(p)) n.delete(p); else n.add(p); return n })
}, [])
function reveal(path: string): void {
@@ -174,12 +200,22 @@ export function App(): React.ReactElement {
setCommitMsg('')
}
const defaultMode: Mode = proj.config.git.defaultDiffMode
function stageGuarded(p: string): void {
if (proj.config.git.confirmStage && !window.confirm(`Stage ${p}?`)) return
actions.stage(p)
}
function unstageGuarded(p: string): void {
if (proj.config.git.confirmUnstage && !window.confirm(`Unstage ${p}?`)) return
actions.unstage(p)
}
function openFile(path: string, opts: { diff?: boolean; line?: number } = {}): void {
const changed = !!proj.diffs[path]
actions.ensureFile(path)
setTabs((t) => t.some((x) => x.path === path) ? t : [...t, { path }])
setActive(path)
setTabMode((m) => ({ ...m, [path]: opts.diff && changed ? 'diff' : (m[path] || (changed ? 'diff' : 'code')) }))
setTabMode((m) => ({ ...m, [path]: opts.diff && changed ? defaultMode : (m[path] || (changed ? defaultMode : 'code')) }))
reveal(path)
if (opts.line) {
// show the current/updated file so line numbers map to search hits
@@ -194,7 +230,8 @@ export function App(): React.ReactElement {
}
}
function closeTab(path: string): void {
function removeTab(path: string): void {
setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n })
setTabs((t) => {
const ix = t.findIndex((x) => x.path === path)
const next = t.filter((x) => x.path !== path)
@@ -206,6 +243,20 @@ export function App(): React.ReactElement {
})
}
async function closeTab(path: string): Promise<void> {
const buf = buffersRef.current[path]
const dirtyNow = buf != null && buf !== (projRef.current.files[path] ?? '')
if (dirtyNow) {
const bridge = window.helder
const choice = bridge
? await bridge.dialog.unsavedClose(path)
: (window.confirm(`Discard unsaved changes to ${path}?`) ? 'discard' : 'cancel')
if (choice === 'cancel') return
if (choice === 'save') writeToDisk(path, buf as string)
}
removeTab(path)
}
// ---- context menus ----
function openMenu(e: React.MouseEvent, target: ContextTarget): void {
e.preventDefault(); e.stopPropagation()
@@ -234,8 +285,8 @@ export function App(): React.ReactElement {
if (target.kind === 'git') {
const isStaged = proj.staged.has(target.path)
items.push(isStaged
? { icon: Icon.minus(), label: 'Unstage changes', onClick: () => actions.unstage(target.path) }
: { icon: Icon.plus(), label: 'Stage changes', onClick: () => actions.stage(target.path) })
? { icon: Icon.minus(), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
: { icon: Icon.plus(), label: 'Stage changes', onClick: () => stageGuarded(target.path) })
items.push({ icon: Icon.diff(), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
items.push({ icon: Icon.discard(), label: 'Discard changes', onClick: () => doDiscard(target.path) })
}
@@ -250,7 +301,8 @@ export function App(): React.ReactElement {
useEffect(() => {
function onKey(e: KeyboardEvent): void {
const meta = e.metaKey || e.ctrlKey
if (meta && e.key.toLowerCase() === 'f') { e.preventDefault(); setOverlay('search') }
// ⌘F and ⌘P both open the unified search (it covers file names too).
if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setOverlay('search') }
else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() }
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
else if (e.key === 'Escape') { if (splitFor) setSplitFor(null); else { setOverlay(null); setMenu(null) } }
@@ -263,10 +315,10 @@ export function App(): React.ReactElement {
const MODE_WORD: Record<string, string> = { original: 'Original', updated: 'Updated', diff: 'Diff' }
const resolvedTabs = tabs.map((t) => {
const changed = !!proj.diffs[t.path]
const m = tabMode[t.path] || (changed ? 'diff' : 'code')
const m = tabMode[t.path] || (changed ? defaultMode : 'code')
return { ...t, changed, modeLabel: splitFor === t.path ? 'split' : MODE_LABEL[m], dirty: isDirty(t.path) }
})
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? 'diff' : 'code')
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
const totals = proj.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
const activeLang = active ? HL.langLabel(active) : ''
@@ -298,7 +350,7 @@ export function App(): React.ReactElement {
<div className="col" style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}>
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={actions.stage} onUnstage={actions.unstage} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit}
onOpen={openFile} onContext={openMenu} activePath={active} />
</div>
<Splitter onDelta={(dx) => setGitW((w) => clamp(w + dx, 160, 460))} />
@@ -332,7 +384,7 @@ export function App(): React.ReactElement {
<div className="sb"><span className="a">+{totals.add}</span> <span className="d">{totals.del}</span></div>
<div className="sb spacer" />
{active && <div className="sb">{selection && selection.path === active && selection.start !== selection.end ? `${selection.end - selection.start + 1} lines selected` : `Ln ${curLine}, Col ${curCol}`}</div>}
{active && <div className="sb">Spaces: 4</div>}
{active && <div className="sb">Spaces: {proj.config.editor.tabSize}</div>}
{active && <div className="sb">UTF-8</div>}
{active && <div className="sb"><b>{activeLang}</b></div>}
{active && proj.diffs[active] && <div className="sb">{splitFor === active ? 'Split' : (MODE_WORD[mode] || '')}</div>}

View File

@@ -29,6 +29,7 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
}): React.ReactElement {
const scrollRef = useRef<HTMLDivElement>(null)
const gutterRef = useRef<HTMLDivElement>(null)
const tabSize = useProject().config.editor.tabSize
const html = useMemo(() => HL.hlText(text, lang), [text, lang])
const count = useMemo(() => text.split('\n').length, [text])
@@ -68,12 +69,12 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="ce-inner">
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
wrap="off"
wrap="off" style={{ tabSize }}
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
onClick={(e) => ensureCaretVisible(e.currentTarget)}
onContextMenu={handleContext} />
<pre className="ce-pre" aria-hidden dangerouslySetInnerHTML={{ __html: html + '\n' }} />
<pre className="ce-pre" aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html + '\n' }} />
</div>
</div>
</div>
@@ -97,7 +98,7 @@ function EditorTabs({ tabs, active, onActivate, onClose }: {
const name = t.path.split('/').pop()
return (
<div key={t.path}
className={'tab' + (active === t.path ? ' active' : '') + (t.dirty ? ' dirty' : '')}
className={'tab' + (active === t.path ? ' active' : '') + (t.dirty ? ' dirtyclose' : '')}
onClick={() => onActivate(t.path)}
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path) } }}
title={t.path}>
@@ -107,6 +108,7 @@ function EditorTabs({ tabs, active, onActivate, onClose }: {
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path) }}>
{Icon.close()}
</span>
{t.dirty && <span className="tdot" title="Unsaved changes" />}
</div>
)
})}

View File

@@ -46,6 +46,9 @@ interface HelderBridge {
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
files: () => Promise<string[]>
}
dialog: {
unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'>
}
onProjectChanged: (cb: () => void) => () => void
onConfigChanged: (cb: () => void) => () => void
}

View File

@@ -0,0 +1,41 @@
import React from 'react'
interface State {
error: Error | null
}
/** Catches render-time errors anywhere in the tree and shows a dark, recoverable
* panel instead of a blank window. */
export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[helder] render error:', error, info.componentStack)
}
render(): React.ReactNode {
const { error } = this.state
if (!error) return this.props.children
return (
<div style={{
height: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 14, background: 'var(--bg-0)', color: 'var(--fg-1)', fontFamily: 'var(--ui)', padding: 40, textAlign: 'center',
}}>
<div style={{ fontSize: 15, color: 'var(--fg-0)' }}>Something went wrong</div>
<pre style={{
maxWidth: 720, maxHeight: 280, overflow: 'auto', margin: 0, padding: 14, textAlign: 'left',
fontFamily: 'var(--code-font)', fontSize: 12, color: 'var(--del)',
background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, whiteSpace: 'pre-wrap',
}}>{error.message}</pre>
<button onClick={() => location.reload()} style={{
background: 'var(--accent)', color: '#0c1320', border: 0, borderRadius: 7, fontWeight: 600,
padding: '7px 14px', cursor: 'pointer', fontSize: 12,
}}>Reload</button>
</div>
)
}
}

12
src/renderer/src/fuzzy.ts Normal file
View File

@@ -0,0 +1,12 @@
/** Subsequence fuzzy match. Returns the matched character indices in `str`
* (in order), or null when `q` is not a subsequence of `str`. Case-insensitive. */
export function fuzzy(q: string, str: string): number[] | null {
q = q.toLowerCase()
const s = str.toLowerCase()
let i = 0
const idx: number[] = []
for (let j = 0; j < s.length && i < q.length; j++) {
if (s[j] === q[i]) { idx.push(j); i++ }
}
return i === q.length ? idx : null
}

View File

@@ -13,11 +13,14 @@ import './styles.css'
import { App } from './App'
import { ProjectProvider } from './project'
import { ErrorBoundary } from './error-boundary'
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ProjectProvider>
<App />
</ProjectProvider>
<ErrorBoundary>
<ProjectProvider>
<App />
</ProjectProvider>
</ErrorBoundary>
</React.StrictMode>,
)

View File

@@ -1,6 +1,7 @@
/* Overlays: combined search (content + file names), context menu, toast, pass-popup */
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { useProject } from './project'
import { fuzzy } from './fuzzy'
import { FileIcon, Icon } from './components'
import type { OpenFile } from './components'
@@ -17,15 +18,6 @@ export interface Toast { id: number; title: string; ref?: string }
interface ContentHit { no: number; ln: string; ix: number }
interface ContentGroup { path: string; hits: ContentHit[] }
export function fuzzy(q: string, str: string): number[] | null {
q = q.toLowerCase(); const s = str.toLowerCase()
let i = 0; const idx: number[] = []
for (let j = 0; j < s.length && i < q.length; j++) {
if (s[j] === q[i]) { idx.push(j); i++ }
}
return i === q.length ? idx : null
}
function Highlight({ text, idx }: { text: string; idx: number[] | null }): React.ReactElement {
if (!idx || !idx.length) return <span>{text}</span>
const set = new Set(idx)
@@ -48,7 +40,7 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
// file-name list: ripgrep `--files` when available, else the in-memory index keys
const [allPaths, setAllPaths] = useState<string[]>(() => (bridge ? [] : Object.keys(PROJECT.files)))
useEffect(() => {
inputRef.current && inputRef.current.focus()
if (inputRef.current) inputRef.current.focus()
if (bridge) bridge.search.files().then((f) => setAllPaths(f.length ? f : Object.keys(PROJECT.files))).catch(() => setAllPaths(Object.keys(PROJECT.files)))
}, [])
@@ -205,7 +197,7 @@ export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: ()
{menu.note && <div className="ctx-note">{menu.note}</div>}
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '')}
onClick={() => { it.onClick && it.onClick(); onClose() }}>
onClick={() => { it.onClick?.(); onClose() }}>
<span className="ic">{it.icon}</span>
<span>{it.label}</span>
{it.kbd && <span className="kc">{it.kbd}</span>}
@@ -239,7 +231,7 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
const [text, setText] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const boxRef = useRef<HTMLDivElement>(null)
useEffect(() => { inputRef.current && inputRef.current.focus() }, [])
useEffect(() => { if (inputRef.current) inputRef.current.focus() }, [])
useEffect(() => {
const h = (e: MouseEvent): void => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) onCancel() }
const k = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault(); onCancel() } }

View File

@@ -0,0 +1,37 @@
/** Tiny localStorage-backed number persistence for layout (splitter positions
* persist across launches). Defensive: never throws if storage is unavailable. */
export function loadNum(key: string, fallback: number): number {
try {
const v = localStorage.getItem(key)
if (v == null) return fallback
const n = Number(v)
return Number.isFinite(n) ? n : fallback
} catch {
return fallback
}
}
export function saveNum(key: string, value: number): void {
try {
localStorage.setItem(key, String(value))
} catch {
/* storage unavailable */
}
}
export function loadJson<T>(key: string, fallback: T): T {
try {
const v = localStorage.getItem(key)
return v == null ? fallback : (JSON.parse(v) as T)
} catch {
return fallback
}
}
export function saveJson(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
/* storage unavailable */
}
}

View File

@@ -65,17 +65,23 @@ export interface ViewLine {
row?: 'add' | 'del' | 'bar-add' | 'bar-del' | null
}
export type DiffMode = 'original' | 'updated' | 'diff'
/** Effective project settings (mirrors src/main/config.ts). */
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 DEFAULT_CONFIG: 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 },
}