Skip to content

Commit 105ddf4

Browse files
feat(web): add git history view (#1150)
* feat(web): show latest commit header in file browser Adds a GitHub-style commit row below the path header on the file browse view, showing the author, message, short SHA, and relative date for the most recent commit that touched the file. A no-op History button is included as a placeholder for the upcoming file-history view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): support co-authors and commit body in commit header Parse Co-authored-by trailers from the commit body and display an AvatarGroup with the combined author list, falling back to an overflow count when more than two. Add a toggle button next to the commit message to reveal the full commit body inline. Upgrades the Avatar ui component to include AvatarGroup, AvatarGroupCount, and AvatarBadge primitives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): add commits pathType and wire History button Extend the browse URL scheme to support /-/commits/<path> as a third pathType alongside blob and tree. Empty paths are permitted so the same route can later serve repo-level history. The History button in the commit header now links to this route via getBrowsePath. The page renders a placeholder for the commits pathType; the actual commit list panel will follow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): add commit history view Render a paginated commit history list when pathType is commits. The view reuses PathHeader for the "History for <repo> / <path>" subheader, groups commits by local date with sticky section headers, and shows Previous/Next links driven by a page query param. Each row renders the commit message, co-authors, short SHA, copy action, view-code-at-commit, and view-repo-at-commit links. When on the last page the list ends with an "End of commit history" marker. Refactor the author parsing, avatar group, body toggle, and body panel out of commitHeader into commitAuthors.ts and commitParts.tsx so the new CommitRow can reuse them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): add GET /api/commits/authors endpoint List unique commit authors scoped to a ref and optional path, sorted by commit count descending. Backed by git shortlog -sne for a native walk that emits one line per author rather than per commit, which keeps the response small even for files with long histories. The route is exposed in the public API under the Git tag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): add author filter to commit history view The history subheader gets an "All users" dropdown that filters commits by author. The top 100 authors (by commit count) are fetched via listCommitAuthors and shown in a Popover + cmdk Command list with a search input, checkmark on the selected author, and a "View commits for all users" footer to clear. A "Filter on author <input>" row appears at the top whenever search is non-empty, acting as an escape valve for authors outside the top 100. The filter survives pagination by threading the author query param through CommitsPagination. Duplicate entries from the same email (git shortlog groups by full author string, so name-variant spellings split into multiple rows) are collapsed client-side with the name variant having the most commits winning as canonical. Document listCommits's --author and --grep as POSIX BRE regex and move the literal-escape responsibility onto the caller; CommitsPanel escapes the selected author via a BRE-safe helper before passing to git. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): add date range filter to commit history view Adds a date range dropdown next to the author filter using shadcn's range Calendar in a Popover. URL state is `?since=YYYY-MM-DD&until=YYYY-MM-DD` so ranges are shareable. Two clicks are required to form a range even when one is already selected — the component tracks an in-progress draft locally and intercepts react-day-picker v9's "adjust" behavior that would otherwise commit a new range in a single click. Single-day ranges require two clicks of the same date. The upper bound is made inclusive by appending end-of-day time before passing to git log. `since` also gets explicit midnight time to sidestep git's approxidate parser, which silently mishandles some bare YYYY-MM-DD forms. Future dates are disabled. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: add CHANGELOG entry for git history view Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): add History tab to bottom panel with latest-commit summary Adds a new History tab to the bottom panel alongside Explore. The tab is toggled via shift+mod+h with the existing collapse-on-same/switch-on-other semantics, and the active tab is indicated with an underline using LowProfileTabsTrigger (now accepts an optional className). The history panel itself is a client-side infinite-scroll list (react-query useInfiniteQuery + IntersectionObserver) that hits a new client-side listCommits wrapper. Each row reuses AuthorsAvatarGroup, the co-author parsing, and the new shared CommitActionLink primitive for the view-code-at-commit / view-repo-at-commit actions. CommitRow was refactored to use the same primitive. The previous CommitHeader rendered above each open file is removed. A compact summary of the latest commit now appears in the bottom panel header (right side) whenever the panel is collapsed, so the file/folder's last commit stays glanceable without consuming a full row above the code. When the panel is expanded the right side hosts the View-full-history and Hide buttons. Shared commit utilities (commitAuthors.ts, commitParts.tsx) moved up from browse/[...path]/components/ to browse/components/ so both the existing commits view and the new bottom-panel components import them without crossing route boundaries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web): resolve path type for the commits view and tighten path handling The commits view URL pattern (/-/commits/<path>) doesn't carry whether the path is a file or a folder, so PathHeader was rendering folder paths with the file-icon last-segment treatment. Add a small getPathType helper backed by `git cat-file -t <ref>:<path>` and use it to pick the correct PathHeader behaviour. CommitRow and HistoryRow now gate the "view code at this commit" action on the same blob check so the file-only link doesn't appear on folder rows. Also fix a related path-handling bug: PathHeader's repo-name link called getBrowsePath with `path: '/'`, which encoded as `%2F` and parsed back as `path: '/'` — that leaked through as `git log -- /`, which git correctly rejects with "outside repository". Strip leading slashes both when generating and when parsing browse URLs so any path that comes through as a literal `/` resolves to the repo root. When clicking "View full history" the bottom panel now collapses on the same click, so the new full-view page renders without the panel still hovering on top of it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feedback --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3ab245a commit 105ddf4

