From 4bb49e8c0991c73d0c55d8c5bc72358b0285038e Mon Sep 17 00:00:00 2001 From: billy slade Date: Thu, 3 Sep 2026 14:08:52 +0100 Subject: [PATCH] feat: show the active Git identity in the titlebar Adds a GitIdentity bridge call that reads the effective user.name and user.email for the selected repository (falling back to the global identity when none is selected) and displays it next to the platform and version text in the titlebar, so it is always clear which account local commits will be authored as before pushing or opening a PR. --- CHANGELOG.md | 4 +++ backend/app_repository.go | 6 ++++ backend/models.go | 6 ++++ backend/repository_git.go | 24 +++++++++++++++ backend/repository_test.go | 42 +++++++++++++++++++++++++++ frontend/src/api.ts | 4 ++- frontend/src/cockpit.css | 4 ++- frontend/src/features/shell/Shell.tsx | 13 +++++++-- frontend/src/types.ts | 1 + frontend/wailsjs/go/backend/App.d.ts | 2 ++ frontend/wailsjs/go/backend/App.js | 4 +++ frontend/wailsjs/go/models.ts | 14 +++++++++ 12 files changed, 119 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de3fa81..97cf306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to Abduction are documented here. This project follows ## [Unreleased] +### Added + +- Showed the active Git author identity (name and email) for the selected repository in the titlebar, so it is clear which account local commits and pushes will use. + ## [0.1.14] - 2026-09-02 ### Added diff --git a/backend/app_repository.go b/backend/app_repository.go index 892f2c7..3767aae 100644 --- a/backend/app_repository.go +++ b/backend/app_repository.go @@ -114,6 +114,12 @@ func (app *App) OpenURL(address string) error { return nil } +// GitIdentity returns the Git author identity active for a repository, or +// the machine-wide identity when no repository is selected. +func (app *App) GitIdentity(repositoryPath string) (GitIdentity, error) { + return app.repository.GitIdentity(repositoryPath) +} + // Branches returns local and remote branches without duplicate names. func (app *App) Branches(repositoryPath string) ([]string, error) { return app.branches.Get(repositoryPath, 15*time.Second, func() ([]string, error) { return app.repository.Branches(repositoryPath) }) diff --git a/backend/models.go b/backend/models.go index b555ee1..5031e3d 100644 --- a/backend/models.go +++ b/backend/models.go @@ -33,6 +33,12 @@ type Repo struct { Description string `json:"description"` } +// GitIdentity describes the Git author identity active for a repository. +type GitIdentity struct { + Name string `json:"name"` + Email string `json:"email"` +} + // RepositorySources groups local and GitHub repositories for the picker. type RepositorySources struct { Yours []Repo `json:"yours"` diff --git a/backend/repository_git.go b/backend/repository_git.go index f6e6f28..17d6ceb 100644 --- a/backend/repository_git.go +++ b/backend/repository_git.go @@ -360,6 +360,30 @@ func RunGit(repositoryPath string, arguments ...string) (string, error) { return strings.TrimSpace(string(outputBytes)), nil } +// GitIdentity returns the effective Git author identity for a repository, or +// the machine-wide identity when repositoryPath is empty. +func (service *RepositoryService) GitIdentity(repositoryPath string) (GitIdentity, error) { + name, nameError := gitConfigValue(repositoryPath, "user.name") + if nameError != nil { + return GitIdentity{}, nameError + } + email, emailError := gitConfigValue(repositoryPath, "user.email") + if emailError != nil { + return GitIdentity{}, emailError + } + return GitIdentity{Name: name, Email: email}, nil +} + +// gitConfigValue reads one configuration key, treating an unset key as an +// empty value rather than an error. +func gitConfigValue(repositoryPath string, key string) (string, error) { + value, valueError := RunGit(repositoryPath, "config", "--get", key) + if valueError != nil && !isGitExitCode(valueError, 1) { + return "", valueError + } + return value, nil +} + // RemoteIdentity extracts an owner, repository name, and browser URL from origin. func RemoteIdentity(repositoryPath string) (string, string, string, error) { remoteOutput, remoteError := RunGit(repositoryPath, "remote", "get-url", "origin") diff --git a/backend/repository_test.go b/backend/repository_test.go index 2a3eb11..afe4ea9 100644 --- a/backend/repository_test.go +++ b/backend/repository_test.go @@ -107,6 +107,48 @@ func TestRepositoryFingerprintChangesWithWorkingTree(testingContext *testing.T) } } +// TestGitIdentityReadsConfiguredAuthor protects the titlebar's git-identity display. +func TestGitIdentityReadsConfiguredAuthor(testingContext *testing.T) { + repositoryPath := testingContext.TempDir() + for _, commandArguments := range [][]string{{"init"}, {"config", "user.email", "identity@example.test"}, {"config", "user.name", "Identity Test"}} { + command := exec.Command("git", commandArguments...) + command.Dir = repositoryPath + if outputBytes, commandError := command.CombinedOutput(); commandError != nil { + testingContext.Fatalf("git setup failed: %s", outputBytes) + } + } + service := NewRepositoryService(Config{}) + identity, identityError := service.GitIdentity(repositoryPath) + if identityError != nil { + testingContext.Fatal(identityError) + } + if identity.Name != "Identity Test" || identity.Email != "identity@example.test" { + testingContext.Fatalf("unexpected git identity: %#v", identity) + } +} + +// TestGitIdentityTreatsUnsetKeysAsEmpty keeps a missing identity from +// surfacing as an error in the titlebar. +func TestGitIdentityTreatsUnsetKeysAsEmpty(testingContext *testing.T) { + home := testingContext.TempDir() + testingContext.Setenv("HOME", home) + testingContext.Setenv("GIT_CONFIG_NOSYSTEM", "1") + repositoryPath := testingContext.TempDir() + command := exec.Command("git", "init") + command.Dir = repositoryPath + if outputBytes, commandError := command.CombinedOutput(); commandError != nil { + testingContext.Fatalf("git setup failed: %s", outputBytes) + } + service := NewRepositoryService(Config{}) + identity, identityError := service.GitIdentity(repositoryPath) + if identityError != nil { + testingContext.Fatal(identityError) + } + if identity.Name != "" || identity.Email != "" { + testingContext.Fatalf("expected empty identity without any configuration, received: %#v", identity) + } +} + // TestListDirectorySortsDirectoriesFirst keeps the file browser predictable. func TestListDirectorySortsDirectoriesFirst(testingContext *testing.T) { repositoryPath := testingContext.TempDir() diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2f098ef..678ace1 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { Bootstrap, Commit, Config, Document, LinterInfo, LintReport, PullRequest, PullRequestDetail, Repo, RepositorySources, RepositoryStats, ScannerInfo, SearchResult, ThemeName, TreeEntry } from "./types"; +import type { Bootstrap, Commit, Config, Document, GitIdentity, LinterInfo, LintReport, PullRequest, PullRequestDetail, Repo, RepositorySources, RepositoryStats, ScannerInfo, SearchResult, ThemeName, TreeEntry } from "./types"; interface AbductionBackend { Bootstrap(): Promise; @@ -26,6 +26,7 @@ interface AbductionBackend { PullRequestDetail(repositoryPath: string, number: number): Promise; SubmitPullRequestReview(repositoryPath: string, number: number, action: string, body: string): Promise; OpenURL(address: string): Promise; + GitIdentity(repositoryPath: string): Promise; Branches(repositoryPath: string): Promise; SwitchBranch(repositoryPath: string, branch: string): Promise; PullLatest(repositoryPath: string): Promise; @@ -109,6 +110,7 @@ export const api = { pullRequestDetail: (repositoryPath: string, number: number): Promise => cached(cacheKey("pull-request-detail", repositoryPath, String(number)), () => backend().PullRequestDetail(repositoryPath, number), 60_000), submitPullRequestReview: (repositoryPath: string, number: number, action: string, body: string): Promise => backend().SubmitPullRequestReview(repositoryPath, number, action, body), openURL: (address: string): Promise => backend().OpenURL(address), + gitIdentity: (repositoryPath: string): Promise => cached(cacheKey("git-identity", repositoryPath), () => backend().GitIdentity(repositoryPath), 60_000), branches: (repositoryPath: string): Promise => cached(cacheKey("branches", repositoryPath), () => backend().Branches(repositoryPath), 15_000), switchBranch: (repositoryPath: string, branch: string): Promise => backend().SwitchBranch(repositoryPath, branch).then((resolvedBranch) => { invalidateRepository(repositoryPath); return resolvedBranch; }), pullLatest: (repositoryPath: string): Promise => backend().PullLatest(repositoryPath).then((output) => { invalidateRepository(repositoryPath); queryCache.delete("bootstrap"); return output; }), diff --git a/frontend/src/cockpit.css b/frontend/src/cockpit.css index 61711aa..fbb88e8 100644 --- a/frontend/src/cockpit.css +++ b/frontend/src/cockpit.css @@ -35,6 +35,8 @@ body { background: var(--depth-canvas); } .titlebar__actions>button { display:flex;align-items:center;gap:7px;height:34px;padding:0 9px;border:1px solid color-mix(in srgb,var(--accent) 24%,var(--border));border-radius:8px;color:var(--muted);background:var(--depth-recessed); } .titlebar__actions>button:hover { color:var(--cyan);border-color:color-mix(in srgb,var(--cyan) 48%,var(--border));transform:translateY(-1px); } .titlebar__actions small { margin-left:5px;color:var(--muted);font:9px var(--mono); } +.git-identity { display:flex;align-items:center;gap:5px;margin-left:2px;color:var(--muted);font:9px var(--mono);white-space:nowrap; } +.git-identity svg { color:var(--cyan); } .rail { position:relative;width:70px;margin:0;padding:14px 10px;border:0;border-right:1px solid color-mix(in srgb,var(--accent) 24%,var(--border));border-radius:0;overflow:hidden;background:linear-gradient(180deg,color-mix(in srgb,var(--surface-2) 96%,var(--page)),color-mix(in srgb,var(--page) 97%,black))!important;box-shadow:12px 0 46px rgba(0,0,0,.32)!important;transition:width 220ms cubic-bezier(.2,.8,.2,1),box-shadow 220ms ease; } .rail:hover,.rail:has(.rail__item:focus-visible) { width:204px;align-items:stretch;box-shadow:28px 0 70px rgba(0,0,0,.58),12px 0 50px color-mix(in srgb,var(--accent) 12%,transparent)!important; } @@ -127,7 +129,7 @@ body { background: var(--depth-canvas); } @keyframes command-fade { from { opacity:0 } } @keyframes command-rise { from { opacity:0;transform:translateY(-12px) scale(.985) } } @keyframes rail-energy { from { top:-18%;height:32% } to { top:68%;height:46% } } -@media(max-width:900px){.titlebar__actions small,.titlebar__actions>button:first-child kbd{display:none}.shortcut-help>div{grid-template-columns:1fr}} +@media(max-width:900px){.titlebar__actions small,.git-identity,.titlebar__actions>button:first-child kbd{display:none}.shortcut-help>div{grid-template-columns:1fr}} @media(max-width:800px){ .shell{grid-template:56px minmax(0,1fr) / 54px minmax(0,1fr)} .titlebar{grid-template-columns:auto minmax(0,1fr) auto;gap:8px;padding:0 10px} diff --git a/frontend/src/features/shell/Shell.tsx b/frontend/src/features/shell/Shell.tsx index 9cf7795..d468474 100644 --- a/frontend/src/features/shell/Shell.tsx +++ b/frontend/src/features/shell/Shell.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; -import { BarChart3, BrainCircuit, Braces, Command, GitBranch, GitPullRequestArrow, Keyboard, Palette, ScrollText, Settings2, ShieldCheck, Wrench, type LucideIcon } from "lucide-react"; +import { BarChart3, BrainCircuit, Braces, Command, GitBranch, GitPullRequestArrow, Keyboard, Palette, ScrollText, Settings2, ShieldCheck, UserRound, Wrench, type LucideIcon } from "lucide-react"; import { api } from "../../api"; -import type { Repo, RepositorySources, ViewName } from "../../types"; +import type { GitIdentity, Repo, RepositorySources, ViewName } from "../../types"; import { fuzzyFilter } from "../../search"; export const destinations: { name: ViewName; label: string; key: string; icon: LucideIcon }[] = [ @@ -23,6 +23,7 @@ export function Titlebar({ version, platform, repos, selectedRepo, onSelect, onC const [pickerOpen, setPickerOpen] = useState(false); const [pickerQuery, setPickerQuery] = useState(""); const [branches, setBranches] = useState([]); + const [gitIdentity, setGitIdentity] = useState(null); const [switchingBranch, setSwitchingBranch] = useState(false); const [cloneURL, setCloneURL] = useState(""); const [cloning, setCloning] = useState(""); @@ -50,6 +51,12 @@ export function Titlebar({ version, platform, repos, selectedRepo, onSelect, onC branchRequest.then(setBranches).catch((reason: unknown) => { setBranches([]); onError(String(reason)); }); }, [selectedRepo]); + useEffect(() => { + let cancelled = false; + api.gitIdentity(selectedRepo?.path ?? "").then((identity) => { if (!cancelled) setGitIdentity(identity); }).catch(() => { if (!cancelled) setGitIdentity(null); }); + return () => { cancelled = true; }; + }, [selectedRepo?.path]); + useEffect(() => { const branchSelect = document.querySelector(".branch-picker select"); if (branchSelect && selectedRepo && !selectedRepo.path) branchSelect.disabled = switchingBranch; @@ -98,7 +105,7 @@ export function Titlebar({ version, platform, repos, selectedRepo, onSelect, onC if (event.key.toLowerCase() === "c" && !pickerQuery && filteredRepos[activeRepoIndex] && !filteredRepos[activeRepoIndex].path) { event.preventDefault(); const repository = filteredRepos[activeRepoIndex]; setCloning(repository.fullName); api.cloneRepository(repository.githubUrl + ".git").then(onCloned).then(() => setPickerOpen(false)).catch((reason: unknown) => onError(String(reason))).finally(() => setCloning("")); } } - return <>
{platform} · v{version}
{pickerOpen ?
setPickerOpen(false)}>
event.stopPropagation()}>
Quick switch

