@@ -1,5 +1,5 @@
|
|||||||
import { readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||||
import { join, relative, sep } from 'node:path'
|
import { dirname, join, relative, sep } from 'node:path'
|
||||||
import { listFiles } from './search-service'
|
import { listFiles } from './search-service'
|
||||||
|
|
||||||
export interface FileNode {
|
export interface FileNode {
|
||||||
@@ -120,6 +120,20 @@ export async function writeProjectFile(root: string, rel: string, content: strin
|
|||||||
await writeFile(join(root, rel), content, 'utf8')
|
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. */
|
/** Delete a project file or folder (relative path). Stays inside the project root. */
|
||||||
export async function deleteProjectFile(root: string, rel: string): Promise<void> {
|
export async function deleteProjectFile(root: string, rel: string): Promise<void> {
|
||||||
const target = join(root, rel)
|
const target = join(root, rel)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'
|
|||||||
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
|
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
|
||||||
import { watch, type FSWatcher } from 'chokidar'
|
import { watch, type FSWatcher } from 'chokidar'
|
||||||
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
|
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 { commit, discard, load, stage, unstage } from './git-service'
|
||||||
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
||||||
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
|
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: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: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: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('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 })
|
ipcMain.handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const api = {
|
|||||||
read: (path: string) => ipcRenderer.invoke('fs:read', path),
|
read: (path: string) => ipcRenderer.invoke('fs:read', path),
|
||||||
write: (path: string, content: string): Promise<void> => ipcRenderer.invoke('fs:write', path, content),
|
write: (path: string, content: string): Promise<void> => ipcRenderer.invoke('fs:write', path, content),
|
||||||
delete: (path: string): Promise<void> => ipcRenderer.invoke('fs:delete', path),
|
delete: (path: string): Promise<void> => ipcRenderer.invoke('fs:delete', path),
|
||||||
|
create: (path: string): Promise<void> => ipcRenderer.invoke('fs:create', path),
|
||||||
},
|
},
|
||||||
|
|
||||||
shell: {
|
shell: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ContextTarget } from './components'
|
|||||||
import { Editor, SplitView } from './editor'
|
import { Editor, SplitView } from './editor'
|
||||||
import type { Cursor, Mode, Selection } from './editor'
|
import type { Cursor, Mode, Selection } from './editor'
|
||||||
import { Terminal, lid } from './terminals'
|
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 { ProjectLauncher } from './launcher'
|
||||||
import type { Menu, Toast } from './overlays'
|
import type { Menu, Toast } from './overlays'
|
||||||
import type { FileNode, GitStatus } from './types'
|
import type { FileNode, GitStatus } from './types'
|
||||||
@@ -88,6 +88,7 @@ export function App(): React.ReactElement {
|
|||||||
const [splitFor, setSplitFor] = useState<string | null>(null)
|
const [splitFor, setSplitFor] = useState<string | null>(null)
|
||||||
const [commitMsg, setCommitMsg] = useState('')
|
const [commitMsg, setCommitMsg] = useState('')
|
||||||
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null)
|
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)
|
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
|
// Brief full-screen "branch - repository" flash whenever the window gains focus
|
||||||
// (handy when juggling several project windows).
|
// (handy when juggling several project windows).
|
||||||
@@ -283,6 +284,20 @@ export function App(): React.ReactElement {
|
|||||||
actions.refresh()
|
actions.refresh()
|
||||||
toast(isDir ? 'Deleted folder' : 'Deleted file', path)
|
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 {
|
function askDelete(path: string, isDir: boolean): void {
|
||||||
setConfirm({
|
setConfirm({
|
||||||
title: isDir ? 'Delete folder?' : 'Delete file?',
|
title: isDir ? 'Delete folder?' : 'Delete file?',
|
||||||
@@ -381,6 +396,11 @@ export function App(): React.ReactElement {
|
|||||||
sparkSend(ref),
|
sparkSend(ref),
|
||||||
{ icon: Icon.copy(), label: isDir ? 'Copy folder path' : 'Copy file name', onClick: () => copyText(isDir ? target.path : name, 'Copied') },
|
{ 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) {
|
if (!isDir) {
|
||||||
items.push({ sep: true })
|
items.push({ sep: true })
|
||||||
if (target.kind === 'git') {
|
if (target.kind === 'git') {
|
||||||
@@ -520,7 +540,7 @@ export function App(): React.ReactElement {
|
|||||||
|
|
||||||
// No project yet (launched via Spotlight / bare) → show the project launcher.
|
// No project yet (launched via Spotlight / bare) → show the project launcher.
|
||||||
if (proj.ready && !proj.root) {
|
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 (
|
return (
|
||||||
@@ -613,6 +633,9 @@ export function App(): React.ReactElement {
|
|||||||
toast('Passed to agent', passPopup.ref)
|
toast('Passed to agent', passPopup.ref)
|
||||||
}}
|
}}
|
||||||
onCancel={() => setPassPopup(null)} />}
|
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 === '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 === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
||||||
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
|
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
|
||||||
|
|||||||
1
src/renderer/src/env.d.ts
vendored
1
src/renderer/src/env.d.ts
vendored
@@ -24,6 +24,7 @@ interface HelderBridge {
|
|||||||
read: (path: string) => Promise<string>
|
read: (path: string) => Promise<string>
|
||||||
write: (path: string, content: string) => Promise<void>
|
write: (path: string, content: string) => Promise<void>
|
||||||
delete: (path: string) => Promise<void>
|
delete: (path: string) => Promise<void>
|
||||||
|
create: (path: string) => Promise<void>
|
||||||
}
|
}
|
||||||
shell: {
|
shell: {
|
||||||
reveal: (path: string) => void
|
reveal: (path: string) => void
|
||||||
|
|||||||
@@ -4,14 +4,13 @@
|
|||||||
* selection, ↵ opens it — same model as the recent-files navigator. */
|
* selection, ↵ opens it — same model as the recent-files navigator. */
|
||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { Icon } from './components'
|
import { Icon } from './components'
|
||||||
|
import type { RecentProject } from './project'
|
||||||
|
|
||||||
interface RecentProject { path: string; name: string }
|
export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: {
|
||||||
|
recents: RecentProject[]
|
||||||
export function ProjectLauncher({ onOpenNew, onOpenPath }: {
|
|
||||||
onOpenNew: () => void
|
onOpenNew: () => void
|
||||||
onOpenPath: (path: string) => void
|
onOpenPath: (path: string) => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const [recents, setRecents] = useState<RecentProject[]>([])
|
|
||||||
const [sel, setSel] = useState(0)
|
const [sel, setSel] = useState(0)
|
||||||
const selRef = useRef(sel); selRef.current = sel
|
const selRef = useRef(sel); selRef.current = sel
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -19,11 +18,6 @@ export function ProjectLauncher({ onOpenNew, onOpenPath }: {
|
|||||||
// rows = [new project, ...recents]; total selectable count
|
// rows = [new project, ...recents]; total selectable count
|
||||||
const count = recents.length + 1
|
const count = recents.length + 1
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const bridge = window.helder
|
|
||||||
if (bridge) bridge.project.recent().then(setRecents).catch(() => setRecents([]))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
function activate(i: number): void {
|
function activate(i: number): void {
|
||||||
if (i <= 0) onOpenNew()
|
if (i <= 0) onOpenNew()
|
||||||
else if (recents[i - 1]) onOpenPath(recents[i - 1].path)
|
else if (recents[i - 1]) onOpenPath(recents[i - 1].path)
|
||||||
|
|||||||
@@ -488,3 +488,41 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { DEFAULT_CONFIG } from './types'
|
|||||||
import { makeDiff } from './diff'
|
import { makeDiff } from './diff'
|
||||||
import { PROJECT as MOCK } from './data'
|
import { PROJECT as MOCK } from './data'
|
||||||
|
|
||||||
|
export interface RecentProject { path: string; name: string }
|
||||||
|
|
||||||
export interface ProjectData {
|
export interface ProjectData {
|
||||||
name: string
|
name: string
|
||||||
root: string | null
|
root: string | null
|
||||||
@@ -20,6 +22,9 @@ export interface ProjectData {
|
|||||||
config: HelderConfig
|
config: HelderConfig
|
||||||
isRepo: boolean
|
isRepo: boolean
|
||||||
ready: 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. */
|
/** 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
|
// non-null root so browser-preview shows the workbench, not the launcher
|
||||||
name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch,
|
name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch,
|
||||||
tree: MOCK.tree, files: MOCK.files, changes: MOCK.changes, diffs: MOCK.diffs,
|
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 = {
|
const emptyData: ProjectData = {
|
||||||
name: 'Loading…', root: null, branch: '—', tree: null, files: {},
|
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 }>({
|
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
|
// apply its result — otherwise a slow/transient mid-checkout read can
|
||||||
// resolve last and clobber the correct settled state.
|
// resolve last and clobber the correct settled state.
|
||||||
const seq = ++loadSeq.current
|
const seq = ++loadSeq.current
|
||||||
const [cur, tree, files, git, config, theme] = await Promise.all([
|
const cur = await bridge.project.current()
|
||||||
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.tree(),
|
||||||
bridge.fs.files(),
|
|
||||||
bridge.git.load(),
|
bridge.git.load(),
|
||||||
bridge.config.get(),
|
bridge.config.get(),
|
||||||
bridge.config.theme(),
|
bridge.config.theme(),
|
||||||
])
|
])
|
||||||
if (seq !== loadSeq.current) return
|
if (seq !== loadSeq.current) return
|
||||||
applyTheme(theme)
|
applyTheme(theme)
|
||||||
setData({
|
setData((d) => ({
|
||||||
name: cur.name, root: cur.root,
|
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),
|
...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:
|
// Fast path for the three git mutations (stage / unstage / commit) + discard:
|
||||||
@@ -147,6 +166,11 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bridge) { setData(mockData()); return }
|
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 })))
|
loadReal().catch(() => setData((d) => ({ ...d, ready: true })))
|
||||||
const offProject = bridge.onProjectChanged(() => { loadReal().catch(() => {}) })
|
const offProject = bridge.onProjectChanged(() => { loadReal().catch(() => {}) })
|
||||||
const offConfig = bridge.onConfigChanged(() => { loadConfigTheme().catch(() => {}) })
|
const offConfig = bridge.onConfigChanged(() => { loadConfigTheme().catch(() => {}) })
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { simpleGit } from 'simple-git'
|
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 = ''
|
let dir = ''
|
||||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
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')
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user