diff --git a/src/main/git-service.ts b/src/main/git-service.ts index 0953bc1..5e896b5 100644 --- a/src/main/git-service.ts +++ b/src/main/git-service.ts @@ -33,6 +33,10 @@ export interface GitChange { export interface GitLoad { 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[] } @@ -125,19 +129,39 @@ function parseBranch(header: string): string { 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 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. */ export function parseStatus(raw: string): ParsedStatus { const parts = raw.split('\0') let branch = 'HEAD' + let ahead = 0 + let canPush = false const files: StatusEntry[] = [] for (let i = 0; i < parts.length; i++) { const p = parts[i] 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 working = p[1] 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++ files.push({ index, working, path }) } - return { branch, files } + return { branch, ahead, canPush, files } } async function headText(root: string, path: string): Promise { @@ -239,7 +263,7 @@ async function doLoad(root: string): Promise { } 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. // 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 @@ -271,7 +295,7 @@ async function doLoad(root: string): Promise { return out })).flat() - return { branch, changes } + return { branch, ahead, canPush, changes } } export async function stage(root: string, paths: string[]): Promise { diff --git a/src/main/index.ts b/src/main/index.ts index 777e26c..d264edb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -91,7 +91,8 @@ function gitDir(root: string): string { * 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 * 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 { if (gitWatcher) { gitWatcher.close(); gitWatcher = null } const root = getRoot() @@ -102,6 +103,7 @@ function startGitWatcher(): void { join(dir, 'HEAD'), join(dir, 'index'), join(dir, 'refs', 'heads'), + join(dir, 'refs', 'remotes'), join(dir, 'logs', 'HEAD'), join(dir, 'MERGE_HEAD'), ], diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bc2a14e..b7d3f91 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1037,6 +1037,7 @@ export function App(): React.ReactElement { const crumb = active ? active.split('/') : [] // 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 stagedCount = proj.changes.filter((c) => c.staged).length // No project yet (launched via Spotlight / bare) → show the project launcher. if (proj.ready && !proj.root) { @@ -1116,7 +1117,7 @@ export function App(): React.ReactElement {
{ setActivePanel('git'); syncGitSel(e.target) }} onMouseOver={(e) => syncGitSel(e.target)}> - ⑂ {proj.branch} +{totals.add} −{totals.del} + {stagedCount} staged · {proj.changes.length - stagedCount} unstaged {active && <> {active} diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index 0817980..a44cf66 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -30,6 +30,7 @@ export const Icon: Record React.ReactElement> = { finder: (p) => (), eye: (p) => (), push: (p) => (), + arrowUp: (p) => (), } /** 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 + ahead: number + canPush: boolean changes: Change[] committed: Set commitMsg: string @@ -200,14 +215,6 @@ export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, return ( -
-