faster
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-06-17 19:55:25 +02:00
parent 6beef86506
commit 0a90ab822f
9 changed files with 139 additions and 23 deletions

View File

@@ -1,5 +1,5 @@
import { readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, relative, sep } from 'node:path'
import { listFiles } from './search-service'
export interface FileNode {
@@ -120,6 +120,20 @@ export async function writeProjectFile(root: string, rel: string, content: strin
await writeFile(join(root, rel), content, 'utf8')
}
/**
* Create a new, empty text file (relative path). Creates parent folders as
* needed, refuses to escape the project root, and throws if the file already
* exists so an accidental name collision never clobbers existing content.
*/
export async function createProjectFile(root: string, rel: string): Promise<void> {
const target = join(root, rel)
if (relative(root, target).startsWith('..')) throw new Error('outside project root')
const existing = await stat(target).catch(() => null)
if (existing) throw new Error('file already exists')
await mkdir(dirname(target), { recursive: true })
await writeFile(target, '', { encoding: 'utf8', flag: 'wx' })
}
/** Delete a project file or folder (relative path). Stays inside the project root. */
export async function deleteProjectFile(root: string, rel: string): Promise<void> {
const target = join(root, rel)

View File

@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
import { watch, type FSWatcher } from 'chokidar'
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
import { deleteProjectFile, readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { createProjectFile, deleteProjectFile, 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, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
@@ -206,6 +206,7 @@ function registerIpc(): void {
ipcMain.handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' })
ipcMain.handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) })
ipcMain.handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
ipcMain.handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) })
ipcMain.handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
ipcMain.handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })

View File

