Compare commits

...

7 Commits

Author SHA1 Message Date
87fcf9bf93 open in diff or in updated mode
Some checks failed
CI / check (push) Has been cancelled
2026-06-16 09:36:44 +02:00
8fc6478bb1 improvements to the ui 2026-06-16 09:36:18 +02:00
7bac3fe7c5 several nice UI improvements 2026-06-16 08:52:22 +02:00
6a940b9b7a cleanup header titles of col a and col b 2026-06-16 08:38:04 +02:00
0879f51f48 green on the full screen dif 2026-06-16 08:18:10 +02:00
bd466e84a8 resizes the col widths on app resize 2026-06-16 08:11:10 +02:00
299ae17d80 update fixing the build of the app 2026-06-16 07:59:38 +02:00
17 changed files with 478 additions and 265 deletions

2
.helder/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Helder — local, machine-specific state (do not commit)
recent.json

View File

@@ -46,9 +46,44 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in
}
`
/** Recently-opened files, newest first. Local machine state — git-ignored. */
const RECENT_FILE = 'recent.json'
const MAX_RECENT = 100
const GITIGNORE_BODY = `# Helder — local, machine-specific state (do not commit)\n${RECENT_FILE}\n`
let current: HelderConfig = DEFAULTS
let themeCss = ''
/** Create `.helder/.gitignore` (ignoring recent.json) only when it's missing. */
async function ensureGitignore(dir: string): Promise<void> {
const path = join(dir, '.gitignore')
try {
await readFile(path, 'utf8')
} catch {
await writeFile(path, GITIGNORE_BODY)
}
}
export async function getRecent(root: string): Promise<string[]> {
try {
const arr = JSON.parse(await readFile(join(root, '.helder', RECENT_FILE), 'utf8'))
return Array.isArray(arr) ? arr.filter((p): p is string => typeof p === 'string').slice(0, MAX_RECENT) : []
} catch {
return []
}
}
export async function setRecent(root: string, list: string[]): Promise<void> {
try {
const dir = join(root, '.helder')
await mkdir(dir, { recursive: true })
await ensureGitignore(dir)
await writeFile(join(dir, RECENT_FILE), JSON.stringify(list.slice(0, MAX_RECENT), null, 2) + '\n')
} catch {
/* read-only / inaccessible root — recents just won't persist */
}
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object' && !Array.isArray(v)
}
@@ -83,6 +118,8 @@ export async function resolveConfig(root: string): Promise<void> {
themeCss = THEME_TEMPLATE
await writeFile(join(dir, 'theme.css'), THEME_TEMPLATE)
}
await ensureGitignore(dir)
} catch {
// Read-only / inaccessible root: fall back to built-in defaults.
current = DEFAULTS

View File

@@ -5,7 +5,7 @@ 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 { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
import { listFiles, searchContent } from './search-service'
const isDev = !!process.env['ELECTRON_RENDERER_URL']
@@ -112,6 +112,9 @@ function registerIpc(): void {
ipcMain.handle('config:get', () => getConfig())
ipcMain.handle('config:theme', () => getThemeCss())
ipcMain.handle('recent:get', () => getRecent(getRoot()))
ipcMain.handle('recent:set', (_e, list: string[]) => setRecent(getRoot(), list))
ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query))
ipcMain.handle('search:files', () => listFiles(getRoot()))

View File

@@ -1,4 +1,4 @@
import { basename } from 'node:path'
import { basename, resolve } from 'node:path'
import { existsSync, statSync } from 'node:fs'
import { dialog, BrowserWindow } from 'electron'
@@ -6,12 +6,16 @@ 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.
*
* Always store an absolute path: in dev the app is launched as `electron .`, so
* argv carries a bare "." — `basename(".")` is "." (the repo name would show as a
* lone dot). `resolve()` turns it into the real directory before we name it.
*/
function resolveInitialRoot(): string {
const envRoot = process.env.HELDER_PROJECT
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return envRoot
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return resolve(envRoot)
const argDir = process.argv.slice(1).find((a) => !a.startsWith('-') && existsSync(a) && safeIsDir(a))
if (argDir) return argDir
if (argDir) return resolve(argDir)
return process.cwd()
}
@@ -30,7 +34,7 @@ export function getName(): string {
}
export function setRoot(next: string): void {
root = next
root = resolve(next)
}
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {

View File

@@ -25,6 +25,19 @@ try {
const terms = new Map<number, import('node-pty').IPty>()
let seq = 0
/**
* Env for spawned PTYs. Electron launched from a Homebrew/GUI context leaks
* `npm_config_prefix` (e.g. "/opt/homebrew") into the child shell, which makes
* nvm refuse to load ("nvm is not compatible with the npm_config_prefix
* environment variable"). Strip it so the user's shell init runs cleanly.
*/
function ptyEnv(): { [key: string]: string } {
const env = { ...process.env } as { [key: string]: string }
delete env.npm_config_prefix
delete env.npm_config_globalconfig
return env
}
function defaultShell(): string {
const configured = getConfig().terminal.shell
if (configured) return configured
@@ -39,12 +52,20 @@ export function ptyAvailable(): boolean {
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(), [], {
const shell = defaultShell()
const ai = getConfig().ai
const launchAgent = kind === 'agent' && ai.autoLaunch && process.platform !== 'win32'
// For the agent pane we exec the `claude` CLI directly as the shell's command
// (`zsh -i -c 'claude'`) instead of typing it into an interactive prompt — `-i`
// still sources the user's rc (nvm etc.), but there's no prompt line and no
// 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',
cols: cols || 80,
rows: rows || 24,
cwd,
env: process.env as { [key: string]: string },
env: ptyEnv(),
})
const id = ++seq
terms.set(id, proc)
@@ -52,9 +73,8 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
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
// Windows path keeps the type-into-shell launch (no `-i -c` semantics there).
if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') {
setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350)
}
return id

View File

@@ -1,4 +1,3 @@
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
import { relative, sep } from 'node:path'
import { getConfig } from './config'
@@ -6,18 +5,23 @@ import { getConfig } from './config'
/** 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
* same way. Substring (fixed-string), smart-case search.
*
* @vscode/ripgrep ships as ESM, so load it with dynamic import() (works for
* ESM and CJS) and cache the resolved binary path. */
const rgPathPromise: Promise<string | null> = (async () => {
try {
rgPath = (require('@vscode/ripgrep') as { rgPath: string }).rgPath
const mod = await import('@vscode/ripgrep')
let p = (mod 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')
if (p) p = p.replace(/\bapp\.asar\b/, 'app.asar.unpacked')
return p || null
} catch (e) {
console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message)
return null
}
})()
export interface ContentHit { no: number; ln: string; ix: number }
export interface ContentGroup { path: string; hits: ContentHit[] }
@@ -31,8 +35,8 @@ const BASE_IGNORE = [
const MAX_FILES = 400
const MAX_LINE = 1000
export function rgAvailable(): boolean {
return !!rgPath
export async function rgAvailable(): Promise<boolean> {
return !!(await rgPathPromise)
}
/** Glob/ignore args derived from config (files.exclude, files.followGitignore). */
@@ -49,9 +53,10 @@ function toRel(root: string, p: string): string {
return relative(root, p).split(sep).join('/')
}
export function searchContent(root: string, query: string): Promise<ContentGroup[]> {
export async function searchContent(root: string, query: string): Promise<ContentGroup[]> {
const rgPath = await rgPathPromise
if (!rgPath || query.trim().length < 2) return []
return new Promise((resolve) => {
if (!rgPath || query.trim().length < 2) return resolve([])
const child = spawn(rgPath, [
'--json', '--fixed-strings', '--smart-case', '--hidden',
'--max-count', '50', '--max-columns', '2000',
@@ -90,9 +95,10 @@ 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[]> {
export async function listFiles(root: string): Promise<string[]> {
const rgPath = await rgPathPromise
if (!rgPath) return []
return new Promise((resolve) => {
if (!rgPath) return resolve([])
const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', root])
let buf = ''
const out: string[] = []

View File

@@ -56,6 +56,11 @@ const api = {
theme: (): Promise<string> => ipcRenderer.invoke('config:theme'),
},
recent: {
get: (): Promise<string[]> => ipcRenderer.invoke('recent:get'),
set: (list: string[]): Promise<void> => ipcRenderer.invoke('recent:set', list),
},
search: {
content: (query: string) => ipcRenderer.invoke('search:content', query),
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),

View File

@@ -1,12 +1,11 @@
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference, status bar */
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { HL } from './highlight'
import { FileTree, GitPanel, Icon } from './components'
import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor'
import { Terminal, lid } from './terminals'
import { ContextMenu, PassPopup, SearchModal, Toasts } from './overlays'
import { ContextMenu, HistoryModal, PassPopup, SearchModal, Toasts } from './overlays'
import type { Menu, Toast } from './overlays'
import type { FileNode, GitStatus } from './types'
import { useProject, useProjectActions } from './project'
@@ -36,7 +35,7 @@ function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onD
return <div className={'splitter' + (orientation === 'h' ? ' h' : '') + (drag ? ' drag' : '')} onMouseDown={down} />
}
function RightColumn({ width }: { width: number }): React.ReactElement {
function RightColumn({ width, onFocus }: { width: number; onFocus: () => void }): React.ReactElement {
const [topFrac, setTopFrac] = useState(() => loadNum('helder.topFrac', 0.52))
const ref = useRef<HTMLDivElement>(null)
useEffect(() => saveNum('helder.topFrac', topFrac), [topFrac])
@@ -45,7 +44,7 @@ function RightColumn({ width }: { width: number }): React.ReactElement {
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)))
}
return (
<div className="col right-col" style={{ width, flex: '0 0 ' + width + 'px' }}>
<div className="col right-col" style={{ width, flex: '0 0 ' + width + 'px' }} onMouseDownCapture={onFocus}>
<div ref={ref} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: '0 0 ' + (topFrac * 100) + '%', minHeight: 0, display: 'flex' }}>
<Terminal kind="agent" />
@@ -80,18 +79,23 @@ export function App(): React.ReactElement {
const changeMap = useMemo(() => Object.fromEntries(proj.changes.map((c) => [c.path, c.status])) as Record<string, GitStatus>, [proj.changes])
const changeSet = useMemo(() => new Set(proj.changes.map((c) => c.path)), [proj.changes])
const [tabs, setTabs] = useState<{ path: string }[]>([])
const [active, setActive] = useState<string | null>(null)
const [tabMode, setTabMode] = useState<Record<string, Mode>>({})
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
const [cursor, setCursor] = useState<Cursor | null>(null)
const [selection, setSelection] = useState<Selection | null>(null)
const [overlay, setOverlay] = useState<'search' | null>(null)
const [overlay, setOverlay] = useState<'search' | 'history' | null>(null)
// Most-recently-opened files, newest first, de-duplicated. Drives the ⌘↓/⌘↑ navigator.
const [history, setHistory] = useState<string[]>([])
const [histInitSel, setHistInitSel] = useState(0)
const [menu, setMenu] = useState<Menu | null>(null)
const [toasts, setToasts] = useState<Toast[]>([])
const [splitFor, setSplitFor] = useState<string | null>(null)
const [commitMsg, setCommitMsg] = useState('')
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null)
// Brief full-screen "branch - repository" flash whenever the window gains focus
// (handy when juggling several project windows).
const [showFlash, setShowFlash] = useState(false)
// Editable buffers: path → current text (absent = clean, showing on-disk content).
const [buffers, setBuffers] = useState<Record<string, string>>({})
@@ -130,12 +134,55 @@ export function App(): React.ReactElement {
toast('Discarded changes', path)
}
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])
// Proportional columns, two regimes (Editor C is the flex remainder):
// ≥ 1650px (roomy) → Git 10% · Explorer 10% · Editor 40% · Right 40%
// (no focus-driven changes — everything fits)
// < 1650px (tight) → Git 15% · Explorer 15%, Editor/Right react to focus:
// default Editor 40% / Right 30%
// focus editor → Editor 50% / Right 20%
// focus agent/terminal → Editor 20% / Right 50%
// Re-applied on resize + focus change; dragging still works in between.
const FOCUS_RESIZE_BELOW = 1650
const [focusZone, setFocusZone] = useState<'default' | 'editor' | 'terminal'>('default')
// Auto panel management: re-fit columns on resize/focus. Manually dragging a
// splitter switches it off (the user took control); the title-bar toggle
// turns it back on (and immediately re-fits).
const [autoResize, setAutoResize] = useState(true)
const [gitW, setGitW] = useState(() => Math.round(window.innerWidth * 0.15))
const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15))
const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3))
useEffect(() => {
if (!autoResize) return
function apply(): void {
const w = window.innerWidth
if (w >= FOCUS_RESIZE_BELOW) {
setGitW(Math.round(w * 0.1))
setTreeW(Math.round(w * 0.1))
setRightW(Math.round(w * 0.4))
} else {
setGitW(Math.round(w * 0.15))
setTreeW(Math.round(w * 0.15))
const rightFrac = focusZone === 'terminal' ? 0.5 : focusZone === 'editor' ? 0.2 : 0.3
setRightW(Math.round(w * rightFrac))
}
}
apply()
window.addEventListener('resize', apply)
return () => window.removeEventListener('resize', apply)
}, [focusZone, autoResize])
// Flash the branch · repository banner for ~2s each time the window gains focus.
useEffect(() => {
let timer: ReturnType<typeof setTimeout>
function flash(): void {
setShowFlash(true)
clearTimeout(timer)
timer = setTimeout(() => setShowFlash(false), 2000)
}
if (document.hasFocus()) flash()
window.addEventListener('focus', flash)
return () => { window.removeEventListener('focus', flash); clearTimeout(timer) }
}, [])
// Seed explorer expansion from the tree's `open` flags once per opened project.
const seededRoot = useRef<string | null | undefined>(undefined)
@@ -146,25 +193,36 @@ 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.
// Per project: load the recent-files history (.helder/recent.json) and the last
// active file + view modes (localStorage), then keep both persisted. Recent files
// persist regardless of restoreOnLaunch; only the active/view-mode restore is gated.
const sessionRoot = useRef<string | null | undefined>(undefined)
const recentReady = useRef(false)
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 ?? {})
recentReady.current = false
setHistory([]); setActive(null); setTabMode({})
if (proj.config.session.restoreOnLaunch) {
const saved = loadJson<{ active: string | null; tabMode: Record<string, Mode> } | null>(`helder.session:${proj.root}`, null)
if (saved) { setActive(saved.active ?? null); setTabMode(saved.tabMode ?? {}) }
}
const bridge = window.helder
if (bridge) bridge.recent.get().then((list) => { setHistory(list); recentReady.current = true }).catch(() => { recentReady.current = true })
}, [proj.ready, proj.root, proj.config.session.restoreOnLaunch])
// Persist the history to .helder/recent.json (newest first, capped to 100 in main),
// but only once it's been loaded for this project (so we never clobber it with []).
useEffect(() => {
if (sessionRoot.current !== proj.root || !recentReady.current) return
const bridge = window.helder
if (bridge) bridge.recent.set(history).catch(() => {})
}, [history, proj.root])
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])
saveJson(`helder.session:${proj.root}`, { active, tabMode })
}, [active, tabMode, proj.root, proj.config.session.restoreOnLaunch])
function toast(title: string, ref?: string): void {
const id = lid()
@@ -212,10 +270,14 @@ export function App(): React.ReactElement {
function openFile(path: string, opts: { diff?: boolean; line?: number } = {}): void {
const changed = !!proj.diffs[path]
setFocusZone('editor')
setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100))
actions.ensureFile(path)
setTabs((t) => t.some((x) => x.path === path) ? t : [...t, { path }])
setActive(path)
setTabMode((m) => ({ ...m, [path]: opts.diff && changed ? defaultMode : (m[path] || (changed ? defaultMode : 'code')) }))
// Git rows open the diff; explorer / recent-files open the updated view.
// Unchanged files only have the plain editable "code" view.
const openMode: Mode = changed ? (opts.diff ? 'diff' : 'updated') : 'code'
setTabMode((m) => ({ ...m, [path]: openMode }))
reveal(path)
if (opts.line) {
// show the current/updated file so line numbers map to search hits
@@ -230,19 +292,8 @@ export function App(): React.ReactElement {
}
}
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)
if (path === active) {
const fallback = next[ix] || next[ix - 1] || next[next.length - 1]
setActive(fallback ? fallback.path : null)
}
return next
})
}
// Close the current file view (no tabs anymore — the recent-files list replaces
// them). The file stays in history; ⌘W just clears the editor after a dirty check.
async function closeTab(path: string): Promise<void> {
const buf = buffersRef.current[path]
const dirtyNow = buf != null && buf !== (projRef.current.files[path] ?? '')
@@ -254,7 +305,8 @@ export function App(): React.ReactElement {
if (choice === 'cancel') return
if (choice === 'save') writeToDisk(path, buf as string)
}
removeTab(path)
setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n })
if (path === active) setActive(null)
}
// ---- context menus ----
@@ -301,30 +353,27 @@ export function App(): React.ReactElement {
useEffect(() => {
function onKey(e: KeyboardEvent): void {
const meta = e.metaKey || e.ctrlKey
// The history navigator owns the keyboard while open (it listens in capture phase).
if (overlay === 'history') return
// ⌘↓/⌘↑ from the file viewer opens the recent-files navigator.
if (meta && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && focusZone === 'editor' && history.length > 0) {
e.preventDefault()
setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0)
setOverlay('history')
}
// ⌘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() === '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) } }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [active, splitFor])
}, [active, splitFor, overlay, focusZone, history])
const MODE_LABEL: Record<string, string> = { original: 'orig', updated: 'upd', diff: 'diff', code: '' }
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 ? 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] ? 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) : ''
const crumb = active ? active.split('/') : []
const curLine = cursor && active && cursor.path === active ? cursor.line : 1
const curCol = cursor && active && cursor.path === active ? cursor.col : 1
return (
<div className="app">
@@ -337,58 +386,71 @@ export function App(): React.ReactElement {
{active && (
<div className="tb-crumb">
{crumb.map((s, i) => (<React.Fragment key={i}>{i > 0 && <span className="seg"> </span>}<span style={i === crumb.length - 1 ? { color: 'var(--fg-1)' } : undefined}>{s}</span></React.Fragment>))}
{isDirty(active) && <span className="tb-dirty" title="Unsaved changes"></span>}
</div>
)}
<div className="tb-repo" title={proj.branch + ' - ' + proj.name}>
{Icon.branch()}<span className="tb-repo-branch">{proj.branch}</span>
<span className="tb-repo-sep">-</span><span className="tb-repo-name">{proj.name}</span>
</div>
<div className="tb-spacer" />
<div className="tb-actions">
<button className="tb-btn" onClick={() => setOverlay('search')}>{Icon.search()} Search <kbd>F</kbd></button>
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}
title={autoResize ? 'Auto-fit panels: on — columns re-fit on resize/focus. Click to lock current sizes.' : 'Auto-fit panels: off — sizes locked. Click to re-enable.'}>
{Icon.layout()} Auto-fit <span className="tb-state">{autoResize ? 'On' : 'Off'}</span>
</button>
</div>
</div>
{/* focus flash — branch - repository, centered, ~1s (inline, header styling) */}
{showFlash && (
<div className="focus-flash" key={proj.branch + proj.name}>
<div className="ff-card">
{Icon.branch({ width: 20, height: 20 })}
<span className="tb-repo-branch">{proj.branch}</span>
<span className="tb-repo-sep">-</span>
<span className="tb-repo-name">{proj.name}</span>
</div>
</div>
)}
{/* workbench */}
<div className="workbench">
<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={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit}
onOpen={openFile} onContext={openMenu} activePath={active} />
onOpen={openFile} onContext={openMenu} activePath={active} showDir={gitW > 300} />
</div>
<Splitter onDelta={(dx) => setGitW((w) => clamp(w + dx, 160, 460))} />
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
<div className="col" style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}>
{proj.tree ? (
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
onContext={openMenu} activePath={active} changeMap={changeMap} committed={NO_COMMITTED} />
) : (
<div className="phead"><span>Explorer</span></div>
<div className="tree-body" />
)}
</div>
<Splitter onDelta={(dx) => setTreeW((w) => clamp(w + dx, 160, 520))} />
<Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
<div className="col editor-col">
<Editor tabs={resolvedTabs} active={active} mode={mode}
<div className="col editor-col" onMouseDownCapture={() => setFocusZone('editor')}>
<Editor active={active} mode={mode}
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null) } }}
onActivate={setActive} onClose={closeTab} onContext={openMenu}
onContext={openMenu}
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
bufferText={bufferText(active)} onEdit={onEdit} />
</div>
<Splitter onDelta={(dx) => setRightW((w) => clamp(w - dx, 280, 780))} />
<Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => {
// grow until the editor would drop below ~280px (rather than a fixed cap)
const max = Math.max(280, window.innerWidth - gitW - treeW - 280)
return clamp(w - dx, 280, max)
}) }} />
{/* keyed by root so the PTYs respawn in the new cwd when the project switches */}
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} />}
</div>
{/* status bar */}
<div className="statusbar">
<div className="sb accent">{Icon.branch({ width: 12, height: 12 })}<span style={{ color: '#0c1320' }}>{proj.branch}</span></div>
<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: {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>}
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} onFocus={() => setFocusZone('terminal')} />}
</div>
{/* overlays */}
@@ -402,6 +464,7 @@ export function App(): React.ReactElement {
}}
onCancel={() => setPassPopup(null)} />}
{overlay === 'search' && <SearchModal onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />}
{overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
<Toasts toasts={toasts} />
</div>

View File

@@ -20,6 +20,7 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
minus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>),
check: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>),
discard: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><path d="M12.5 2.5v3h-3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
layout: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M6 3v10M10 3v10" stroke="currentColor" strokeWidth="1.3" /></svg>),
}
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
@@ -30,8 +31,14 @@ export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
export const FolderIcon = ({ open }: { open: boolean }): React.ReactElement => (
<svg className="folder-ic" width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d={open ? 'M1.5 4.5h4l1.2 1.4H14V13H2V4.5z' : 'M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z'}
fill={open ? 'rgba(122,131,140,.18)' : 'rgba(122,131,140,.12)'} stroke="currentColor" strokeWidth="1.1" />
{open ? (
<Fragment>
<path d="M2 12.5V4.5h3.6l1.2 1.4h6.7V7.4" fill="rgba(122,131,140,.16)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
<path d="M1.4 12.6l1.9-5.1h11.4l-1.9 5.1H1.4z" fill="rgba(122,131,140,.22)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
</Fragment>
) : (
<path d="M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z" fill="rgba(122,131,140,.12)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
)}
</svg>
)
@@ -52,16 +59,18 @@ export interface ContextTarget {
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
/* ============ Git / Source Control panel ============ */
function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }: {
function GitRow({ c, staged, activePath, showDir, onOpen, onContext, onToggleStage }: {
c: Change
staged: boolean
activePath: string | null
showDir: boolean
onOpen: OpenFile
onContext: OnContext
onToggleStage: (path: string) => void
}): React.ReactElement {
const name = c.path.split('/').pop()
const dir = c.path.split('/').slice(0, -1).join('/')
const dirShown = showDir && !!dir
return (
<div className={'git-row' + (activePath === c.path ? ' active' : '')}
onClick={() => onOpen(c.path, { diff: true })}
@@ -70,20 +79,16 @@ function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }: {
<span className={'git-stat ' + c.status}>{c.status}</span>
<FileIcon path={c.path} />
<span className={'git-name' + (c.deleted ? ' del' : '')}>{name}</span>
{dir && <span className="git-dir">{dir}/</span>}
<button className="git-act" title={staged ? 'Unstage changes' : 'Stage changes'}
{dirShown && <span className="git-dir">{dir}/</span>}
<button className={'git-act' + (dirShown ? '' : ' push')} title={staged ? 'Unstage changes' : 'Stage changes'}
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}>
{staged ? Icon.minus() : Icon.plus()}
</button>
<span className="git-delta">
{c.add > 0 && <span className="a">+{c.add}</span>}
{c.del > 0 && <span className="d">-{c.del}</span>}
</span>
</div>
)
}
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath }: {
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, showDir }: {
branch: string
changes: Change[]
staged: Set<string>
@@ -98,6 +103,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
onOpen: OpenFile
onContext: OnContext
activePath: string | null
showDir: boolean
}): React.ReactElement {
const visible = changes.filter((c) => !committed.has(c.path))
const stagedList = visible.filter((c) => staged.has(c.path))
@@ -107,20 +113,11 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
return (
<Fragment>
<div className="phead">
{Icon.branch()}<span>Source Control</span>
<span className="ct">{visible.length}</span>
</div>
<div className="commit-box">
<textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false}
placeholder="Message (⌘↵ to commit)"
placeholder="Shift+Enter to commit"
onChange={(e) => setCommitMsg(e.target.value)}
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canCommit) { e.preventDefault(); onCommit() } }} />
<button className="commit-btn" disabled={!canCommit} onClick={onCommit}
title={canCommit ? 'Commit staged changes' : 'Stage files and write a message to commit'}>
{Icon.check()}<span>Commit{stagedList.length ? ' ' + stagedList.length : ''}</span>
</button>
onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} />
</div>
<div className="git-body">
@@ -133,7 +130,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
</div>
{stagedList.length > 0 ? stagedList.map((c) => (
<GitRow key={c.path} c={c} staged={true} activePath={activePath}
<GitRow key={c.path} c={c} staged={true} activePath={activePath} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
)) : (
<div className="git-none">Nothing staged use <span className="key">+</span> to stage a file</div>
@@ -146,7 +143,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
</div>
{changesList.length > 0 ? changesList.map((c) => (
<GitRow key={c.path} c={c} staged={false} activePath={activePath}
<GitRow key={c.path} c={c} staged={false} activePath={activePath} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
)) : (
<div className="git-none">All changes staged</div>
@@ -227,10 +224,6 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
}): React.ReactElement {
return (
<Fragment>
<div className="phead">
<span>Explorer</span>
<span style={{ marginLeft: 'auto', color: 'var(--fg-3)', textTransform: 'none', letterSpacing: 0, fontFamily: 'var(--mono)', fontSize: 10.5 }}>{tree.name}</span>
</div>
<div className="tree-body">
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
onOpen={onOpen} onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} />

View File

@@ -1,5 +1,5 @@
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
import React, { Fragment, useEffect, useMemo, useRef } from 'react'
/* Editor: four view modes (Original / Updated / Diff / Split) + line selection */
import React, { Fragment, useMemo, useRef } from 'react'
import type { Diff, ViewLine } from './types'
import { useProject } from './project'
import { HL } from './highlight'
@@ -16,8 +16,6 @@ function climbToLine(node: Node | null): HTMLElement | null {
return el || null
}
interface ResolvedTab { path: string; changed: boolean; modeLabel: string; dirty?: boolean }
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
* scroll-synced line-number gutter. Live highlighting while typing. */
function CodeEditor({ path, text, lang, onChange, onContext }: {
@@ -81,41 +79,6 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
)
}
function EditorTabs({ tabs, active, onActivate, onClose }: {
tabs: ResolvedTab[]
active: string | null
onActivate: (path: string) => void
onClose: (path: string) => void
}): React.ReactElement {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current && ref.current.querySelector('.tab.active')
if (el) el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [active])
return (
<div className="tabs" ref={ref}>
{tabs.map((t) => {
const name = t.path.split('/').pop()
return (
<div key={t.path}
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}>
<FileIcon path={t.path} />
<span className="tname">{name}</span>
{t.changed && <span className="tab-mode">{t.modeLabel}</span>}
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path) }}>
{Icon.close()}
</span>
{t.dirty && <span className="tdot" title="Unsaved changes" />}
</div>
)
})}
</div>
)
}
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, setCursor, setSelection, onContext }: {
cacheKey: string
@@ -228,18 +191,15 @@ function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined)
}
const SEGMENTS: { id: Mode; label: string }[] = [
{ id: 'original', label: 'Original' },
{ id: 'updated', label: 'Updated' },
{ id: 'original', label: 'Original' },
{ id: 'diff', label: 'Diff' },
]
export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
tabs: ResolvedTab[]
export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
active: string | null
mode: Mode
setMode: (m: Mode) => void
onActivate: (path: string) => void
onClose: (path: string) => void
onContext: OnContext
onSplit: (path: string) => void
splitOpen: boolean
@@ -251,7 +211,7 @@ export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onCon
onEdit: (text: string) => void
}): React.ReactElement {
const PROJECT = useProject()
const tab = tabs.find((t) => t.path === active)
const tab = active ? { path: active } : null
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null
const diff = tab ? PROJECT.diffs[tab.path] : null
const lang = tab ? HL.langFor(tab.path) : null
@@ -272,7 +232,6 @@ export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onCon
return (
<Fragment>
<EditorTabs tabs={tabs} active={active} onActivate={onActivate} onClose={onClose} />
{!tab ? (
<div className="empty-ed">
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div>
@@ -368,7 +327,7 @@ export function SplitView({ path, onClose, onContext }: {
<div className="split-label">Original <span>before</span></div>
<div className="editor" ref={leftRef} onScroll={() => sync(leftRef.current, rightRef.current)} onContextMenu={ctx}>
{diff.split.map((row, i) => (
<div key={i} data-line={row.l ? row.l.no : undefined} className={'ln-row' + (row.l && row.l.mark === 'del' ? ' bar-del' : '') + (!row.l ? ' empty' : '')}>
<div key={i} data-line={row.l ? row.l.no : undefined} className={'ln-row' + (row.l && row.l.mark === 'del' ? ' del bar-del' : '') + (!row.l ? ' empty' : '')}>
<span className="ln-gutter">{row.l ? row.l.no : ''}</span>
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.l ? leftHtml[i] : '' }} />
</div>
@@ -379,7 +338,7 @@ export function SplitView({ path, onClose, onContext }: {
<div className="split-label">Updated <span>after</span></div>
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={ctx}>
{diff.split.map((row, i) => (
<div key={i} data-line={row.r ? row.r.no : undefined} className={'ln-row' + (row.r && row.r.mark === 'add' ? ' bar-add' : '') + (!row.r ? ' empty' : '')}>
<div key={i} data-line={row.r ? row.r.no : undefined} className={'ln-row' + (row.r && row.r.mark === 'add' ? ' add bar-add' : '') + (!row.r ? ' empty' : '')}>
<span className="ln-gutter">{row.r ? row.r.no : ''}</span>
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.r ? rightHtml[i] : '' }} />
</div>

View File

@@ -42,6 +42,10 @@ interface HelderBridge {
get: () => Promise<HelderConfig>
theme: () => Promise<string>
}
recent: {
get: () => Promise<string[]>
set: (list: string[]) => Promise<void>
}
search: {
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
files: () => Promise<string[]>

View File

@@ -180,6 +180,68 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
)
}
/* Recently-opened-files navigator. Looks like the search modal but is a single
* keyboard-driven list (most-recent first). ⌘↓/⌘↑ move the selection, ↵ opens.
* Listens in the capture phase so it owns the keyboard while open. */
export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }: {
history: string[]
initialSel: number
onOpen: OpenFile
onClose: () => void
changeSet: Set<string>
}): React.ReactElement {
const [sel, setSel] = useState(() => Math.min(Math.max(initialSel, 0), Math.max(history.length - 1, 0)))
const selRef = useRef(sel); selRef.current = sel
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, history.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
else if (e.key === 'Enter') { e.preventDefault(); const p = history[selRef.current]; if (p) { onOpen(p); onClose() } }
else if (e.key === 'Escape') { e.preventDefault(); onClose() }
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [history])
useEffect(() => {
const el = listRef.current && listRef.current.querySelector('.hist-row.sel')
if (el) el.scrollIntoView({ block: 'nearest' })
}, [sel])
return (
<div className="scrim" onMouseDown={onClose}>
<div className="history-modal" onMouseDown={(e) => e.stopPropagation()}>
<div className="pi">
{Icon.file({ style: { color: 'var(--fg-3)' } })}
<span className="hist-title">Recent files</span>
<span className="mode-chip">{history.length} file{history.length === 1 ? '' : 's'} · <kbd></kbd> <kbd></kbd> <kbd></kbd></span>
</div>
<div className="hist-list" ref={listRef}>
{history.length === 0 && <div className="pempty">No files opened yet</div>}
{history.map((p, i) => {
const name = p.split('/').pop() as string
const dir = p.split('/').slice(0, -1).join('/')
return (
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p}
onMouseEnter={() => setSel(i)}
onClick={() => { onOpen(p); onClose() }}>
<FileIcon path={p} />
<div className="hist-txt">
<span className="fn">{name}</span>
<span className="fd">{dir ? dir + '/' : ''}</span>
</div>
{changeSet.has(p) && <span className="tree-badge M" style={{ fontFamily: 'var(--mono)', fontSize: 10 }}></span>}
</div>
)
})}
</div>
</div>
</div>
)
}
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {

View File

@@ -13,9 +13,12 @@
--fg-1:#b4bac2;
--fg-2:#838a94;
--fg-3:#5d636c;
--accent:#4d8dff;
--accent-soft:rgba(77,141,255,0.16);
--accent-line:rgba(77,141,255,0.55);
--accent:#f19f3f;
--accent-soft:rgba(241,159,63,0.16);
--accent-line:rgba(241,159,63,0.55);
--add:#5cbd6b;
--del:#e0696a;
--mod:#d8a85c;
@@ -50,7 +53,7 @@ body {
overflow:hidden; -webkit-font-smoothing:antialiased;
}
#root { height:100vh; }
::selection { background:rgba(77,141,255,0.32); }
::selection { background:rgba(241,159,63,0.30); }
/* scrollbars */
::-webkit-scrollbar { width:11px; height:11px; }
@@ -62,17 +65,45 @@ body {
.app { display:flex; flex-direction:column; height:100vh; }
.titlebar {
height:36px; flex:0 0 36px; display:flex; align-items:center;
height:36px; flex:0 0 36px; display:flex; align-items:center; position:relative;
background:var(--bg-3); border-bottom:1px solid var(--border);
padding:0 12px; gap:14px; user-select:none;
}
/* branch - repository, centered in the bar */
.tb-repo {
position:absolute; left:50%; transform:translateX(-50%);
display:flex; align-items:center; gap:6px; pointer-events:none;
font-family:var(--mono); font-size:11.5px; color:var(--fg-2);
max-width:46%; white-space:nowrap; overflow:hidden;
}
.tb-repo svg { color:var(--accent); flex:0 0 auto; }
.tb-repo-branch { color:var(--fg-3); }
.tb-repo-sep { color:var(--fg-3); }
.tb-repo-name { color:var(--fg-0); font-weight:600; overflow:hidden; text-overflow:ellipsis; }
/* focus flash — branch - repository, briefly centered; same inline style as the header label, scaled up */
.focus-flash {
position:fixed; inset:0; z-index:200; pointer-events:none;
display:flex; align-items:center; justify-content:center;
animation:ff-fade 2s ease forwards;
}
.ff-card {
display:flex; align-items:center; gap:9px;
font-family:var(--mono); font-size:24px; line-height:1;
padding:20px 34px; border-radius:14px;
background:rgba(22,23,26,0.82); border:1px solid var(--border-2);
backdrop-filter:blur(8px); box-shadow:0 18px 60px rgba(0,0,0,0.5);
}
.ff-card svg { color:var(--accent); flex:0 0 auto; }
@keyframes ff-fade { 0%{opacity:0;} 12%{opacity:1;} 72%{opacity:1;} 100%{opacity:0;} }
@media (prefers-reduced-motion:reduce) { .focus-flash { animation:ff-fade-rm 2s steps(1) forwards; } @keyframes ff-fade-rm { 0%{opacity:1;} 99%{opacity:1;} 100%{opacity:0;} } }
.traffic { display:flex; gap:8px; }
.traffic i { width:12px; height:12px; border-radius:50%; display:block; }
.traffic .r{background:#e0696a;} .traffic .y{background:#d8a85c;} .traffic .g{background:#5cbd6b;}
.tb-title { font-size:12px; color:var(--fg-1); display:flex; align-items:center; gap:7px; }
.tb-title b { color:var(--fg-0); font-weight:600; }
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); }
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); display:flex; align-items:center; gap:2px; }
.tb-crumb .seg{color:var(--fg-2);}
.tb-crumb .tb-dirty { color:var(--mod); font-size:10px; margin-left:4px; }
.tb-spacer { flex:1; }
.tb-actions { display:flex; gap:6px; align-items:center; }
.tb-btn {
@@ -81,6 +112,9 @@ body {
}
.tb-btn:hover { background:var(--hover); color:var(--fg-0); }
.tb-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
.tb-toggle .tb-state { font-family:var(--mono); font-size:10px; border-radius:4px; padding:1px 5px; background:var(--bg-1); color:var(--fg-3); }
.tb-toggle.on { color:var(--fg-1); border-color:var(--border-2); }
.tb-toggle.on .tb-state { background:var(--accent-soft); color:var(--accent); }
.workbench { flex:1; display:flex; min-height:0; }
@@ -109,8 +143,8 @@ body {
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; }
.commit-input:focus { border-color:var(--accent-line); }
.commit-input::placeholder { color:var(--fg-3); }
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#0c1320; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
.commit-btn:hover:not(:disabled) { background:#5d97ff; }
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#201608; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
.commit-btn:hover:not(:disabled) { background:#f6b35f; }
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
.git-body { overflow:auto; flex:1; padding:4px 0 10px; }
.git-group { padding:8px 12px 3px; font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:6px; }
@@ -135,11 +169,10 @@ body {
.git-row.active .git-name { color:var(--fg-0); }
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
.git-dir { color:var(--fg-3); font-size:11px; margin-left:auto; padding-left:8px; white-space:nowrap; max-width:42%; overflow:hidden; text-overflow:ellipsis; direction:rtl; }
.git-act { flex:0 0 auto; display:none; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
.git-row:hover .git-act { display:flex; }
.git-act { flex:0 0 auto; display:flex; visibility:hidden; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
.git-act.push { margin-left:auto; } /* right-align when the dir column is hidden */
.git-row:hover .git-act { visibility:visible; }
.git-act:hover { background:var(--active); color:var(--fg-0); }
.git-delta { font-family:var(--mono); font-size:10.5px; display:flex; gap:6px; flex:0 0 auto; }
.git-delta .a{color:var(--add);} .git-delta .d{color:var(--del);}
.git-foot { border-top:1px solid var(--border); padding:8px 12px; display:flex; align-items:center; gap:8px; font-size:11px; color:var(--fg-2); }
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-1); }
.branch-chip b { font-weight:600; color:var(--fg-0); }
@@ -164,26 +197,6 @@ body {
.folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); }
/* ============ editor ============ */
.tabs { height:35px; flex:0 0 35px; display:flex; align-items:stretch; background:var(--bg-3); border-bottom:1px solid var(--border); overflow-x:auto; overflow-y:hidden; }
.tabs::-webkit-scrollbar { height:0; }
.tab {
display:flex; align-items:center; gap:7px; padding:0 9px 0 13px; cursor:pointer;
border-right:1px solid var(--border); color:var(--fg-2); font-size:12.5px; white-space:nowrap;
background:var(--bg-3); position:relative; max-width:230px;
}
.tab:hover { background:#272b31; }
.tab.active { background:var(--bg-0); color:var(--fg-0); }
.tab.active::after { content:""; position:absolute; left:0; right:0; top:0; height:2px; background:var(--accent); }
.tab .tname { overflow:hidden; text-overflow:ellipsis; }
.tab.dirty .tname::after { content:" ●"; color:var(--mod); font-size:10px; }
.tab .tclose { width:17px; height:17px; border-radius:4px; display:flex; align-items:center; justify-content:center; color:var(--fg-3); flex:0 0 17px; }
.tab .tclose:hover { background:var(--active); color:var(--fg-0); }
.tab .tdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--fg-2); }
.tab.dirtyclose .tclose { display:none; }
.tab.dirtyclose:hover .tclose { display:flex; }
.tab.dirtyclose:hover .tdot { display:none; }
.tab.dirtyclose .tdot { display:block; }
.tab-mode { margin-left:6px; font-size:9.5px; letter-spacing:.05em; text-transform:uppercase; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; line-height:14px; }
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
.editor { flex:1; overflow:auto; font-family:var(--code-font); font-size:var(--code-size); line-height:20px; padding:6px 0 40px; }
@@ -239,25 +252,26 @@ body {
.split-label { height:27px; flex:0 0 27px; display:flex; align-items:center; gap:9px; padding:0 16px; font-size:10.5px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-2); background:var(--bg-2); border-bottom:1px solid var(--border); }
.split-label span { text-transform:none; letter-spacing:0; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
/* syntax token colors */
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important{color:var(--t-key);}
.ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex{color:var(--t-str);}
.ln-code .token.number,.ln-code .token.unit{color:var(--t-num);}
.ln-code .token.function,.ln-code .token.method{color:var(--t-fn);}
.ln-code .token.comment,.ln-code .token.prolog,.ln-code .token.doctype,.ln-code .token.cdata{color:var(--t-com);font-style:italic;}
.ln-code .token.tag{color:var(--t-tag);}
.ln-code .token.attr-name{color:var(--t-attr);}
.ln-code .token.punctuation{color:var(--t-punc);}
.ln-code .token.operator{color:var(--t-punc);}
.ln-code .token.variable,.ln-code .token.symbol{color:var(--t-var);}
.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin{color:var(--t-const);}
.ln-code .token.property,.ln-code .token.property-access{color:var(--t-prop);}
.ln-code .token.class-name,.ln-code .token.maybe-class-name{color:var(--t-attr);}
.ln-code .token.parameter{color:var(--fg-0);}
.ln-code .token.namespace{color:var(--fg-2);}
.ln-code .token.selector{color:var(--t-tag);}
.ln-code .token.entity,.ln-code .token.url{color:var(--t-prop);}
.ln-code .token.deleted{color:var(--del);} .ln-code .token.inserted{color:var(--add);}
/* syntax token colors — applied to both the read-only line views (.ln-code)
* and the editable buffer's highlight layer (.ce-pre) */
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important,.ce-pre .token.keyword,.ce-pre .token.rule,.ce-pre .token.atrule,.ce-pre .token.important{color:var(--t-key);}
.ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex,.ce-pre .token.string,.ce-pre .token.attr-value,.ce-pre .token.char,.ce-pre .token.regex{color:var(--t-str);}
.ln-code .token.number,.ln-code .token.unit,.ce-pre .token.number,.ce-pre .token.unit{color:var(--t-num);}
.ln-code .token.function,.ln-code .token.method,.ce-pre .token.function,.ce-pre .token.method{color:var(--t-fn);}
.ln-code .token.comment,.ln-code .token.prolog,.ln-code .token.doctype,.ln-code .token.cdata,.ce-pre .token.comment,.ce-pre .token.prolog,.ce-pre .token.doctype,.ce-pre .token.cdata{color:var(--t-com);font-style:italic;}
.ln-code .token.tag,.ce-pre .token.tag{color:var(--t-tag);}
.ln-code .token.attr-name,.ce-pre .token.attr-name{color:var(--t-attr);}
.ln-code .token.punctuation,.ce-pre .token.punctuation{color:var(--t-punc);}
.ln-code .token.operator,.ce-pre .token.operator{color:var(--t-punc);}
.ln-code .token.variable,.ln-code .token.symbol,.ce-pre .token.variable,.ce-pre .token.symbol{color:var(--t-var);}
.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin,.ce-pre .token.constant,.ce-pre .token.boolean,.ce-pre .token.builtin{color:var(--t-const);}
.ln-code .token.property,.ln-code .token.property-access,.ce-pre .token.property,.ce-pre .token.property-access{color:var(--t-prop);}
.ln-code .token.class-name,.ln-code .token.maybe-class-name,.ce-pre .token.class-name,.ce-pre .token.maybe-class-name{color:var(--t-attr);}
.ln-code .token.parameter,.ce-pre .token.parameter{color:var(--fg-0);}
.ln-code .token.namespace,.ce-pre .token.namespace{color:var(--fg-2);}
.ln-code .token.selector,.ce-pre .token.selector{color:var(--t-tag);}
.ln-code .token.entity,.ln-code .token.url,.ce-pre .token.entity,.ce-pre .token.url{color:var(--t-prop);}
.ln-code .token.deleted,.ce-pre .token.deleted{color:var(--del);} .ln-code .token.inserted,.ce-pre .token.inserted{color:var(--add);}
/* ============ terminals (right column) ============ */
.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); }
@@ -289,9 +303,11 @@ body {
/* ============ overlays ============ */
.scrim { position:fixed; inset:0; background:rgba(8,9,11,0.5); z-index:50; display:flex; justify-content:center; align-items:flex-start; padding-top:90px; backdrop-filter:blur(1.5px); }
.palette { width:620px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; }
.palette .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
.palette .pi input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-size:15px; font-family:var(--ui); }
.palette .pi .mode-chip { font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); }
.palette .pi, .search-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
.palette .pi input, .search-modal .pi input { flex:1; min-width:0; background:transparent; border:0; outline:0; color:var(--fg-0); font-size:15px; font-family:var(--ui); }
.palette .pi input::placeholder, .search-modal .pi input::placeholder { color:var(--fg-3); }
.palette .pi svg, .search-modal .pi svg { flex:0 0 auto; }
.palette .pi .mode-chip, .search-modal .pi .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); }
.palette .results { max-height:380px; overflow:auto; padding:6px; }
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; }
.pres.sel { background:var(--accent-soft); }
@@ -317,6 +333,22 @@ body {
.fres-txt .fn b { color:var(--accent); font-weight:700; }
.fres-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* recent-files navigator — single-column, keyboard-driven, search-modal styling */
.history-modal { width:560px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
.history-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
.history-modal .pi svg { flex:0 0 auto; }
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
.history-modal .mode-chip kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; }
.hist-list { max-height:460px; overflow:auto; padding:5px 0; }
.hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; }
.hist-row.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); }
.hist-row:hover { background:var(--hover); }
.hist-row.sel:hover { background:rgba(241,159,63,0.20); }
.hist-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
.hist-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* content search */
.search-results { max-height:420px; overflow:auto; padding:4px 0 8px; }
.sr-file { padding:7px 14px 3px; font-size:11.5px; color:var(--fg-2); display:flex; align-items:center; gap:8px; cursor:pointer; }
@@ -368,14 +400,6 @@ body {
.toast .tref { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:2px 8px; }
/* ============ status bar ============ */
.statusbar { height:23px; flex:0 0 23px; display:flex; align-items:center; gap:0; background:var(--bg-3); border-top:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
.sb { display:flex; align-items:center; gap:6px; padding:0 11px; height:100%; }
.sb:hover { background:var(--hover); }
.sb.accent { background:var(--accent); color:#0c1320; }
.sb.accent:hover { background:#5d97ff; }
.sb.spacer { flex:1; }
.sb .a{color:var(--add);} .sb .d{color:var(--del);}
.sb b { font-weight:600; color:var(--fg-1); }
/* editable buffer — transparent textarea over a highlighted <pre>, synced gutter */
.code-edit { flex:1; min-height:0; display:flex; overflow:hidden; }
@@ -394,7 +418,7 @@ body {
position:absolute; inset:0; resize:none; outline:none; overflow:hidden;
background:transparent; color:transparent; caret-color:var(--accent);
}
.ce-ta::selection { background:rgba(77,141,255,0.32); }
.ce-ta::selection { background:rgba(241,159,63,0.30); }
/* xterm.js host (real terminals) */
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:var(--bg-1); }

View File

@@ -29,7 +29,7 @@ const THEME = {
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
const hostRef = useRef<HTMLDivElement>(null)
const [live, setLive] = useState(kind === 'agent')
const [, setLive] = useState(kind === 'agent')
useEffect(() => {
const bridge = window.helder
@@ -102,11 +102,6 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
return (
<div className="term-pane" style={{ flex: 1, minHeight: 0 }} onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}>
<div className="term-head">
<span className={'dot' + (live ? ' live' : '')}></span>
<span className="lbl">{kind === 'agent' ? 'claude' : 'zsh'}</span>
<span className="tag">{kind === 'agent' ? 'agent session' : '— shell'}</span>
</div>
<div className="term-xterm" ref={hostRef} />
</div>
)

View File

@@ -65,25 +65,19 @@ describe('Pass on to Agent', () => {
})
describe('Stage + commit', () => {
it('stages a file, commits with a message, and toasts', async () => {
it('stages a file and commits via Shift+Enter (no commit button)', async () => {
const c = renderApp()
const row = await waitFor(() => {
const r = find(c, '.git-row', 'UserController.php')
if (!r) throw new Error('git not ready')
return r
})
const stageBtn = row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!
fireEvent.click(stageBtn)
// commit button reflects the staged count once a file is staged
await waitFor(() => expect(find(c, '.commit-btn', 'Commit')?.textContent).toMatch(/Commit\s*\d/))
fireEvent.click(row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!)
// the commit button is intentionally gone — committing is keyboard-only
expect(c.querySelector('.commit-btn')).toBeNull()
const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')!
fireEvent.change(msg, { target: { value: 'wire up balance' } })
const commitBtn = find(c, '.commit-btn', 'Commit') as HTMLButtonElement
expect(commitBtn.disabled).toBe(false)
fireEvent.click(commitBtn)
fireEvent.keyDown(msg, { key: 'Enter', shiftKey: true })
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
})
})

View File

@@ -39,13 +39,13 @@ describe('App (mock data, jsdom)', () => {
const c = renderApp()
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
expect(c.querySelector('.workbench')).toBeTruthy()
expect(c.textContent).toContain('Source Control')
expect(c.textContent).toContain('Explorer')
expect(c.querySelector('.commit-box')).toBeTruthy() // git panel
expect(c.querySelector('.tree-body')).toBeTruthy() // explorer
// no file open yet
expect(c.textContent).toContain('No file open')
})
it('opens a changed file from the git panel into a diff tab', async () => {
it('opens a changed file from the git panel into the diff view', async () => {
const c = renderApp()
const row = await waitFor(() => {
const r = rowWithText(c, '.git-row', 'UserController.php')
@@ -53,12 +53,14 @@ describe('App (mock data, jsdom)', () => {
return r
})
fireEvent.click(row)
await waitFor(() => expect(rowWithText(c, '.tab', 'UserController.php')).toBeTruthy())
// changed file → diff toolbar with a status word
// no tabs anymore — the file opens straight into the editor's diff toolbar,
// and its path shows in the title-bar breadcrumb.
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
expect(c.querySelector('.diff-bar')?.textContent).toContain('Modified')
expect(rowWithText(c, '.tb-crumb', 'UserController.php')).toBeTruthy()
})
it('makes an edited buffer dirty (tab dot)', async () => {
it('makes an edited buffer dirty (breadcrumb dot)', async () => {
const c = renderApp()
const treeRow = await waitFor(() => {
const r = rowWithText(c, '.tree-row', 'store.js')
@@ -71,9 +73,9 @@ describe('App (mock data, jsdom)', () => {
if (!t) throw new Error('editor not ready')
return t
})
expect(c.querySelector('.tab.dirtyclose')).toBeNull()
expect(c.querySelector('.tb-dirty')).toBeNull()
fireEvent.change(ta, { target: { value: '// edited\n' } })
await waitFor(() => expect(c.querySelector('.tab.dirtyclose')).toBeTruthy())
await waitFor(() => expect(c.querySelector('.tb-dirty')).toBeTruthy())
})
it('searches file contents from the search modal', async () => {

40
test/search.test.ts Normal file
View File

@@ -0,0 +1,40 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { simpleGit } from 'simple-git'
import { listFiles, rgAvailable, searchContent } from '../src/main/search-service'
let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
describe('search-service (real ripgrep via dynamic import)', () => {
it('ripgrep is available (catches the ESM require() regression)', async () => {
expect(await rgAvailable()).toBe(true)
})
it('finds content matches with line number + column', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-search-'))
await simpleGit(dir).init()
await writeFile(join(dir, 'a.ts'), 'const x = 1\nconst balance = 2\n')
await writeFile(join(dir, 'b.ts'), 'nothing relevant\n')
const groups = await searchContent(dir, 'balance')
expect(groups).toHaveLength(1)
expect(groups[0].path).toBe('a.ts')
expect(groups[0].hits[0].no).toBe(2)
expect(groups[0].hits[0].ln).toContain('balance')
expect(groups[0].hits[0].ix).toBe('const '.length)
})
it('returns [] for queries under 2 characters', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-search-'))
expect(await searchContent(dir, 'a')).toEqual([])
})
it('lists project files', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-search-'))
await simpleGit(dir).init()
await writeFile(join(dir, 'keep.ts'), 'x')
expect(await listFiles(dir)).toContain('keep.ts')
})
})