This commit is contained in:
@@ -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)} />}
|
||||||
|
|||||||
@@ -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,11 +195,13 @@ 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 || [])
|
||||||
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
|
.filter((c) => showHidden || !c.name.startsWith('.'))
|
||||||
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
|
.map((c) => (
|
||||||
onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} />
|
<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>
|
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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])),
|
||||||
|
|||||||
Reference in New Issue
Block a user