@@ -26,6 +26,7 @@ const api = {
read: (path: string) => ipcRenderer.invoke('fs:read', path),
write: (path: string, content: string): Promise<void> => ipcRenderer.invoke('fs:write', path, content),
delete: (path: string): Promise<void> => ipcRenderer.invoke('fs:delete', path),
create: (path: string): Promise<void> => ipcRenderer.invoke('fs:create', path),
},
shell: {

View File

@@ -5,7 +5,7 @@ import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor'
import { Terminal, lid } from './terminals'
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, PassPopup, SearchModal, Toasts } from './overlays'
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, PassPopup, SearchModal, Toasts } from './overlays'
import { ProjectLauncher } from './launcher'
import type { Menu, Toast } from './overlays'
import type { FileNode, GitStatus } from './types'
@@ -88,6 +88,7 @@ export function App(): React.ReactElement {
const [splitFor, setSplitFor] = useState<string | null>(null)
const [commitMsg, setCommitMsg] = useState('')
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null)
const [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null)
const [confirm, setConfirm] = useState<{ title: string; body?: string; confirmLabel: string; onConfirm: () => void } | null>(null)
// Brief full-screen "branch - repository" flash whenever the window gains focus
// (handy when juggling several project windows).
@@ -283,6 +284,20 @@ export function App(): React.ReactElement {
actions.refresh()
toast(isDir ? 'Deleted folder' : 'Deleted file', path)
}
// Create a new empty file inside `dir` (project-relative folder, '' = root),
// then open it in a tab so the user can start typing right away.
async function createFile(dir: string, name: string): Promise<void> {
const rel = (dir ? dir + '/' : '') + name.replace(/^\/+/, '')
const bridge = window.helder
if (bridge) {
try { await bridge.fs.create(rel) } catch { toast('Create failed', rel); return }
}
if (dir) setOpenDirs((d) => { const n = new Set(d); n.add(dir); return n })
actions.refresh()
toast('Created file', rel)
openFile(rel)
}
function askDelete(path: string, isDir: boolean): void {
setConfirm({
title: isDir ? 'Delete folder?' : 'Delete file?',
@@ -381,6 +396,11 @@ export function App(): React.ReactElement {
sparkSend(ref),
{ icon: Icon.copy(), label: isDir ? 'Copy folder path' : 'Copy file name', onClick: () => copyText(isDir ? target.path : name, 'Copied') },
]
if (isDir) {
const mx = e.clientX, my = e.clientY
items.push({ sep: true })
items.push({ icon: Icon.file(), label: 'New file', onClick: () => setNewFilePopup({ x: mx, y: my, dir: target.path }) })
}
if (!isDir) {
items.push({ sep: true })
if (target.kind === 'git') {
@@ -520,7 +540,7 @@ export function App(): React.ReactElement {
// No project yet (launched via Spotlight / bare) → show the project launcher.
if (proj.ready && !proj.root) {
return <ProjectLauncher onOpenNew={() => actions.openFolder()} onOpenPath={(p) => actions.openProjectPath(p)} />
return <ProjectLauncher recents={proj.recents} onOpenNew={() => actions.openFolder()} onOpenPath={(p) => actions.openProjectPath(p)} />
}
return (
@@ -613,6 +633,9 @@ export function App(): React.ReactElement {
toast('Passed to agent', passPopup.ref)
}}
onCancel={() => setPassPopup(null)} />}
{newFilePopup && <NamePopup x={newFilePopup.x} y={newFilePopup.y} dir={newFilePopup.dir}
onConfirm={(name) => { createFile(newFilePopup.dir, name); setNewFilePopup(null) }}
onCancel={() => setNewFilePopup(null)} />}
{overlay === 'search' && <SearchModal initialQuery={searchInit} onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} showHidden={showHidden} />}
{overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}

View File

@@ -24,6 +24,7 @@ interface HelderBridge {
read: (path: string) => Promise<string>
write: (path: string, content: string) => Promise<void>
delete: (path: string) => Promise<void>
create: (path: string) => Promise<void>
}
shell: {
reveal: (path: string) => void

View File

@@ -4,14 +4,13 @@
* selection, ↵ opens it — same model as the recent-files navigator. */
import React, { useEffect, useRef, useState } from 'react'
import { Icon } from './components'
import type { RecentProject } from './project'
interface RecentProject { path: string; name: string }
export function ProjectLauncher({ onOpenNew, onOpenPath }: {
export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: {
recents: RecentProject[]
onOpenNew: () => void
onOpenPath: (path: string) => void
}): React.ReactElement {
const [recents, setRecents] = useState<RecentProject[]>([])
const [sel, setSel] = useState(0)
const selRef = useRef(sel); selRef.current = sel
const listRef = useRef<HTMLDivElement>(null)
@@ -19,11 +18,6 @@ export function ProjectLauncher({ onOpenNew, onOpenPath }: {
// rows = [new project, ...recents]; total selectable count
const count = recents.length + 1
useEffect(() => {
const bridge = window.helder
if (bridge) bridge.project.recent().then(setRecents).catch(() => setRecents([]))
}, [])
function activate(i: number): void {
if (i <= 0) onOpenNew()
else if (recents[i - 1]) onOpenPath(recents[i - 1].path)

View File

@@ -488,3 +488,41 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
</div>
)
}
export function NamePopup({ x, y, dir, onConfirm, onCancel }: {
x: number
y: number
dir: string
onConfirm: (name: string) => void
onCancel: () => void
}): React.ReactElement {
const [name, setName] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const boxRef = useRef<HTMLDivElement>(null)
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() } }
document.addEventListener('mousedown', h)
document.addEventListener('keydown', k, true)
return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k, true) }
}, [])
const left = Math.min(x, window.innerWidth - 360)
const top = Math.min(y + 6, window.innerHeight - 150)
const trimmed = name.trim()
const target = (dir ? dir + '/' : '') + trimmed
return (
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
<div className="pass-head">{Icon.file()}<span>New file</span><span className="pass-esc">esc</span></div>
<input ref={inputRef} className="pass-input" value={name} spellCheck={false}
placeholder="file name…"
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); if (trimmed) onConfirm(trimmed) }
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
}} />
<div className="pass-preview"><span className="pp-lbl">creates</span><code>{target || '…'}</code></div>
<div className="pass-foot"><kbd></kbd> create file · <kbd>esc</kbd> cancel</div>
</div>
)
}

View File