32 files changed

Lines changed: 2247 additions & 125 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Added commit history viewer to code browser. [#1150](https://github.com/sourcebot-dev/sourcebot/pull/1150)
12+
- 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)
13+
1014
## [4.16.15] - 2026-04-23
1115

1216
### Changed

docs/api-reference/sourcebot-public.openapi.json

Lines changed: 151 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,32 @@
965965
"parents"
966966
]
967967
},
968+
"PublicCommitAuthor": {
969+
"type": "object",
970+
"properties": {
971+
"name": {
972+
"type": "string"
973+
},
974+
"email": {
975+
"type": "string"
976+
},
977+
"commitCount": {
978+
"type": "integer",
979+
"minimum": 0
980+
}
981+
},
982+
"required": [
983+
"name",
984+
"email",
985+
"commitCount"
986+
]
987+
},
988+
"PublicListCommitAuthorsResponse": {
989+
"type": "array",
990+
"items": {
991+
"$ref": "#/components/schemas/PublicCommitAuthor"
992+
}
993+
},
968994
"PublicEeUser": {
969995
"type": "object",
970996
"properties": {
@@ -1731,10 +1757,10 @@
17311757
{
17321758
"schema": {
17331759
"type": "string",
1734-
"description": "Filter commits by message content (case-insensitive)."
1760+
"description": "Filter commits by message content (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching."
17351761
},
17361762
"required": false,
1737-
"description": "Filter commits by message content (case-insensitive).",
1763+
"description": "Filter commits by message content (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching.",
17381764
"name": "query",
17391765
"in": "query"
17401766
},
@@ -1761,10 +1787,10 @@
17611787
{
17621788
"schema": {
17631789
"type": "string",
1764-
"description": "Filter commits by author name or email (case-insensitive)."
1790+
"description": "Filter commits by author name or email (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching."
17651791
},
17661792
"required": false,
1767-
"description": "Filter commits by author name or email (case-insensitive).",
1793+
"description": "Filter commits by author name or email (case-insensitive). Interpreted as a POSIX BRE regex — escape metacharacters for literal matching.",
17681794
"name": "author",
17691795
"in": "query"
17701796
},
@@ -1944,6 +1970,127 @@
19441970
}
19451971
}
19461972
},
1973+
"/api/commits/authors": {
1974+
"get": {
1975+
"operationId": "listCommitAuthors",
1976+
"tags": [
1977+
"Git"
1978+
],
1979+
"summary": "List commit authors",
1980+
"description": "Returns a paginated list of unique authors who committed in a repository, sorted by commit count descending. Optionally scoped to a file path.",
1981+
"parameters": [
1982+
{
1983+
"schema": {
1984+
"type": "string",
1985+
"description": "The fully-qualified repository name."
1986+
},
1987+
"required": true,
1988+
"description": "The fully-qualified repository name.",
1989+
"name": "repo",
1990+
"in": "query"
1991+
},
1992+
{
1993+
"schema": {
1994+
"type": "string",
1995+
"description": "The git ref (branch, tag, or commit SHA) to list authors from. Defaults to `HEAD`."
1996+
},
1997+
"required": false,
1998+
"description": "The git ref (branch, tag, or commit SHA) to list authors from. Defaults to `HEAD`.",
1999+
"name": "ref",
2000+
"in": "query"
2001+
},
2002+
{
2003+
"schema": {
2004+
"type": "string",
2005+
"description": "Restrict authors to those who touched this file path."
2006+
},
2007+
"required": false,
2008+
"description": "Restrict authors to those who touched this file path.",
2009+
"name": "path",
2010+
"in": "query"
2011+
},
2012+
{
2013+
"schema": {
2014+
"type": "integer",
2015+
"minimum": 0,
2016+
"exclusiveMinimum": true,
2017+
"default": 1
2018+
},
2019+
"required": false,
2020+
"name": "page",
2021+
"in": "query"
2022+
},
2023+
{
2024+
"schema": {
2025+
"type": "integer",
2026+
"minimum": 0,
2027+
"exclusiveMinimum": true,
2028+
"maximum": 100,
2029+
"default": 50
2030+
},
2031+
"required": false,
2032+
"name": "perPage",
2033+
"in": "query"
2034+
}
2035+
],
2036+
"responses": {
2037+
"200": {
2038+
"description": "Paginated commit author list.",
2039+
"headers": {
2040+
"X-Total-Count": {
2041+
"description": "Total number of unique authors matching the query across all pages.",
2042+
"schema": {
2043+
"type": "integer"
2044+
}
2045+
},
2046+
"Link": {
2047+
"description": "Pagination links formatted per RFC 8288.",
2048+
"schema": {
2049+
"type": "string"
2050+
}
2051+
}
2052+
},
2053+
"content": {
2054+
"application/json": {
2055+
"schema": {
2056+
"$ref": "#/components/schemas/PublicListCommitAuthorsResponse"
2057+
}
2058+
}
2059+
}
2060+
},
2061+
"400": {
2062+
"description": "Invalid query parameters or git ref.",
2063+
"content": {
2064+
"application/json": {
2065+
"schema": {
2066+
"$ref": "#/components/schemas/PublicApiServiceError"
2067+
}
2068+
}
2069+
}
2070+
},
2071+
"404": {
2072+
"description": "Repository not found.",
2073+
"content": {
2074+
"application/json": {
2075+
"schema": {
2076+
"$ref": "#/components/schemas/PublicApiServiceError"
2077+
}
2078+
}
2079+
}
2080+
},
2081+
"500": {
2082+
"description": "Unexpected failure.",
2083+
"content": {
2084+
"application/json": {
2085+
"schema": {
2086+
"$ref": "#/components/schemas/PublicApiServiceError"
2087+
}
2088+
}
2089+
}
2090+
}
2091+
}
2092+
}
2093+
},
19472094
"/api/ee/user": {
19482095
"get": {
19492096
"operationId": "getUser",

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@
175175
"GET /api/commit",
176176
"GET /api/diff",
177177
"GET /api/commits",
178+
"GET /api/commits/authors",
178179
"GET /api/source",
179180
"POST /api/tree"
180181
]

packages/web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
"@radix-ui/react-scroll-area": "^1.1.0",
8282
"@radix-ui/react-select": "^2.1.6",
8383
"@radix-ui/react-separator": "^1.1.0",
84-
"@radix-ui/react-slot": "^1.1.1",
84+
"@radix-ui/react-slot": "^1.2.4",
8585
"@radix-ui/react-switch": "^1.2.4",
8686
"@radix-ui/react-tabs": "^1.1.2",
8787
"@radix-ui/react-toast": "^1.2.2",
@@ -166,6 +166,7 @@
166166
"pretty-bytes": "^6.1.1",
167167
"psl": "^1.15.0",
168168
"react": "19.2.4",
169+
"react-day-picker": "^9.14.0",
169170
"react-device-detect": "^2.2.3",
170171
"react-dom": "19.2.4",
171172
"react-hook-form": "^7.53.0",
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useMemo, useState } from "react";
4+
import { useRouter, usePathname, useSearchParams } from "next/navigation";
5+
import { Check, ChevronDown, Users } from "lucide-react";
6+
import { Button } from "@/components/ui/button";
7+
import {
8+
Command,
9+
CommandInput,
10+
CommandItem,
11+
CommandList,
12+
CommandSeparator,
13+
} from "@/components/ui/command";
14+
import {
15+
Popover,
16+
PopoverContent,
17+
PopoverTrigger,
18+
} from "@/components/ui/popover";
19+
import { UserAvatar } from "@/components/userAvatar";
20+
import { cn } from "@/lib/utils";
21+
import type { CommitAuthor } from "@/features/git";
22+
23+
interface AuthorFilterProps {
24+
authors: CommitAuthor[];
25+
selectedAuthor?: string;
26+
}
27+
28+
export const AuthorFilter = ({ authors, selectedAuthor }: AuthorFilterProps) => {
29+
const router = useRouter();
30+
const pathname = usePathname();
31+
const searchParams = useSearchParams();
32+
33+
const [isOpen, setIsOpen] = useState(false);
34+
const [search, setSearch] = useState('');
35+
36+
// Reset the search input when the popover (re)opens, so stale text from a
37+
// prior session doesn't appear. Intentionally does NOT fire on close —
38+
// mid-close re-renders race with Radix's close animation and cause the
39+
// flash-open-then-close behavior.
40+
useEffect(() => {
41+
if (isOpen) {
42+
setSearch('');
43+
}
44+
}, [isOpen]);
45+
46+
const selectedAuthorDisplay = useMemo(() => {
47+
if (!selectedAuthor) {
48+
return undefined;
49+
}
50+
const key = selectedAuthor.toLowerCase();
51+
return authors.find((a) => a.email.toLowerCase() === key);
52+
}, [authors, selectedAuthor]);
53+
54+
const filteredAuthors = useMemo(() => {
55+
const term = search.trim().toLowerCase();
56+
if (term.length === 0) {
57+
return authors;
58+
}
59+
return authors.filter(
60+
(a) =>
61+
a.name.toLowerCase().includes(term) ||
62+
a.email.toLowerCase().includes(term),
63+
);
64+
}, [authors, search]);
65+
66+
const navigateWithAuthor = useCallback((author: string | null) => {
67+
const params = new URLSearchParams(searchParams);
68+
if (author === null) {
69+
params.delete('author');
70+
} else {
71+
params.set('author', author);
72+
}
73+
params.delete('page');
74+
const query = params.toString();
75+
// Close the popover before kicking off navigation so the close render
76+
// commits cleanly; the search reset is deferred to the next open.
77+
setIsOpen(false);
78+
router.push(`${pathname}${query ? `?${query}` : ''}`);
79+
}, [pathname, router, searchParams]);
80+
81+
const buttonLabel = selectedAuthor
82+
? selectedAuthorDisplay?.name ?? selectedAuthor
83+
: 'All users';
84+
85+
return (
86+
<Popover open={isOpen} onOpenChange={setIsOpen}>
87+
<PopoverTrigger asChild>
88+
<Button
89+
variant="outline"
90+
size="sm"
91+
className="h-8 gap-2 flex-shrink-0"
92+
aria-label="Filter by author"
93+
>
94+
{selectedAuthorDisplay ? (
95+
<UserAvatar
96+
email={selectedAuthorDisplay.email}
97+
className="h-5 w-5 flex-shrink-0"
98+
/>
99+
) : (
100+
<Users className="h-4 w-4 flex-shrink-0" />
101+
)}
102+
<span className="text-sm truncate max-w-[160px]">{buttonLabel}</span>
103+
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
104+
</Button>
105+
</PopoverTrigger>
106+
<PopoverContent className="w-[320px] p-0" align="start">
107+
<Command shouldFilter={false}>
108+
<CommandInput
109+
placeholder="Find a user..."
110+
value={search}
111+
onValueChange={setSearch}
112+
/>
113+
<CommandList>
114+
{search.trim().length > 0 && (
115+
<CommandItem
116+
value={`__filter_${search}`}
117+
onSelect={() => navigateWithAuthor(search.trim())}
118+
className="cursor-pointer"
119+
>
120+
<span>
121+
Filter on author <strong>{search.trim()}</strong>
122+
</span>
123+
</CommandItem>
124+
)}
125+
{filteredAuthors.map((a) => {
126+
const isSelected =
127+
!!selectedAuthor &&
128+
a.email.toLowerCase() === selectedAuthor.toLowerCase();
129+
return (
130+
<CommandItem
131+
key={a.email}
132+
value={a.email}
133+
onSelect={() => navigateWithAuthor(a.email)}
134+
className="cursor-pointer"
135+
>
136+
<Check
137+
className={cn(
138+
"h-4 w-4 flex-shrink-0",
139+
isSelected ? "opacity-100" : "opacity-0",
140+
)}
141+
/>
142+
<UserAvatar
143+
email={a.email}
144+
className="h-5 w-5 flex-shrink-0"
145+
/>
146+
<span className="truncate font-medium">{a.name}</span>
147+
</CommandItem>
148+
);
149+
})}
150+
</CommandList>
151+
{selectedAuthor && (
152+
<>
153+
<CommandSeparator />
154+
<div className="p-1">
155+
<CommandItem
156+
value="__clear"
157+
onSelect={() => navigateWithAuthor(null)}
158+
className="cursor-pointer justify-center text-primary"
159+
>
160+
View commits for all users
161+
</CommandItem>
162+
</div>
163+
</>
164+
)}
165+
</Command>
166+
</PopoverContent>
167+
</Popover>
168+
);
169+
};

0 commit comments

Comments
 (0)