Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/app_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down
6 changes: 6 additions & 0 deletions backend/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
24 changes: 24 additions & 0 deletions backend/repository_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
42 changes: 42 additions & 0 deletions backend/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -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<Bootstrap>;
Expand Down Expand Up @@ -26,6 +26,7 @@ interface AbductionBackend {
PullRequestDetail(repositoryPath: string, number: number): Promise<PullRequestDetail>;
SubmitPullRequestReview(repositoryPath: string, number: number, action: string, body: string): Promise<void>;
OpenURL(address: string): Promise<void>;
GitIdentity(repositoryPath: string): Promise<GitIdentity>;
Branches(repositoryPath: string): Promise<string[]>;
SwitchBranch(repositoryPath: string, branch: string): Promise<string>;
PullLatest(repositoryPath: string): Promise<string>;
Expand Down Expand Up @@ -109,6 +110,7 @@ export const api = {
pullRequestDetail: (repositoryPath: string, number: number): Promise<PullRequestDetail> => cached(cacheKey("pull-request-detail", repositoryPath, String(number)), () => backend().PullRequestDetail(repositoryPath, number), 60_000),
submitPullRequestReview: (repositoryPath: string, number: number, action: string, body: string): Promise<void> => backend().SubmitPullRequestReview(repositoryPath, number, action, body),
openURL: (address: string): Promise<void> => backend().OpenURL(address),
gitIdentity: (repositoryPath: string): Promise<GitIdentity> => cached(cacheKey("git-identity", repositoryPath), () => backend().GitIdentity(repositoryPath), 60_000),
branches: (repositoryPath: string): Promise<string[]> => cached(cacheKey("branches", repositoryPath), () => backend().Branches(repositoryPath), 15_000),
switchBranch: (repositoryPath: string, branch: string): Promise<string> => backend().SwitchBranch(repositoryPath, branch).then((resolvedBranch) => { invalidateRepository(repositoryPath); return resolvedBranch; }),
pullLatest: (repositoryPath: string): Promise<string> => backend().PullLatest(repositoryPath).then((output) => { invalidateRepository(repositoryPath); queryCache.delete("bootstrap"); return output; }),
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/cockpit.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 10 additions & 3 deletions frontend/src/features/shell/Shell.tsx
Original file line number Diff line number Diff line change
@@ -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 }[] = [
Expand All @@ -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<string[]>([]);
const [gitIdentity, setGitIdentity] = useState<GitIdentity | null>(null);
const [switchingBranch, setSwitchingBranch] = useState(false);
const [cloneURL, setCloneURL] = useState("");
const [cloning, setCloning] = useState("");
Expand Down Expand Up @@ -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<HTMLSelectElement>(".branch-picker select");
if (branchSelect && selectedRepo && !selectedRepo.path) branchSelect.disabled = switchingBranch;
Expand Down Expand Up @@ -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 <><header className="titlebar"><BrandIdentity/><div className="top-context"><button className="repo-picker" onClick={() => setPickerOpen(true)}><span>Repository</span><strong>{selectedRepo?.fullName ?? "Select a repository"}</strong><kbd>⌘P</kbd></button><label className="branch-picker"><GitBranch size={15}/><select disabled={!selectedRepo?.path || switchingBranch} value={selectedRepo?.branch ?? ""} onChange={(event) => chooseBranch(event.target.value)} aria-label="Branch">{selectedRepo?.branch && !branches.includes(selectedRepo.branch) ? <option value={selectedRepo.branch}>{selectedRepo.branch}</option> : null}{branches.map((branch) => <option key={branch} value={branch}>{branch}</option>)}</select></label></div><div className="titlebar__actions"><button onClick={onCommand} aria-label="Open command palette"><Command size={15}/><kbd>⌘K</kbd></button><button onClick={onShortcuts} aria-label="Show keyboard shortcuts"><Keyboard size={16}/></button><small>{platform} · v{version}</small></div></header>{pickerOpen ? <div className="picker-backdrop" onMouseDown={() => setPickerOpen(false)}><section className="picker" role="dialog" aria-modal="true" aria-label="Open repository" onMouseDown={(event) => event.stopPropagation()}><header><span className="eyebrow">Quick switch</span><h2>Open repository</h2></header><div className="repo-source-tabs">{(["yours", "organisations", "starred"] as const).map((source) => <button key={source} className={repoSource === source ? "repo-source-tab repo-source-tab--active" : "repo-source-tab"} onClick={() => setRepoSource(source)}><span>{source === "yours" ? "On disk" : source === "organisations" ? "Organisations" : "Starred"}</span><b>{repositorySources[source].length}</b></button>)}</div>{repositorySources.error ? <p className="picker-source-error">{repositorySources.error}</p> : null}<input autoFocus value={pickerQuery} onChange={(event) => setPickerQuery(event.target.value)} onKeyDown={handlePickerKey} placeholder="Search owner or repository…"/><div className="picker__list" role="listbox">{loadingSources && repoSource !== "yours" ? <UfoLoader label={`loading ${repoSource}…`}/> : filteredRepos.map((repository, repositoryIndex) => <button key={repository.fullName} role="option" aria-selected={repositoryIndex === activeRepoIndex} className={repositoryIndex === activeRepoIndex ? "picker__option--active" : ""} onMouseEnter={() => setActiveRepoIndex(repositoryIndex)} onClick={() => chooseRepository(repository)} disabled={Boolean(cloning)}><span className="repo__glyph">{repository.path ? "⌁" : "☁"}</span><span><strong>{repository.name}</strong><small>{repository.owner} · {repository.language || "Unknown"}{repository.description ? ` · ${repository.description}` : ""}</small></span><span className={repository.path ? "repo-action repo-action--local" : "repo-action"}>{repository.path ? repository.branch || "On disk" : "View remote"}</span></button>)}{!loadingSources && !filteredRepos.length ? <p className="picker-empty">No repositories in this source.</p> : null}</div><div className="clone-box"><div><span className="eyebrow">Add to workspace</span><strong>Clone from URL</strong></div><div><input value={cloneURL} onChange={(event) => setCloneURL(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") cloneRepository(); }} placeholder="https://github.com/owner/repository.git"/><button className="primary" disabled={!cloneURL.trim() || Boolean(cloning)} onClick={cloneRepository}>{cloning === cloneURL.trim() ? "Cloning…" : "Clone"}</button></div></div><footer><span><kbd>↑↓</kbd> navigate</span><span><kbd>↵</kbd> open</span><span><kbd>esc</kbd> close</span></footer></section></div> : null}</>;
return <><header className="titlebar"><BrandIdentity/><div className="top-context"><button className="repo-picker" onClick={() => setPickerOpen(true)}><span>Repository</span><strong>{selectedRepo?.fullName ?? "Select a repository"}</strong><kbd>⌘P</kbd></button><label className="branch-picker"><GitBranch size={15}/><select disabled={!selectedRepo?.path || switchingBranch} value={selectedRepo?.branch ?? ""} onChange={(event) => chooseBranch(event.target.value)} aria-label="Branch">{selectedRepo?.branch && !branches.includes(selectedRepo.branch) ? <option value={selectedRepo.branch}>{selectedRepo.branch}</option> : null}{branches.map((branch) => <option key={branch} value={branch}>{branch}</option>)}</select></label></div><div className="titlebar__actions"><button onClick={onCommand} aria-label="Open command palette"><Command size={15}/><kbd>⌘K</kbd></button><button onClick={onShortcuts} aria-label="Show keyboard shortcuts"><Keyboard size={16}/></button>{gitIdentity?.name ? <span className="git-identity" title={gitIdentity.email ? `${gitIdentity.name} <${gitIdentity.email}>` : gitIdentity.name}><UserRound size={13}/>{gitIdentity.name}</span> : null}<small>{platform} · v{version}</small></div></header>{pickerOpen ? <div className="picker-backdrop" onMouseDown={() => setPickerOpen(false)}><section className="picker" role="dialog" aria-modal="true" aria-label="Open repository" onMouseDown={(event) => event.stopPropagation()}><header><span className="eyebrow">Quick switch</span><h2>Open repository</h2></header><div className="repo-source-tabs">{(["yours", "organisations", "starred"] as const).map((source) => <button key={source} className={repoSource === source ? "repo-source-tab repo-source-tab--active" : "repo-source-tab"} onClick={() => setRepoSource(source)}><span>{source === "yours" ? "On disk" : source === "organisations" ? "Organisations" : "Starred"}</span><b>{repositorySources[source].length}</b></button>)}</div>{repositorySources.error ? <p className="picker-source-error">{repositorySources.error}</p> : null}<input autoFocus value={pickerQuery} onChange={(event) => setPickerQuery(event.target.value)} onKeyDown={handlePickerKey} placeholder="Search owner or repository…"/><div className="picker__list" role="listbox">{loadingSources && repoSource !== "yours" ? <UfoLoader label={`loading ${repoSource}…`}/> : filteredRepos.map((repository, repositoryIndex) => <button key={repository.fullName} role="option" aria-selected={repositoryIndex === activeRepoIndex} className={repositoryIndex === activeRepoIndex ? "picker__option--active" : ""} onMouseEnter={() => setActiveRepoIndex(repositoryIndex)} onClick={() => chooseRepository(repository)} disabled={Boolean(cloning)}><span className="repo__glyph">{repository.path ? "⌁" : "☁"}</span><span><strong>{repository.name}</strong><small>{repository.owner} · {repository.language || "Unknown"}{repository.description ? ` · ${repository.description}` : ""}</small></span><span className={repository.path ? "repo-action repo-action--local" : "repo-action"}>{repository.path ? repository.branch || "On disk" : "View remote"}</span></button>)}{!loadingSources && !filteredRepos.length ? <p className="picker-empty">No repositories in this source.</p> : null}</div><div className="clone-box"><div><span className="eyebrow">Add to workspace</span><strong>Clone from URL</strong></div><div><input value={cloneURL} onChange={(event) => setCloneURL(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") cloneRepository(); }} placeholder="https://github.com/owner/repository.git"/><button className="primary" disabled={!cloneURL.trim() || Boolean(cloning)} onClick={cloneRepository}>{cloning === cloneURL.trim() ? "Cloning…" : "Clone"}</button></div></div><footer><span><kbd>↑↓</kbd> navigate</span><span><kbd>↵</kbd> open</span><span><kbd>esc</kbd> close</span></footer></section></div> : null}</>;
}

// BrandIdentity renders Abduction's compact craft and wordmark.
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading