toggle hidden files
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-06-17 15:36:39 +02:00
parent e55f4e714e
commit 513af0e164
4 changed files with 88 additions and 32 deletions

View File

@@ -104,7 +104,13 @@ export function App(): React.ReactElement {
function isDirty(path: string): boolean { return buffers[path] != null && buffers[path] !== diskText(path) }
function writeToDisk(path: string, text: string): void {
if (window.helder) window.helder.fs.write(path, text).catch(() => toast('Save failed', path))
if (!window.helder) return
// A save flips the file's working-tree state (clean → modified, etc.), so
// refresh the git column directly the moment the write lands rather than
// waiting on the FS watcher's debounce.
window.helder.fs.write(path, text)
.then(() => actions.refreshGit())
.catch(() => toast('Save failed', path))
}
function saveActive(): void {
if (!active) return
@@ -144,6 +150,7 @@ export function App(): React.ReactElement {
// 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 [showHidden, setShowHidden] = useState(false)
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))
@@ -538,6 +545,10 @@ export function App(): React.ReactElement {
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>
<button className={'tb-btn tb-toggle' + (showHidden ? ' on' : '')} onClick={() => setShowHidden((v) => !v)}
title={showHidden ? 'Hidden files: shown — dotfiles appear in the tree and search. Click to hide.' : 'Hidden files: hidden — dotfiles excluded from the tree and search. Click to show.'}>
{Icon.eye()} Hidden <span className="tb-state">{showHidden ? 'On' : 'Off'}</span>
</button>
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
</div>
</div>
@@ -567,7 +578,7 @@ export function App(): React.ReactElement {
<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} />
onContext={openMenu} activePath={active} changeMap={changeMap} committed={NO_COMMITTED} showHidden={showHidden} />
) : (
<div className="tree-body" />
)}
@@ -602,7 +613,7 @@ export function App(): React.ReactElement {
toast('Passed to agent', passPopup.ref)
}}
onCancel={() => setPassPopup(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)} />}
{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)} />}
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />}

View File

@@ -25,6 +25,7 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
help: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><circle cx="8" cy="8" r="6.2" stroke="currentColor" strokeWidth="1.3" /><path d="M6.3 6.2a1.7 1.7 0 1 1 2.3 1.6c-.5.25-.8.6-.8 1.2v.3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><circle cx="8" cy="11.4" r=".75" fill="currentColor" /></svg>),
trash: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
finder: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><path d="M9 7l3-3M12 4v2.6M12 4H9.4" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
eye: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" stroke="currentColor" strokeWidth="1.2" fill="none" /><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.2" /></svg>),
}
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
@@ -168,7 +169,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
}
/* ============ File Tree ============ */
function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }: {
function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed, showHidden }: {
node: FileNode
depth: number
openDirs: Set<string>
@@ -178,6 +179,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
activePath: string | null
changeMap: Record<string, GitStatus>
committed: Set<string>
showHidden: boolean
}): React.ReactElement {
const pad = 10 + depth * 13
if (node.type === 'dir') {
@@ -193,11 +195,13 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
<span className="tree-label">{node.name}</span>
</div>
)}
{isOpen && (node.children || []).map((c) => (
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} />
))}
{isOpen && (node.children || [])
.filter((c) => showHidden || !c.name.startsWith('.'))
.map((c) => (
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
))}
</Fragment>
)
}
@@ -216,7 +220,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
)
}
export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }: {
export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed, showHidden }: {
tree: FileNode
openDirs: Set<string>
toggleDir: (path: string) => void
@@ -225,12 +229,13 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
activePath: string | null
changeMap: Record<string, GitStatus>
committed: Set<string>
showHidden: boolean
}): React.ReactElement {
return (
<Fragment>
<div className="tree-body">
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
onOpen={onOpen} onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} />
onOpen={onOpen} onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
</div>
</Fragment>
)

View File

