From 513af0e164f7bfc89b90c861d3d332c1314f4a4f Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Wed, 17 Jun 2026 15:36:39 +0200 Subject: [PATCH] toggle hidden files --- src/renderer/src/App.tsx | 17 ++++++++-- src/renderer/src/components.tsx | 21 ++++++++----- src/renderer/src/overlays.tsx | 27 ++++++++++++---- src/renderer/src/project.tsx | 55 ++++++++++++++++++++++++--------- 4 files changed, 88 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index eba7a57..4eff7f5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 {autoResize ? 'On' : 'Off'} + @@ -567,7 +578,7 @@ export function App(): React.ReactElement {
{proj.tree ? ( + onContext={openMenu} activePath={active} changeMap={changeMap} committed={NO_COMMITTED} showHidden={showHidden} /> ) : (
)} @@ -602,7 +613,7 @@ export function App(): React.ReactElement { toast('Passed to agent', passPopup.ref) }} onCancel={() => setPassPopup(null)} />} - {overlay === 'search' && openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} />} + {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)} />} {confirm && setConfirm(null)} />} diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index 2e7af6d..65242d9 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -25,6 +25,7 @@ export const Icon: Record React.ReactElement> = { help: (p) => (), trash: (p) => (), finder: (p) => (), + eye: (p) => (), } 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 @@ -178,6 +179,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP activePath: string | null changeMap: Record committed: Set + 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 {node.name}
)} - {isOpen && (node.children || []).map((c) => ( - - ))} + {isOpen && (node.children || []) + .filter((c) => showHidden || !c.name.startsWith('.')) + .map((c) => ( + + ))} ) } @@ -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 toggleDir: (path: string) => void @@ -225,12 +229,13 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP activePath: string | null changeMap: Record committed: Set + showHidden: boolean }): React.ReactElement { return (
+ onOpen={onOpen} onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
) diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx index 77ffe37..1396e53 100644 --- a/src/renderer/src/overlays.tsx +++ b/src/renderer/src/overlays.tsx @@ -24,7 +24,14 @@ function Highlight({ text, idx }: { text: string; idx: number[] | null }): React return {text.split('').map((ch, i) => set.has(i) ? {ch} : {ch})} } -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 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
Project {totalHits > 0 && {totalHits}} {!hasInFile && ⌘←}
{term.length < 2 &&
Type at least 2 characters
} - {term.length >= 2 && content.length === 0 &&
No content matches
} - {content.map((g) => ( + {term.length >= 2 && visibleContent.length === 0 &&
No content matches
} + {visibleContent.map((g) => (
onOpenAt(g.path, g.hits[0].no)}> diff --git a/src/renderer/src/project.tsx b/src/renderer/src/project.tsx index 2dca158..bbc8556 100644 --- a/src/renderer/src/project.tsx +++ b/src/renderer/src/project.tsx @@ -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['git']['load']>> +function deriveGit(git: GitLoadResult): Pick { + const changes: Change[] = [] + const diffs: Record = {} + const staged = new Set() + 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 = {} - const staged = new Set() - 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 { + 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 { 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): void => { op.then(() => loadReal()).catch(() => {}) } + const after = (op: Promise): 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])),