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 f71a085..fa7f9a5 100644 --- a/backend/models.go +++ b/backend/models.go @@ -34,6 +34,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 e49acfb..59f8bb2 100644 --- a/backend/repository_git.go +++ b/backend/repository_git.go @@ -383,6 +383,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 3507d91..3ef0eaf 100644 --- a/backend/repository_test.go +++ b/backend/repository_test.go @@ -124,6 +124,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 c70bfe2..c7e9027 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 2e16590..834fcf6 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; scale: 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 c8e8e1a..d4b8970 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -195,6 +195,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;