@@ -24,7 +24,14 @@ function Highlight({ text, idx }: { text: string; idx: number[] | null }): React
return <span>{text.split('').map((ch, i) => set.has(i) ? <b key={i}>{ch}</b> : <Fragment key={i}>{ch}</Fragment>)}</span>
}
export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet, activePath, activeText }: {
/** A path is hidden if any of its segments is a dotfile/dotfolder (e.g. `.env`,
* `src/.cache/x`). Used to exclude hidden entries from search when the toggle is
* off — mirrors the Explorer tree filter. */
function isHiddenPath(p: string): boolean {
return p.split('/').some((seg) => seg.startsWith('.'))
}
export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet, activePath, activeText, showHidden }: {
initialQuery?: string
onOpen: OpenFile
onOpenAt: (path: string, line: number) => void
@@ -32,6 +39,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
changeSet: Set<string>
activePath?: string | null
activeText?: string
showHidden?: boolean
}): React.ReactElement {
const PROJECT = useProject()
const bridge = window.helder
@@ -82,6 +90,12 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
return
}, [q])
// hide dotfile content hits unless the Hidden toggle is on
const visibleContent = useMemo(
() => (showHidden ? content : content.filter((g) => !isHiddenPath(g.path))),
[content, showHidden],
)
// in-file matches (leftmost): substring grep within the currently open file's buffer
const inFile = useMemo(() => {
const term = q.trim()
@@ -101,6 +115,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
if (!term) return []
const out: { path: string; idx: number[] | null; rank: number; pos: number }[] = []
for (const p of allPaths) {
if (!showHidden && isHiddenPath(p)) continue
const name = p.split('/').pop() as string
const ni = fuzzy(term, name)
if (ni) { out.push({ path: p, idx: ni, rank: 0, pos: ni[0] }); continue }
@@ -109,14 +124,14 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
}
out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length)
return out
}, [q, allPaths])
}, [q, allPaths, showHidden])
// flat list of content hits for keyboard nav
const flat = useMemo(() => {
const arr: { path: string; no: number }[] = []
content.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no })))
visibleContent.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no })))
return arr
}, [content])
}, [visibleContent])
const totalHits = flat.length
const fileCount = Math.min(files.length, 40)
@@ -208,8 +223,8 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
<div className={'sc-left' + (col === 'content' ? ' active' : '')} ref={leftRef}>
<div className="sc-head">Project {totalHits > 0 && <span className="sc-ct">{totalHits}</span>} {!hasInFile && <kbd className="col-kbd"></kbd>}</div>
{term.length < 2 && <div className="pempty">Type at least 2 characters</div>}
{term.length >= 2 && content.length === 0 && <div className="pempty">No content matches</div>}
{content.map((g) => (
{term.length >= 2 && visibleContent.length === 0 && <div className="pempty">No content matches</div>}
{visibleContent.map((g) => (
<Fragment key={g.path}>
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}>
<FileIcon path={g.path} />

View File

@@ -37,6 +37,7 @@ export interface ProjectActions {
openFolder: () => void
openProjectPath: (path: string) => void
refresh: () => void
refreshGit: () => void
stage: (path: string) => void
unstage: (path: string) => void
stageAll: () => void
@@ -67,6 +68,24 @@ const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({
actions: {} as ProjectActions,
})
/** Map a git.load() result into the git-derived slice of ProjectData. Shared by
* the full reload and the git-only fast path so the two stay in lockstep. */
type GitLoadResult = Awaited<ReturnType<NonNullable<typeof window.helder>['git']['load']>>
function deriveGit(git: GitLoadResult): Pick<ProjectData, 'branch' | 'changes' | 'diffs' | 'staged' | 'isRepo'> {
const changes: Change[] = []
const diffs: Record<string, Diff> = {}
const staged = new Set<string>()
if (git) {
for (const c of git.changes) {
const d = makeDiff(c.status, c.original, c.updated)
diffs[c.path] = d
changes.push({ path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D' })
if (c.staged) staged.add(c.path)
}
}
return { branch: git ? git.branch : '—', changes, diffs, staged, isRepo: !!git }
}
export function useProject(): ProjectData {
return useContext(Ctx).data
}
@@ -98,23 +117,27 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
])
if (seq !== loadSeq.current) return
applyTheme(theme)
const changes: Change[] = []
const diffs: Record<string, Diff> = {}
const staged = new Set<string>()
if (git) {
for (const c of git.changes) {
const d = makeDiff(c.status, c.original, c.updated)
diffs[c.path] = d
changes.push({ path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D' })
if (c.staged) staged.add(c.path)
}
}
setData({
name: cur.name, root: cur.root, branch: git ? git.branch : '—',
tree, files: files || {}, changes, diffs, staged, config, isRepo: !!git, ready: true,
name: cur.name, root: cur.root,
tree, files: files || {}, config, ready: true,
...deriveGit(git),
})
}
// Fast path for the three git mutations (stage / unstage / commit) + discard:
// re-read ONLY git status and patch the git fields, skipping the full
// loadReal() that also re-walks the file tree + content index. This is the
// direct, immediate refresh those actions trigger — the .git watcher stays a
// backstop for git changes made by external tools. Shares loadSeq so a
// concurrent full reload still settles to the newest read.
async function loadGit(): Promise<void> {
if (!bridge) return
const seq = ++loadSeq.current
const git = await bridge.git.load()
if (seq !== loadSeq.current) return
setData((d) => ({ ...d, ...deriveGit(git) }))
}
async function loadConfigTheme(): Promise<void> {
if (!bridge) return
const [config, theme] = await Promise.all([bridge.config.get(), bridge.config.theme()])
@@ -140,6 +163,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
openFolder: () => {},
openProjectPath: () => {},
refresh: () => setData(mockData()),
refreshGit: () => {},
stage: (p) => setStaged((s) => (s.add(p), s)),
unstage: (p) => setStaged((s) => (s.delete(p), s)),
stageAll: () => setData((d) => ({ ...d, staged: new Set(d.changes.map((c) => c.path)) })),
@@ -159,11 +183,12 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
}
}
// ---- real git-backed actions ----
const after = (op: Promise<unknown>): void => { op.then(() => loadReal()).catch(() => {}) }
const after = (op: Promise<unknown>): void => { op.then(() => loadGit()).catch(() => {}) }
return {
openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) },
openProjectPath: (path) => { bridge.project.openPath(path).then(() => loadReal()).catch(() => {}) },
refresh: () => { loadReal().catch(() => {}) },
refreshGit: () => { loadGit().catch(() => {}) },
stage: (p) => after(bridge.git.stage([p])),
unstage: (p) => after(bridge.git.unstage([p])),
stageAll: () => {
@@ -179,7 +204,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
const cur = dataRef.current
const n = cur.changes.filter((c) => cur.staged.has(c.path)).length
await bridge.git.commit(msg)
await loadReal()
await loadGit()
return n
},
discard: (p) => after(bridge.git.discard([p])),