@@ -8,6 +8,8 @@ import { DEFAULT_CONFIG } from './types'
import { makeDiff } from './diff'
import { PROJECT as MOCK } from './data'
export interface RecentProject { path: string; name: string }
export interface ProjectData {
name: string
root: string | null
@@ -20,6 +22,9 @@ export interface ProjectData {
config: HelderConfig
isRepo: boolean
ready: boolean
// Prefetched at startup (parallel to the project load) so the launcher paints
// its list with no extra round-trip when there's no project open.
recents: RecentProject[]
}
/** Inject the project's theme.css over the built-in dark theme. */
@@ -54,13 +59,13 @@ function mockData(): ProjectData {
// non-null root so browser-preview shows the workbench, not the launcher
name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch,
tree: MOCK.tree, files: MOCK.files, changes: MOCK.changes, diffs: MOCK.diffs,
staged: new Set(MOCK_STAGED), config: DEFAULT_CONFIG, isRepo: true, ready: true,
staged: new Set(MOCK_STAGED), config: DEFAULT_CONFIG, isRepo: true, ready: true, recents: [],
}
}
const emptyData: ProjectData = {
name: 'Loading…', root: null, branch: '—', tree: null, files: {},
changes: [], diffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false,
changes: [], diffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false, recents: [],
}
const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({
@@ -107,21 +112,35 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
// apply its result — otherwise a slow/transient mid-checkout read can
// resolve last and clobber the correct settled state.
const seq = ++loadSeq.current
const [cur, tree, files, git, config, theme] = await Promise.all([
bridge.project.current(),
const cur = await bridge.project.current()
if (seq !== loadSeq.current) return
// Bare launch (Spotlight / no project): flip ready immediately so the
// launcher paints, skipping the tree/files/git/theme reads it doesn't need.
if (!cur.root) {
setData((d) => ({ ...emptyData, name: cur.name, root: null, ready: true, recents: d.recents }))
return
}
const [tree, git, config, theme] = await Promise.all([
bridge.fs.tree(),
bridge.fs.files(),
bridge.git.load(),
bridge.config.get(),
bridge.config.theme(),
])
if (seq !== loadSeq.current) return
applyTheme(theme)
setData({
setData((d) => ({
name: cur.name, root: cur.root,
tree, files: files || {}, config, ready: true,
tree, files: d.root === cur.root ? d.files : {}, config, ready: true, recents: d.recents,
...deriveGit(git),
})
}))
// The whole-repo content index is only a fallback (real viewing/search go
// through fs.read + ripgrep), and reading every file serially costs seconds.
// Build it in the background and patch it in — never block the workbench on
// it. Lazily-loaded files (ensureFile) win over the bulk read.
bridge.fs.files().then((files) => {
if (seq !== loadSeq.current) return
setData((d) => ({ ...d, files: { ...(files || {}), ...d.files } }))
}).catch(() => {})
}
// Fast path for the three git mutations (stage / unstage / commit) + discard:
@@ -147,6 +166,11 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
useEffect(() => {
if (!bridge) { setData(mockData()); return }
// Fetch recents in parallel with the project load (not after it) so the list
// is already in state by the time the launcher mounts.
bridge.project.recent()
.then((list) => setData((d) => ({ ...d, recents: list })))
.catch(() => {})
loadReal().catch(() => setData((d) => ({ ...d, ready: true })))
const offProject = bridge.onProjectChanged(() => { loadReal().catch(() => {}) })
const offConfig = bridge.onConfigChanged(() => { loadConfigTheme().catch(() => {}) })

View File

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { simpleGit } from 'simple-git'
import { buildTreeFromPaths, readAll, readProjectFile, readTree, writeProjectFile } from '../src/main/fs-service'
import { buildTreeFromPaths, createProjectFile, readAll, readProjectFile, readTree, writeProjectFile } from '../src/main/fs-service'
let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
@@ -76,3 +76,23 @@ describe('read/write round-trip', () => {
expect(await readProjectFile(dir, 'note.txt')).toBe('hello world\n')
})
})
describe('createProjectFile', () => {
it('creates an empty file and makes missing parent folders', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await createProjectFile(dir, 'src/new/fresh.ts')
expect(await readProjectFile(dir, 'src/new/fresh.ts')).toBe('')
})
it('refuses to overwrite an existing file', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await writeProjectFile(dir, 'keep.txt', 'precious\n')
await expect(createProjectFile(dir, 'keep.txt')).rejects.toThrow()
expect(await readProjectFile(dir, 'keep.txt')).toBe('precious\n')
})
it('refuses to escape the project root', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await expect(createProjectFile(dir, '../escape.txt')).rejects.toThrow()
})
})