diff --git a/src/main/fs-service.ts b/src/main/fs-service.ts index 6a37435..2ac60bf 100644 --- a/src/main/fs-service.ts +++ b/src/main/fs-service.ts @@ -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 { + 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 { const target = join(root, rel) diff --git a/src/main/index.ts b/src/main/index.ts index c740e1c..bafcadb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 97833d4..f431048 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,6 +26,7 @@ const api = { read: (path: string) => ipcRenderer.invoke('fs:read', path), write: (path: string, content: string): Promise => ipcRenderer.invoke('fs:write', path, content), delete: (path: string): Promise => ipcRenderer.invoke('fs:delete', path), + create: (path: string): Promise => ipcRenderer.invoke('fs:create', path), }, shell: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4eff7f5..c38250a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(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 { + 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 actions.openFolder()} onOpenPath={(p) => actions.openProjectPath(p)} /> + return 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 && { createFile(newFilePopup.dir, name); setNewFilePopup(null) }} + onCancel={() => setNewFilePopup(null)} />} {overlay === 'search' && openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} showHidden={showHidden} />} {overlay === 'history' && setOverlay(null)} changeSet={changeSet} />} {overlay === 'help' && setOverlay(null)} />} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index f4a96b3..d11eaee 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -24,6 +24,7 @@ interface HelderBridge { read: (path: string) => Promise write: (path: string, content: string) => Promise delete: (path: string) => Promise + create: (path: string) => Promise } shell: { reveal: (path: string) => void diff --git a/src/renderer/src/launcher.tsx b/src/renderer/src/launcher.tsx index 22a7d60..e727146 100644 --- a/src/renderer/src/launcher.tsx +++ b/src/renderer/src/launcher.tsx @@ -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([]) const [sel, setSel] = useState(0) const selRef = useRef(sel); selRef.current = sel const listRef = useRef(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) diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx index 1396e53..eac9020 100644 --- a/src/renderer/src/overlays.tsx +++ b/src/renderer/src/overlays.tsx @@ -488,3 +488,41 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: { ) } + +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(null) + const boxRef = useRef(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 ( +
+
{Icon.file()}New fileesc
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); if (trimmed) onConfirm(trimmed) } + else if (e.key === 'Escape') { e.preventDefault(); onCancel() } + }} /> +
creates{target || '…'}
+
create file · esc cancel
+
+ ) +} diff --git a/src/renderer/src/project.tsx b/src/renderer/src/project.tsx index bbc8556..b2c206c 100644 --- a/src/renderer/src/project.tsx +++ b/src/renderer/src/project.tsx @@ -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(() => {}) }) diff --git a/test/fs.test.ts b/test/fs.test.ts index ba5c425..080ba00 100644 --- a/test/fs.test.ts +++ b/test/fs.test.ts @@ -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() + }) +})