Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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