This was an update of which I cannot remember what it did but it was surely something that I needed so let's commit it. YOLO
CI / check (push) Waiting to run

This commit is contained in:
2026-09-22 16:38:00 +02:00
parent dbb7e40c6c
commit 5112d3de94
13 changed files with 172 additions and 55 deletions
+29 -5
View File
@@ -33,6 +33,10 @@ export interface GitChange {
export interface GitLoad { export interface GitLoad {
branch: string branch: string
/** Commits that the branch holds and the remote does not. 0 when unknown. */
ahead: number
/** True when a push would send something. Drives the lit push button. */
canPush: boolean
changes: GitChange[] changes: GitChange[]
} }
@@ -125,19 +129,39 @@ function parseBranch(header: string): string {
return head.split(' ')[0].trim() || 'HEAD' return head.split(' ')[0].trim() || 'HEAD'
} }
/**
* Read the push state from a `## ...` porcelain branch header.
*
* A branch without an upstream reports no ahead count, but its first push still
* sends every commit, so it counts as pushable with an unknown count.
*/
export function parseAhead(header: string): { ahead: number; canPush: boolean } {
if (header.startsWith('No commits yet on ') || header.startsWith('HEAD ')) return { ahead: 0, canPush: false }
const m = header.match(/\[[^\]]*\bahead (\d+)/)
if (m) return { ahead: Number(m[1]), canPush: true }
return { ahead: 0, canPush: !header.includes('...') }
}
interface StatusEntry { index: string; working: string; path: string } interface StatusEntry { index: string; working: string; path: string }
interface ParsedStatus { branch: string; files: StatusEntry[] } interface ParsedStatus { branch: string; ahead: number; canPush: boolean; files: StatusEntry[] }
/** Parse `git status --porcelain -b -z` output. NUL-separated, never quoted. */ /** Parse `git status --porcelain -b -z` output. NUL-separated, never quoted. */
export function parseStatus(raw: string): ParsedStatus { export function parseStatus(raw: string): ParsedStatus {
const parts = raw.split('\0') const parts = raw.split('\0')
let branch = 'HEAD' let branch = 'HEAD'
let ahead = 0
let canPush = false
const files: StatusEntry[] = [] const files: StatusEntry[] = []
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const p = parts[i] const p = parts[i]
if (!p) continue if (!p) continue
if (p.startsWith('## ')) { branch = parseBranch(p.slice(3)); continue } if (p.startsWith('## ')) {
const header = p.slice(3)
branch = parseBranch(header)
;({ ahead, canPush } = parseAhead(header))
continue
}
const index = p[0] const index = p[0]
const working = p[1] const working = p[1]
const path = p.slice(3) // skip "XY " const path = p.slice(3) // skip "XY "
@@ -146,7 +170,7 @@ export function parseStatus(raw: string): ParsedStatus {
if (index === 'R' || index === 'C' || working === 'R' || working === 'C') i++ if (index === 'R' || index === 'C' || working === 'R' || working === 'C') i++
files.push({ index, working, path }) files.push({ index, working, path })
} }
return { branch, files } return { branch, ahead, canPush, files }
} }
async function headText(root: string, path: string): Promise<string> { async function headText(root: string, path: string): Promise<string> {
@@ -239,7 +263,7 @@ async function doLoad(root: string): Promise<GitLoad | null> {
} }
if (raw == null) return null if (raw == null) return null
const { branch, files } = parseStatus(raw) const { branch, ahead, canPush, files } = parseStatus(raw)
// Each changed file needs its HEAD blob (a `git show` spawn) + its disk text. // Each changed file needs its HEAD blob (a `git show` spawn) + its disk text.
// Done serially this is O(files) subprocess spawns in a row — staging one file // Done serially this is O(files) subprocess spawns in a row — staging one file
// re-reads ALL of them, which is the dominant cost of a reload. Run them with // re-reads ALL of them, which is the dominant cost of a reload. Run them with
@@ -271,7 +295,7 @@ async function doLoad(root: string): Promise<GitLoad | null> {
return out return out
})).flat() })).flat()
return { branch, changes } return { branch, ahead, canPush, changes }
} }
export async function stage(root: string, paths: string[]): Promise<void> { export async function stage(root: string, paths: string[]): Promise<void> {
+3 -1
View File
@@ -91,7 +91,8 @@ function gitDir(root: string): string {
* done by external tools (Sublime Merge, the CLI, …) would never refresh the * done by external tools (Sublime Merge, the CLI, …) would never refresh the
* git column. Watch the few git files that mark those events so the renderer * git column. Watch the few git files that mark those events so the renderer
* re-reads status: HEAD (branch switch), index (staging), refs/heads + logs * re-reads status: HEAD (branch switch), index (staging), refs/heads + logs
* (commits), MERGE_HEAD (in-progress merge). */ * (commits), refs/remotes (a push, which clears the ahead count), MERGE_HEAD
* (in-progress merge). */
function startGitWatcher(): void { function startGitWatcher(): void {
if (gitWatcher) { gitWatcher.close(); gitWatcher = null } if (gitWatcher) { gitWatcher.close(); gitWatcher = null }
const root = getRoot() const root = getRoot()
@@ -102,6 +103,7 @@ function startGitWatcher(): void {
join(dir, 'HEAD'), join(dir, 'HEAD'),
join(dir, 'index'), join(dir, 'index'),
join(dir, 'refs', 'heads'), join(dir, 'refs', 'heads'),
join(dir, 'refs', 'remotes'),
join(dir, 'logs', 'HEAD'), join(dir, 'logs', 'HEAD'),
join(dir, 'MERGE_HEAD'), join(dir, 'MERGE_HEAD'),
], ],
+3 -1
View File
@@ -1037,6 +1037,7 @@ export function App(): React.ReactElement {
const crumb = active ? active.split('/') : [] const crumb = active ? active.split('/') : []
// Whole-project line counts, shown in the status bar next to the branch. // Whole-project line counts, shown in the status bar next to the branch.
const totals = proj.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 }) const totals = proj.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
const stagedCount = proj.changes.filter((c) => c.staged).length
// No project yet (launched via Spotlight / bare) → show the project launcher. // No project yet (launched via Spotlight / bare) → show the project launcher.
if (proj.ready && !proj.root) { if (proj.ready && !proj.root) {
@@ -1116,7 +1117,7 @@ export function App(): React.ReactElement {
<div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 1 ' + gitW + 'px' }} <div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 1 ' + gitW + 'px' }}
onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }} onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }}
onMouseOver={(e) => syncGitSel(e.target)}> onMouseOver={(e) => syncGitSel(e.target)}>
<GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED} <GitPanel branch={proj.branch} ahead={proj.ahead} canPush={proj.canPush} changes={proj.changes} committed={NO_COMMITTED}
commitMsg={commitMsg} setCommitMsg={setCommitMsg} commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push} onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push}
onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null} onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null}
@@ -1165,6 +1166,7 @@ export function App(): React.ReactElement {
<span className="sb-branch"> {proj.branch}</span> <span className="sb-branch"> {proj.branch}</span>
<span className="sb-add">+{totals.add}</span> <span className="sb-add">+{totals.add}</span>
<span className="sb-del">{totals.del}</span> <span className="sb-del">{totals.del}</span>
<span className="sb-staged">{stagedCount} staged · {proj.changes.length - stagedCount} unstaged</span>
<span className="sb-spacer" /> <span className="sb-spacer" />
{active && <> {active && <>
<span className="sb-path">{active}</span> <span className="sb-path">{active}</span>
+25 -12
View File
@@ -30,6 +30,7 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
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>), 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>),
push: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 13V4M8 4 4.5 7.5M8 4l3.5 3.5M3.5 2.5h9" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>), push: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 13V4M8 4 4.5 7.5M8 4l3.5 3.5M3.5 2.5h9" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
arrowUp: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 13V3.5M8 3.5 4.5 7M8 3.5 11.5 7" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
} }
/** Hover tooltip. One surface, no arrow, no shadow — the current state is named /** Hover tooltip. One surface, no arrow, no shadow — the current state is named
@@ -174,8 +175,22 @@ function GitList({ list, ...row }: {
) )
} }
export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId }: { /** Push button tooltip. The count is absent on a branch with no upstream. */
function pushTitle(canPush: boolean, ahead: number): string {
if (!canPush) return 'Nothing to push (⌘P)'
if (ahead > 0) return `Push ${ahead} commit${ahead > 1 ? 's' : ''} to remote (⌘P)`
return 'Push the branch to remote (⌘P)'
}
/** Dictation tools capitalize and add line breaks; a commit subject is one lowercase line. */
export function normalizeCommitMsg(value: string): string {
return value.toLowerCase().replace(/[ \t]*[\r\n]+[ \t]*/g, ' ')
}
export function GitPanel({ branch, ahead, canPush, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId }: {
branch: string branch: string
ahead: number
canPush: boolean
changes: Change[] changes: Change[]
committed: Set<string> committed: Set<string>
commitMsg: string commitMsg: string
@@ -200,14 +215,6 @@ export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg,
return ( return (
<Fragment> <Fragment>
<div className="commit-box">
<textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false}
placeholder="Shift+Enter to commit"
onChange={(e) => setCommitMsg(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} />
<button className="push-btn" title="Push to remote (⌘P)" onClick={onPush}>{Icon.push()}</button>
</div>
<div className="git-body"> <div className="git-body">
{visible.length === 0 ? ( {visible.length === 0 ? (
<div className="git-empty"> No changes. The working tree is clean.</div> <div className="git-empty"> No changes. The working tree is clean.</div>
@@ -242,9 +249,15 @@ export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg,
)} )}
</div> </div>
{/* The line counts live in the status bar; this footer only counts files. */} <div className="commit-box">
<div className="git-foot"> <textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false}
<span>{stagedList.length} staged · {changesList.length} unstaged</span> placeholder="Shift+Enter to commit"
onChange={(e) => setCommitMsg(normalizeCommitMsg(e.target.value))}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} />
<div className="commit-actions">
<button className="commit-btn" disabled={stagedList.length === 0} title="Commit the staged files (⌘↵)" onClick={onCommit}>Commit {Icon.arrowUp()}</button>
<button className={'push-btn' + (canPush ? ' lit' : '')} title={pushTitle(canPush, ahead)} onClick={onPush}>{Icon.push()}</button>
</div>
</div> </div>
</Fragment> </Fragment>
) )
+33 -9
View File
@@ -71,6 +71,8 @@ interface Band extends ChangeMark { top: number; height: number }
/** The line box of `.ce-pre` and its top padding — the unwrapped arithmetic. */ /** The line box of `.ce-pre` and its top padding — the unwrapped arithmetic. */
const LINE_H = 20 const LINE_H = 20
const PAD_TOP = 10 const PAD_TOP = 10
/** Pointer travel that still counts as a click and not as a text selection. */
const DRAG_SLOP = 4
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a /* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
* scroll-synced line-number gutter. Live highlighting while typing. * scroll-synced line-number gutter. Live highlighting while typing.
@@ -92,14 +94,17 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, on
flash: FlashLine | null flash: FlashLine | null
/** Changed lines of this file, from the HEAD-vs-disk diff. */ /** Changed lines of this file, from the HEAD-vs-disk diff. */
marks: ChangeMark[] marks: ChangeMark[]
/** A click landed on a marked line: open the full-screen Diff there. */ /** A click landed on a marked line: open the full-screen Diff there. Null on
onPick: (line: number) => void * a new file, where every line is marked and the diff has no left side. */
onPick: ((line: number) => void) | null
onChange: (text: string) => void onChange: (text: string) => void
onContext: OnContext onContext: OnContext
onSymbol: OnSymbol onSymbol: OnSymbol
}): React.ReactElement { }): React.ReactElement {
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const innerRef = useRef<HTMLDivElement>(null) const innerRef = useRef<HTMLDivElement>(null)
const taRef = useRef<HTMLTextAreaElement>(null)
const downRef = useRef<{ x: number; y: number; hadSel: boolean } | null>(null)
const gutterRef = useRef<HTMLDivElement>(null) const gutterRef = useRef<HTMLDivElement>(null)
const preRef = useRef<HTMLPreElement>(null) const preRef = useRef<HTMLPreElement>(null)
const tabSize = useProject().config.editor.tabSize const tabSize = useProject().config.editor.tabSize
@@ -159,11 +164,29 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, on
})) }))
}, [marks, wrap, text, paneW]) }, [marks, wrap, text, paneW])
/* A click on a marked line opens the full-screen Diff at that line. The click /* A click on a marked line opens the full-screen Diff at that line. The line
* lands on the textarea rather than on a row, so the line comes from the * comes from the bands, because the click lands on the textarea.
* bands — the same geometry that drew the tint. */ * A drag and a click that clears a selection both end with a click event. Open
* the Diff only for a plain click that moved almost nothing, held no selection
* and cleared none, or the overlay steals every attempt to select code. The
* browser collapses a selection first, thus the flag comes from mouse-down. */
function hasSelection(): boolean {
const ta = taRef.current
if (ta && ta.selectionEnd !== ta.selectionStart) return true
const sel = window.getSelection()
return !!sel && !sel.isCollapsed
}
function isPlainClick(e: React.MouseEvent): boolean {
if (e.detail > 1 || e.shiftKey || e.altKey) return false
const down = downRef.current
if (down && Math.abs(e.clientX - down.x) + Math.abs(e.clientY - down.y) > DRAG_SLOP) return false
if (down?.hadSel) return false
return !hasSelection()
}
function pickAt(e: React.MouseEvent): void { function pickAt(e: React.MouseEvent): void {
if (!bands.length) return if (!onPick || !bands.length || !isPlainClick(e)) return
const box = innerRef.current?.getBoundingClientRect() const box = innerRef.current?.getBoundingClientRect()
if (!box) return if (!box) return
const y = e.clientY - box.top const y = e.clientY - box.top
@@ -272,7 +295,8 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, on
)} )}
{/* No marks, no pick: an unchanged file pays nothing for the reveal. */} {/* No marks, no pick: an unchanged file pays nothing for the reveal. */}
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll} <div className="ce-scroll" ref={scrollRef} onScroll={onScroll}
onClick={(e) => { if (e.metaKey || e.ctrlKey) handleTokenClick(e); else if (marks.length) pickAt(e) }}> onMouseDown={(e) => { downRef.current = { x: e.clientX, y: e.clientY, hadSel: hasSelection() } }}
onClick={(e) => { if (e.metaKey || e.ctrlKey) handleTokenClick(e); else if (onPick && marks.length) pickAt(e) }}>
<div className="ce-inner" ref={innerRef}> <div className="ce-inner" ref={innerRef}>
{bands.map((b) => ( {bands.map((b) => (
<Fragment key={b.line}> <Fragment key={b.line}>
@@ -281,7 +305,7 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, on
</Fragment> </Fragment>
))} ))}
{flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />} {flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />}
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off" <textarea className="ce-ta" ref={taRef} value={text} spellCheck={false} autoComplete="off"
wrap={wrap ? 'soft' : 'off'} style={{ tabSize }} wrap={wrap ? 'soft' : 'off'} style={{ tabSize }}
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }} onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@@ -668,7 +692,7 @@ export function Editor({ active, mode, side, setMode, onContext, onOpenDiff, cur
<NoOriginal /> <NoOriginal />
) : editable ? ( ) : editable ? (
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} marks={marks} <CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} marks={marks}
onPick={onOpenDiff} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} /> onPick={fileStatus === 'A' ? null : onOpenDiff} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
) : ( ) : (
built && <PaneView cacheKey={tab.path + ':' + paneMode} path={tab.path} lines={built.lines} built && <PaneView cacheKey={tab.path + ':' + paneMode} path={tab.path} lines={built.lines}
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection} lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
+1 -1
View File
@@ -38,7 +38,7 @@ interface HelderBridge {
write: (text: string) => Promise<void> write: (text: string) => Promise<void>
} }
git: { git: {
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null> load: () => Promise<{ branch: string; ahead: number; canPush: boolean; changes: GitChangeRaw[] } | null>
stage: (paths: string[]) => Promise<void> stage: (paths: string[]) => Promise<void>
unstage: (paths: string[]) => Promise<void> unstage: (paths: string[]) => Promise<void>
commit: (message: string) => Promise<void> commit: (message: string) => Promise<void>
+14 -5
View File
@@ -15,6 +15,10 @@ export interface ProjectData {
name: string name: string
root: string | null root: string | null
branch: string branch: string
/** Commits that the remote does not have yet. 0 when the count is unknown. */
ahead: number
/** True when a push would send something. Lights the push button. */
canPush: boolean
tree: FileNode | null tree: FileNode | null
files: Record<string, string> files: Record<string, string>
/** Git rows. One file can appear twice: staged and unstaged (see Change.id). */ /** Git rows. One file can appear twice: staged and unstaged (see Change.id). */
@@ -98,7 +102,7 @@ function mockData(): ProjectData {
const changes = mockChanges(staged) const changes = mockChanges(staged)
return { return {
// non-null root so browser-preview shows the workbench, not the launcher // non-null root so browser-preview shows the workbench, not the launcher
name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch, name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch, ahead: 2, canPush: true,
tree: MOCK.tree, files: MOCK.files, changes, diffs: MOCK.diffs, tree: MOCK.tree, files: MOCK.files, changes, diffs: MOCK.diffs,
rowDiffs: mockRowDiffs(changes), rowDiffs: mockRowDiffs(changes),
staged, config: DEFAULT_CONFIG, isRepo: true, ready: true, recents: [], staged, config: DEFAULT_CONFIG, isRepo: true, ready: true, recents: [],
@@ -106,7 +110,7 @@ function mockData(): ProjectData {
} }
const emptyData: ProjectData = { const emptyData: ProjectData = {
name: 'Loading…', root: null, branch: '—', tree: null, files: {}, name: 'Loading…', root: null, branch: '—', ahead: 0, canPush: false, tree: null, files: {},
changes: [], diffs: {}, rowDiffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false, recents: [], changes: [], diffs: {}, rowDiffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false, recents: [],
} }
@@ -118,7 +122,7 @@ const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({
/** Map a git.load() result into the git-derived slice of ProjectData. Shared by /** 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. */ * 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']>> type GitLoadResult = Awaited<ReturnType<NonNullable<typeof window.helder>['git']['load']>>
function deriveGit(git: GitLoadResult): Pick<ProjectData, 'branch' | 'changes' | 'diffs' | 'rowDiffs' | 'staged' | 'isRepo'> { function deriveGit(git: GitLoadResult): Pick<ProjectData, 'branch' | 'ahead' | 'canPush' | 'changes' | 'diffs' | 'rowDiffs' | 'staged' | 'isRepo'> {
const changes: Change[] = [] const changes: Change[] = []
const rowDiffs: Record<string, Diff> = {} const rowDiffs: Record<string, Diff> = {}
const diffs: Record<string, Diff> = {} const diffs: Record<string, Diff> = {}
@@ -143,7 +147,12 @@ function deriveGit(git: GitLoadResult): Pick<ProjectData, 'branch' | 'changes' |
} }
} }
for (const [path, w] of whole) diffs[path] = makeDiff(w.status, w.head, w.disk) for (const [path, w] of whole) diffs[path] = makeDiff(w.status, w.head, w.disk)
return { branch: git ? git.branch : '—', changes, diffs, rowDiffs, staged, isRepo: !!git } return {
branch: git ? git.branch : '—',
ahead: git ? git.ahead ?? 0 : 0,
canPush: git ? !!git.canPush : false,
changes, diffs, rowDiffs, staged, isRepo: !!git,
}
} }
export function useProject(): ProjectData { export function useProject(): ProjectData {
@@ -197,7 +206,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
setData((d) => ({ setData((d) => ({
name: cur.name, root: cur.root, name: cur.name, root: cur.root,
tree, files: d.root === cur.root ? d.files : {}, config, ready: true, recents: d.recents, tree, files: d.root === cur.root ? d.files : {}, config, ready: true, recents: d.recents,
...(gitFresh ? deriveGit(git) : { branch: d.branch, changes: d.changes, diffs: d.diffs, rowDiffs: d.rowDiffs, staged: d.staged, isRepo: d.isRepo }), ...(gitFresh ? deriveGit(git) : { branch: d.branch, ahead: d.ahead, canPush: d.canPush, changes: d.changes, diffs: d.diffs, rowDiffs: d.rowDiffs, staged: d.staged, isRepo: d.isRepo }),
})) }))
// The whole-repo content index is only a fallback (real viewing/search go // The whole-repo content index is only a fallback (real viewing/search go
// through fs.read + ripgrep), and reading every file serially costs seconds. // through fs.read + ripgrep), and reading every file serially costs seconds.
+17 -12
View File
@@ -219,18 +219,24 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
.phead .ico-btn:hover { background:var(--hover); color:var(--fg-1); } .phead .ico-btn:hover { background:var(--hover); color:var(--fg-1); }
/* ============ git panel ============ */ /* ============ git panel ============ */
/* One line at rest, so this bar matches the Explorer head and the view strip. /* The commit box sits at the foot of the column: the message over the action
Focus grows the box downward inside the git column only. */ row, both edge to edge. The message keeps one height, so focus never moves
.commit-box { height:var(--bar-h); flex:0 0 auto; padding:0 12px; border-bottom:1px solid var(--border); display:flex; gap:8px; align-items:center; } the file list. The file counts live in the status bar. */
.commit-box:focus-within { height:auto; padding:8px 12px; align-items:flex-start; } .commit-box { flex:0 0 auto; display:flex; flex-direction:column; align-items:stretch; }
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:var(--r-sm); color:var(--fg-1); font-family:var(--ui); font-size:12px; line-height:1.5; padding:3px 9px; outline:none; height:26px; } .commit-input { width:100%; height:72px; box-sizing:border-box; resize:none; background:var(--bg-0); border:0; border-top:1px solid var(--border); border-radius:0; color:var(--fg-1); font-family:var(--ui); font-size:12px; line-height:1.5; padding:9px 12px; outline:none; }
/* Focus is the same everywhere: 2px amber border plus the 22% ring. */ /* Focus is the same everywhere: 2px amber border plus the 22% ring. The
.commit-input:focus { border-color:var(--accent); border-width:2px; padding:6px 8px; box-shadow:0 0 0 2px var(--accent-ring); height:72px; } padding drops 2px against the border, so the text does not move. */
.commit-input:focus { border:2px solid var(--accent); padding:8px 10px; box-shadow:inset 0 0 0 2px var(--accent-ring); }
.commit-input::placeholder { color:var(--fg-3); } .commit-input::placeholder { color:var(--fg-3); }
.push-btn { flex:0 0 26px; display:flex; align-items:center; justify-content:center; width:26px; height:26px; background:transparent; border:1px solid var(--border-2); border-radius:var(--r-sm); color:var(--fg-2); cursor:pointer; } .commit-actions { display:flex; align-items:stretch; gap:0; height:32px; }
.push-btn { flex:0 0 36px; display:flex; align-items:center; justify-content:center; background:var(--bg-2); border:0; border-top:1px solid var(--border); border-left:1px solid var(--border); border-radius:0; color:var(--fg-2); cursor:pointer; }
.push-btn:hover { background:var(--hover); color:var(--accent); } .push-btn:hover { background:var(--hover); color:var(--accent); }
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#201608; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; } /* Lit: the branch is ahead of its remote. Amber marks the pending action,
.commit-btn:hover:not(:disabled) { background:#f6b35f; } without a second filled block next to the amber Commit button. */
.push-btn.lit { color:var(--accent); background:var(--accent-soft); }
.push-btn.lit:hover { background:var(--accent-soft); color:var(--accent-lite); }
.commit-btn { flex:1; min-width:0; display:flex; align-items:center; justify-content:center; gap:6px; background:var(--accent); color:#201608; border:0; border-radius:0; font-family:var(--ui); font-size:12px; font-weight:600; padding:0 11px; cursor:pointer; }
.commit-btn:hover:not(:disabled) { background:var(--accent-lite); }
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; } .commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
.git-body { overflow:auto; flex:1; padding:0 0 8px; } .git-body { overflow:auto; flex:1; padding:0 0 8px; }
/* Group header: the standard 10px mono label, count right in amber. */ /* Group header: the standard 10px mono label, count right in amber. */
@@ -293,8 +299,6 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
.git-act.push { margin-left:auto; } /* right-align when the dir column is hidden */ .git-act.push { margin-left:auto; } /* right-align when the dir column is hidden */
.git-row:hover .git-act { visibility:visible; } .git-row:hover .git-act { visibility:visible; }
.git-act:hover { color:var(--accent); } .git-act:hover { color:var(--accent); }
.git-foot { border-top:1px solid var(--border); padding:12px; display:flex; align-items:center; gap:8px;
font-family:var(--mono); font-size:11px; line-height:1.5; color:var(--fg-3); }
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-3); } .branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-3); }
.branch-chip svg { display:none; } .branch-chip svg { display:none; }
.branch-chip b { font-weight:400; color:var(--fg-3); } .branch-chip b { font-weight:400; color:var(--fg-3); }
@@ -744,6 +748,7 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
.statusbar .sb-branch { color:var(--accent); } .statusbar .sb-branch { color:var(--accent); }
.statusbar .sb-add { color:var(--add); } .statusbar .sb-add { color:var(--add); }
.statusbar .sb-del { color:var(--del); } .statusbar .sb-del { color:var(--del); }
.statusbar .sb-staged { color:var(--fg-3); }
.statusbar .sb-spacer { flex:1; } .statusbar .sb-spacer { flex:1; }
.statusbar .sb-path { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:52%; } .statusbar .sb-path { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:52%; }
.statusbar .sb-lang { color:var(--fg-2); } .statusbar .sb-lang { color:var(--fg-2); }
+23 -3
View File
@@ -10,6 +10,7 @@ vi.mock('../src/renderer/src/terminals', () => {
import { App } from '../src/renderer/src/App' import { App } from '../src/renderer/src/App'
import { ProjectProvider } from '../src/renderer/src/project' import { ProjectProvider } from '../src/renderer/src/project'
import { normalizeCommitMsg } from '../src/renderer/src/components'
beforeAll(() => { beforeAll(() => {
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
@@ -78,7 +79,7 @@ describe('Pass on to Agent', () => {
}) })
describe('Stage + commit', () => { describe('Stage + commit', () => {
it('stages a file and commits via Shift+Enter (no commit button)', async () => { it('stages a file and commits via Shift+Enter', async () => {
const c = renderApp() const c = renderApp()
const row = await waitFor(() => { const row = await waitFor(() => {
const r = find(c, '.git-row', 'UserController.php') const r = find(c, '.git-row', 'UserController.php')
@@ -86,13 +87,26 @@ describe('Stage + commit', () => {
return r return r
}) })
fireEvent.click(row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!) fireEvent.click(row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!)
// the commit button is intentionally gone — committing is keyboard-only
expect(c.querySelector('.commit-btn')).toBeNull()
const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')! const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')!
fireEvent.change(msg, { target: { value: 'wire up balance' } }) fireEvent.change(msg, { target: { value: 'wire up balance' } })
fireEvent.keyDown(msg, { key: 'Enter', shiftKey: true }) fireEvent.keyDown(msg, { key: 'Enter', shiftKey: true })
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy()) await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
}) })
it('commits from the button once a file is staged', async () => {
const c = renderApp()
const row = await waitFor(() => {
const r = find(c, '.git-row', 'UserController.php')
if (!r) throw new Error('git not ready')
return r
})
fireEvent.click(row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!)
await waitFor(() => expect(c.querySelector<HTMLButtonElement>('.commit-btn')!.disabled).toBe(false))
const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')!
fireEvent.change(msg, { target: { value: 'wire up balance' } })
fireEvent.click(c.querySelector<HTMLButtonElement>('.commit-btn')!)
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
})
}) })
describe('Explorer background menu', () => { describe('Explorer background menu', () => {
@@ -126,3 +140,9 @@ describe('Explorer background menu', () => {
expect(find(c, '.ctx-item', 'Copy file name')).toBeTruthy() expect(find(c, '.ctx-item', 'Copy file name')).toBeTruthy()
}) })
}) })
describe('Commit message normalization', () => {
it('lowercases the text and folds the line breaks into one line', () => {
expect(normalizeCommitMsg('Fix Login\nAnd Logout\r\n Flow')).toBe('fix login and logout flow')
})
})
+19 -1
View File
@@ -5,7 +5,25 @@ import { afterEach, describe, expect, it } from 'vitest'
import { simpleGit } from 'simple-git' import { simpleGit } from 'simple-git'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import { classify, discard, load, stage } from '../src/main/git-service' import { classify, discard, load, parseAhead, stage } from '../src/main/git-service'
describe('parseAhead', () => {
it('reads the ahead count from the porcelain header', () => {
expect(parseAhead('main...origin/main [ahead 3]')).toEqual({ ahead: 3, canPush: true })
expect(parseAhead('main...origin/main [ahead 1, behind 2]')).toEqual({ ahead: 1, canPush: true })
})
it('reports nothing to push on a branch in sync', () => {
expect(parseAhead('main...origin/main')).toEqual({ ahead: 0, canPush: false })
expect(parseAhead('main...origin/main [behind 2]')).toEqual({ ahead: 0, canPush: false })
})
it('treats a branch without an upstream as pushable, count unknown', () => {
expect(parseAhead('feature/x')).toEqual({ ahead: 0, canPush: true })
})
it('reports nothing to push on an empty repo or a detached HEAD', () => {
expect(parseAhead('No commits yet on main')).toEqual({ ahead: 0, canPush: false })
expect(parseAhead('HEAD (no branch)')).toEqual({ ahead: 0, canPush: false })
})
})
describe('classify', () => { describe('classify', () => {
it('reads the index code as staged, working code as unstaged', () => { it('reads the index code as staged, working code as unstaged', () => {
+2 -2
View File
@@ -41,7 +41,7 @@ function stubBridge(): void {
write: async (t: string) => { stored = t; writes.push(t) }, write: async (t: string) => { stored = t; writes.push(t) },
}, },
git: { git: {
load: async () => ({ branch: 'main', changes: [] }), load: async () => ({ branch: 'main', ahead: 0, canPush: false, changes: [] }),
stage: async () => {}, unstage: async () => {}, commit: async () => {}, stage: async () => {}, unstage: async () => {}, commit: async () => {},
push: async () => ({ ok: true, message: '' }), discard: async () => {}, push: async () => ({ ok: true, message: '' }), discard: async () => {},
}, },
@@ -68,7 +68,7 @@ afterEach(() => {
async function boot(): Promise<HTMLElement> { async function boot(): Promise<HTMLElement> {
const c = render(<ProjectProvider><App /></ProjectProvider>).container const c = render(<ProjectProvider><App /></ProjectProvider>).container
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') }) await waitFor(() => { if (!c.querySelector('.commit-box')) throw new Error('not ready') })
return c return c
} }
+1 -1
View File
@@ -40,7 +40,7 @@ export function installBridge(rows: StubRow[], files: Record<string, string>): v
shell: { reveal: noop }, shell: { reveal: noop },
notes: { read: async () => '', write: async () => {} }, notes: { read: async () => '', write: async () => {} },
git: { git: {
load: async () => ({ branch: 'main', changes: rows }), load: async () => ({ branch: 'main', ahead: 0, canPush: false, changes: rows }),
stage: async () => {}, unstage: async () => {}, commit: async () => {}, stage: async () => {}, unstage: async () => {}, commit: async () => {},
push: async () => ({ ok: true, message: '' }), discard: async () => {}, push: async () => ({ ok: true, message: '' }), discard: async () => {},
}, },
+2 -2
View File
@@ -38,7 +38,7 @@ function stubBridge(): void {
shell: { reveal: noop }, shell: { reveal: noop },
notes: { read: async () => '', write: async () => {} }, notes: { read: async () => '', write: async () => {} },
git: { git: {
load: async () => ({ branch: 'main', changes: [] }), load: async () => ({ branch: 'main', ahead: 0, canPush: false, changes: [] }),
stage: async () => {}, unstage: async () => {}, commit: async () => {}, stage: async () => {}, unstage: async () => {}, commit: async () => {},
push: async () => ({ ok: true, message: '' }), discard: async () => {}, push: async () => ({ ok: true, message: '' }), discard: async () => {},
}, },
@@ -69,7 +69,7 @@ afterEach(() => {
async function boot(): Promise<HTMLElement> { async function boot(): Promise<HTMLElement> {
const c = render(<ProjectProvider><App /></ProjectProvider>).container const c = render(<ProjectProvider><App /></ProjectProvider>).container
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') }) await waitFor(() => { if (!c.querySelector('.commit-box')) throw new Error('not ready') })
return c return c
} }