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

## [Unreleased]

### Added
- Added a GitHub-style git history view to the code browser: a latest-commit header above each file, a paginated commits list (file- or repo-scoped) at `/browse/<repo>@<rev>/-/commits[/<path>]`, and author and date-range filters. Also exposes `GET /api/commits/authors` in the public API. [#1150](https://github.com/sourcebot-dev/sourcebot/pull/1150)

Comment on lines 8 to +12
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Changelog Unreleased entry violates “single sentence” requirement.

The bullet under ## [Unreleased] -> ### Added is not a single sentence: it has a period after “author and date-range filters.” before “Also exposes GET /api/commits/authors…”. This conflicts with the guideline that each Unreleased entry must be a single sentence.

✅ Proposed fix
 ### Added
-- Added a GitHub-style git history view to the code browser: a latest-commit header above each file, a paginated commits list (file- or repo-scoped) at `/browse/<repo>@<rev>/-/commits[/<path>]`, and author and date-range filters. Also exposes `GET /api/commits/authors` in the public API. [`#1150`](https://github.com/sourcebot-dev/sourcebot/pull/1150)
+- Added a GitHub-style git history view to the code browser: a latest-commit header above each file, a paginated commits list (file- or repo-scoped) at `/browse/<repo>@<rev>/-/commits[/<path>]`, and author and date-range filters, and also exposes `GET /api/commits/authors` in the public API. [`#1150`](https://github.com/sourcebot-dev/sourcebot/pull/1150)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 8 - 12, The Unreleased entry under "##
[Unreleased]" -> "### Added" is not a single sentence; edit the single bullet
(the line starting "Added a GitHub-style git history view to the code browser:
...") so it becomes one sentence (for example by joining the clause "Also
exposes GET /api/commits/authors in the public API." to the previous sentence
with appropriate punctuation or conjunction), ensuring the entire bullet is a
single grammatically correct sentence.

## [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