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 isDirty(path: string): boolean { return buffers[path] != null && buffers[path] !== diskText(path) }
function writeToDisk(path: string, text: string): void { 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 { function saveActive(): void {
if (!active) return if (!active) return
@@ -144,6 +150,7 @@ export function App(): React.ReactElement {
// splitter switches it off (the user took control); the title-bar toggle // splitter switches it off (the user took control); the title-bar toggle
// turns it back on (and immediately re-fits). // turns it back on (and immediately re-fits).
const [autoResize, setAutoResize] = useState(true) const [autoResize, setAutoResize] = useState(true)
const [showHidden, setShowHidden] = useState(false)
const [gitW, setGitW] = useState(() => Math.round(window.innerWidth * 0.15)) const [gitW, setGitW] = useState(() => Math.round(window.innerWidth * 0.15))
const [treeW, setTreeW] = 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)) 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.'}> 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> {Icon.layout()} Auto-fit <span className="tb-state">{autoResize ? 'On' : 'Off'}</span>
</button> </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> <button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
</div> </div>
</div> </div>
@@ -567,7 +578,7 @@ export function App(): React.ReactElement {
<div className="col" style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}> <div className="col" style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}>
{proj.tree ? ( {proj.tree ? (
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile} <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" /> <div className="tree-body" />
)} )}
@@ -602,7 +613,7 @@ export function App(): React.ReactElement {
toast('Passed to agent', passPopup.ref) toast('Passed to agent', passPopup.ref)
}} }}
onCancel={() => setPassPopup(null)} />} 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 === '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)} />}
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(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>), 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>), 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>), 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 => ( export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
@@ -168,7 +169,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
} }
/* ============ File Tree ============ */ /* ============ 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 node: FileNode
depth: number depth: number
openDirs: Set<string> openDirs: Set<string>
@@ -178,6 +179,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
activePath: string | null activePath: string | null
changeMap: Record<string, GitStatus> changeMap: Record<string, GitStatus>
committed: Set<string> committed: Set<string>
showHidden: boolean
}): React.ReactElement { }): React.ReactElement {
const pad = 10 + depth * 13 const pad = 10 + depth * 13
if (node.type === 'dir') { if (node.type === 'dir') {
@@ -193,10 +195,12 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
<span className="tree-label">{node.name}</span> <span className="tree-label">{node.name}</span>
</div> </div>
)} )}
{isOpen && (node.children || []).map((c) => ( {isOpen && (node.children || [])
.filter((c) => showHidden || !c.name.startsWith('.'))
.map((c) => (
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1} <TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen} openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} /> onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
))} ))}
</Fragment> </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 tree: FileNode
openDirs: Set<string> openDirs: Set<string>
toggleDir: (path: string) => void toggleDir: (path: string) => void
@@ -225,12 +229,13 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
activePath: string | null activePath: string | null
changeMap: Record<string, GitStatus> changeMap: Record<string, GitStatus>
committed: Set<string> committed: Set<string>
showHidden: boolean
}): React.ReactElement { }): React.ReactElement {
return ( return (
<Fragment> <Fragment>
<div className="tree-body"> <div className="tree-body">
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir} <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> </div>
</Fragment> </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> 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 initialQuery?: string
onOpen: OpenFile onOpen: OpenFile
onOpenAt: (path: string, line: number) => void onOpenAt: (path: string, line: number) => void
@@ -32,6 +39,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
changeSet: Set<string> changeSet: Set<string>
activePath?: string | null activePath?: string | null
activeText?: string activeText?: string
showHidden?: boolean
}): React.ReactElement { }): React.ReactElement {
const PROJECT = useProject() const PROJECT = useProject()
const bridge = window.helder const bridge = window.helder
@@ -82,6 +90,12 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
return return
}, [q]) }, [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 // in-file matches (leftmost): substring grep within the currently open file's buffer
const inFile = useMemo(() => { const inFile = useMemo(() => {
const term = q.trim() const term = q.trim()
@@ -101,6 +115,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
if (!term) return [] if (!term) return []
const out: { path: string; idx: number[] | null; rank: number; pos: number }[] = [] const out: { path: string; idx: number[] | null; rank: number; pos: number }[] = []
for (const p of allPaths) { for (const p of allPaths) {
if (!showHidden && isHiddenPath(p)) continue
const name = p.split('/').pop() as string const name = p.split('/').pop() as string
const ni = fuzzy(term, name) const ni = fuzzy(term, name)
if (ni) { out.push({ path: p, idx: ni, rank: 0, pos: ni[0] }); continue } 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) out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length)
return out return out
}, [q, allPaths]) }, [q, allPaths, showHidden])
// flat list of content hits for keyboard nav // flat list of content hits for keyboard nav
const flat = useMemo(() => { const flat = useMemo(() => {
const arr: { path: string; no: number }[] = [] 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 return arr
}, [content]) }, [visibleContent])
const totalHits = flat.length const totalHits = flat.length
const fileCount = Math.min(files.length, 40) 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-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> <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 && <div className="pempty">Type at least 2 characters</div>}
{term.length >= 2 && content.length === 0 && <div className="pempty">No content matches</div>} {term.length >= 2 && visibleContent.length === 0 && <div className="pempty">No content matches</div>}
{content.map((g) => ( {visibleContent.map((g) => (
<Fragment key={g.path}> <Fragment key={g.path}>
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}> <div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}>
<FileIcon path={g.path} /> <FileIcon path={g.path} />

View File

@@ -37,6 +37,7 @@ export interface ProjectActions {
openFolder: () => void openFolder: () => void
openProjectPath: (path: string) => void openProjectPath: (path: string) => void
refresh: () => void refresh: () => void
refreshGit: () => void
stage: (path: string) => void stage: (path: string) => void
unstage: (path: string) => void unstage: (path: string) => void
stageAll: () => void stageAll: () => void
@@ -67,6 +68,24 @@ const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({
actions: {} as 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 { export function useProject(): ProjectData {
return useContext(Ctx).data return useContext(Ctx).data
} }
@@ -98,23 +117,27 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
]) ])
if (seq !== loadSeq.current) return if (seq !== loadSeq.current) return
applyTheme(theme) 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({ setData({
name: cur.name, root: cur.root, branch: git ? git.branch : '—', name: cur.name, root: cur.root,
tree, files: files || {}, changes, diffs, staged, config, isRepo: !!git, ready: true, 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> { async function loadConfigTheme(): Promise<void> {
if (!bridge) return if (!bridge) return
const [config, theme] = await Promise.all([bridge.config.get(), bridge.config.theme()]) 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: () => {}, openFolder: () => {},
openProjectPath: () => {}, openProjectPath: () => {},
refresh: () => setData(mockData()), refresh: () => setData(mockData()),
refreshGit: () => {},
stage: (p) => setStaged((s) => (s.add(p), s)), stage: (p) => setStaged((s) => (s.add(p), s)),
unstage: (p) => setStaged((s) => (s.delete(p), s)), unstage: (p) => setStaged((s) => (s.delete(p), s)),
stageAll: () => setData((d) => ({ ...d, staged: new Set(d.changes.map((c) => c.path)) })), 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 ---- // ---- 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 { return {
openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) }, openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) },
openProjectPath: (path) => { bridge.project.openPath(path).then(() => loadReal()).catch(() => {}) }, openProjectPath: (path) => { bridge.project.openPath(path).then(() => loadReal()).catch(() => {}) },
refresh: () => { loadReal().catch(() => {}) }, refresh: () => { loadReal().catch(() => {}) },
refreshGit: () => { loadGit().catch(() => {}) },
stage: (p) => after(bridge.git.stage([p])), stage: (p) => after(bridge.git.stage([p])),
unstage: (p) => after(bridge.git.unstage([p])), unstage: (p) => after(bridge.git.unstage([p])),
stageAll: () => { stageAll: () => {
@@ -179,7 +204,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
const cur = dataRef.current const cur = dataRef.current
const n = cur.changes.filter((c) => cur.staged.has(c.path)).length const n = cur.changes.filter((c) => cur.staged.has(c.path)).length
await bridge.git.commit(msg) await bridge.git.commit(msg)
await loadReal() await loadGit()
return n return n
}, },
discard: (p) => after(bridge.git.discard([p])), discard: (p) => after(bridge.git.discard([p])),