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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Added commit history viewer to code browser. [#1150](https://github.com/sourcebot-dev/sourcebot/pull/1150)
- Added `/api/commits/authors` to the public API to allow fetching a list of authors for a given path and revision. [#1150](https://github.com/sourcebot-dev/sourcebot/pull/1150)

Comment thread
brendan-kellam marked this conversation as resolved.
## [4.16.15] - 2026-04-23

### Changed
Expand Down
155 changes: 151 additions & 4 deletions docs/api-reference/sourcebot-public.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,32 @@
"parents"
]
},
"PublicCommitAuthor": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string"
},
"commitCount": {
"type": "integer",
"minimum": 0
}
},
"required": [
"name",
"email",
"commitCount"
]
},
"PublicListCommitAuthorsResponse": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PublicCommitAuthor"
}
},
"PublicEeUser": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -1731,10 +1757,10 @@
{
"schema": {
"type": "string",
"description": "Filter commits by message content (case-insensitive)."
"description": "Filter commits by message content (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching."
},
"required": false,
"description": "Filter commits by message content (case-insensitive).",
"description": "Filter commits by message content (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching.",
"name": "query",
"in": "query"
},
Expand All @@ -1761,10 +1787,10 @@
{
"schema": {
"type": "string",
"description": "Filter commits by author name or email (case-insensitive)."
"description": "Filter commits by author name or email (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching."
},
"required": false,
"description": "Filter commits by author name or email (case-insensitive).",
"description": "Filter commits by author name or email (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching.",
"name": "author",
"in": "query"
},
Expand Down Expand Up @@ -1944,6 +1970,127 @@
}
}
},
"/api/commits/authors": {
"get": {
"operationId": "listCommitAuthors",
"tags": [
"Git"
],
"summary": "List commit authors",
"description": "Returns a paginated list of unique authors who committed in a repository, sorted by commit count descending. Optionally scoped to a file path.",
"parameters": [
{
"schema": {
"type": "string",
"description": "The fully-qualified repository name."
},
"required": true,
"description": "The fully-qualified repository name.",
"name": "repo",
"in": "query"
},
{
"schema": {
"type": "string",
"description": "The git ref (branch, tag, or commit SHA) to list authors from. Defaults to `HEAD`."
},
"required": false,
"description": "The git ref (branch, tag, or commit SHA) to list authors from. Defaults to `HEAD`.",
"name": "ref",
"in": "query"
},
{
"schema": {
"type": "string",
"description": "Restrict authors to those who touched this file path."
},
"required": false,
"description": "Restrict authors to those who touched this file path.",
"name": "path",
"in": "query"
},
{
"schema": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"default": 1
},
"required": false,
"name": "page",
"in": "query"
},
{
"schema": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 100,
"default": 50
},
"required": false,
"name": "perPage",
"in": "query"
}
],
"responses": {
"200": {
"description": "Paginated commit author list.",
"headers": {
"X-Total-Count": {
"description": "Total number of unique authors matching the query across all pages.",
"schema": {
"type": "integer"
}
},
"Link": {
"description": "Pagination links formatted per RFC 8288.",
"schema": {
"type": "string"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicListCommitAuthorsResponse"
}
}
}
},
"400": {
"description": "Invalid query parameters or git ref.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
},
"404": {
"description": "Repository not found.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
},
"500": {
"description": "Unexpected failure.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
}
}
}
},
"/api/ee/user": {
"get": {
"operationId": "getUser",
Expand Down
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
"GET /api/commit",
"GET /api/diff",
"GET /api/commits",
"GET /api/commits/authors",
"GET /api/source",
"POST /api/tree"
]
Expand Down
3 changes: 2 additions & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.2",
Expand Down Expand Up @@ -166,6 +166,7 @@
"pretty-bytes": "^6.1.1",
"psl": "^1.15.0",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-device-detect": "^2.2.3",
"react-dom": "19.2.4",
"react-hook-form": "^7.53.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
'use client';

import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Check, ChevronDown, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { UserAvatar } from "@/components/userAvatar";
import { cn } from "@/lib/utils";
import type { CommitAuthor } from "@/features/git";

interface AuthorFilterProps {
authors: CommitAuthor[];
selectedAuthor?: string;
}

export const AuthorFilter = ({ authors, selectedAuthor }: AuthorFilterProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');

// Reset the search input when the popover (re)opens, so stale text from a
// prior session doesn't appear. Intentionally does NOT fire on close —
// mid-close re-renders race with Radix's close animation and cause the
// flash-open-then-close behavior.
useEffect(() => {
if (isOpen) {
setSearch('');
}
}, [isOpen]);

const selectedAuthorDisplay = useMemo(() => {
if (!selectedAuthor) {
return undefined;
}
const key = selectedAuthor.toLowerCase();
return authors.find((a) => a.email.toLowerCase() === key);
}, [authors, selectedAuthor]);

const filteredAuthors = useMemo(() => {
const term = search.trim().toLowerCase();
if (term.length === 0) {
return authors;
}
return authors.filter(
(a) =>
a.name.toLowerCase().includes(term) ||
a.email.toLowerCase().includes(term),
);
}, [authors, search]);

const navigateWithAuthor = useCallback((author: string | null) => {
const params = new URLSearchParams(searchParams);
if (author === null) {
params.delete('author');
} else {
params.set('author', author);
}
params.delete('page');
const query = params.toString();
// Close the popover before kicking off navigation so the close render
// commits cleanly; the search reset is deferred to the next open.
setIsOpen(false);
router.push(`${pathname}${query ? `?${query}` : ''}`);
}, [pathname, router, searchParams]);

const buttonLabel = selectedAuthor
? selectedAuthorDisplay?.name ?? selectedAuthor
: 'All users';

return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-2 flex-shrink-0"
aria-label="Filter by author"
>
{selectedAuthorDisplay ? (
<UserAvatar
email={selectedAuthorDisplay.email}
className="h-5 w-5 flex-shrink-0"
/>
) : (
<Users className="h-4 w-4 flex-shrink-0" />
)}
<span className="text-sm truncate max-w-[160px]">{buttonLabel}</span>
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Find a user..."
value={search}
onValueChange={setSearch}
/>
<CommandList>
{search.trim().length > 0 && (
<CommandItem
value={`__filter_${search}`}
onSelect={() => navigateWithAuthor(search.trim())}
className="cursor-pointer"
>
<span>
Filter on author <strong>{search.trim()}</strong>
</span>
</CommandItem>
)}
{filteredAuthors.map((a) => {
const isSelected =
!!selectedAuthor &&
a.email.toLowerCase() === selectedAuthor.toLowerCase();
return (
<CommandItem
key={a.email}
value={a.email}
onSelect={() => navigateWithAuthor(a.email)}
className="cursor-pointer"
>
<Check
className={cn(
"h-4 w-4 flex-shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
<UserAvatar
email={a.email}
className="h-5 w-5 flex-shrink-0"
/>
<span className="truncate font-medium">{a.name}</span>
</CommandItem>
);
})}
</CommandList>
{selectedAuthor && (
<>
<CommandSeparator />
<div className="p-1">
<CommandItem
value="__clear"
onSelect={() => navigateWithAuthor(null)}
className="cursor-pointer justify-center text-primary"
>
View commits for all users
</CommandItem>
</div>
</>
)}
</Command>
</PopoverContent>
</Popover>
);
};
Loading
Loading