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])),