Open repository

{(["yours", "organisations", "starred"] as const).map((source) => )}
{repositorySources.error ?

{repositorySources.error}

: null} setPickerQuery(event.target.value)} onKeyDown={handlePickerKey} placeholder="Search owner or repository…"/>
{loadingSources && repoSource !== "yours" ? : filteredRepos.map((repository, repositoryIndex) => )}{!loadingSources && !filteredRepos.length ?

No repositories in this source.

: null}
Add to workspaceClone from URL
setCloneURL(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") cloneRepository(); }} placeholder="https://github.com/owner/repository.git"/>
↑↓ navigate↵ openesc close
: null}; + return <>
{gitIdentity?.name ? ` : gitIdentity.name}>{gitIdentity.name} : null}{platform} · v{version}
{pickerOpen ?
setPickerOpen(false)}>
event.stopPropagation()}>
Quick switch

Open repository

{(["yours", "organisations", "starred"] as const).map((source) => )}
{repositorySources.error ?

{repositorySources.error}

: null} setPickerQuery(event.target.value)} onKeyDown={handlePickerKey} placeholder="Search owner or repository…"/>
{loadingSources && repoSource !== "yours" ? : filteredRepos.map((repository, repositoryIndex) => )}{!loadingSources && !filteredRepos.length ?

No repositories in this source.

: null}
Add to workspaceClone from URL
setCloneURL(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") cloneRepository(); }} placeholder="https://github.com/owner/repository.git"/>
↑↓ navigate↵ openesc close
: null}; } // BrandIdentity renders Abduction's compact craft and wordmark. diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 98f75c4..2d206d1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3,6 +3,7 @@ export type ViewName = "code" | "history" | "stats" | "reviews" | "security" | " export interface Config { workspace: string; editor: string; theme: ThemeName; glow: number; radius: number; glass: number } export interface Repo { name: string; owner: string; fullName: string; path: string; branch: string; language: string; updated: string; githubUrl: string; description: string } export interface RepositorySources { yours: Repo[]; organisations: Repo[]; starred: Repo[]; error: string } +export interface GitIdentity { name: string; email: string } export interface TreeEntry { name: string; path: string; kind: "file" | "directory"; size: number } export interface SearchResult { path: string; line: number; preview: string; kind: "content" | "file" } export interface Document { path: string; name: string; language: string; html: string; source: string; size: number; lines: number; markdown: boolean; binary: boolean } diff --git a/frontend/wailsjs/go/backend/App.d.ts b/frontend/wailsjs/go/backend/App.d.ts index 26601c1..544dfb2 100755 --- a/frontend/wailsjs/go/backend/App.d.ts +++ b/frontend/wailsjs/go/backend/App.d.ts @@ -16,6 +16,8 @@ export function Commits(arg1:string):Promise>; export function ConfigPath():Promise; +export function GitIdentity(arg1:string):Promise; + export function Linters(arg1:string):Promise>; export function ListDirectory(arg1:string,arg2:string):Promise>; diff --git a/frontend/wailsjs/go/backend/App.js b/frontend/wailsjs/go/backend/App.js index 85039bc..f4578f2 100755 --- a/frontend/wailsjs/go/backend/App.js +++ b/frontend/wailsjs/go/backend/App.js @@ -30,6 +30,10 @@ export function ConfigPath() { return window['go']['backend']['App']['ConfigPath'](); } +export function GitIdentity(arg1) { + return window['go']['backend']['App']['GitIdentity'](arg1); +} + export function Linters(arg1) { return window['go']['backend']['App']['Linters'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 949303e..b386ac9 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -193,6 +193,20 @@ export namespace backend { this.percent = source["percent"]; } } + export class GitIdentity { + name: string; + email: string; + + static createFrom(source: any = {}) { + return new GitIdentity(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.email = source["email"]; + } + } export class Document { path: string; name: string;