Skip to content

Commit 2001211

Browse files
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>
1 parent d481ac8 commit 2001211

9 files changed

Lines changed: 296 additions & 30 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1757,10 +1757,10 @@
17571757
{
17581758
"schema": {
17591759
"type": "string",
1760-
"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."
17611761
},
17621762
"required": false,
1763-
"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.",
17641764
"name": "query",
17651765
"in": "query"
17661766
},
@@ -1787,10 +1787,10 @@
17871787
{
17881788
"schema": {
17891789
"type": "string",
1790-
"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."
17911791
},
17921792
"required": false,
1793-
"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.",
17941794
"name": "author",
17951795
"in": "query"
17961796
},
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+
};

packages/web/src/app/(app)/browse/[...path]/components/commitAuthors.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Commit } from "@/features/git";
1+
import type { Commit, CommitAuthor } from "@/features/git";
22

33
export type Author = { name: string; email: string };
44

@@ -28,6 +28,46 @@ export const getCommitAuthors = (commit: Commit): Author[] => {
2828
});
2929
};
3030

31+
/**
32+
* Collapses rows with the same lowercased email into a single entry.
33+
* git shortlog groups by full author string (name + email), so one person
34+
* who committed under multiple name spellings appears as multiple rows.
35+
* The canonical name picked is the one with the most commits; counts are
36+
* summed. Result is resorted by commitCount descending.
37+
*/
38+
export const dedupeCommitAuthorsByEmail = (authors: CommitAuthor[]): CommitAuthor[] => {
39+
type Accum = CommitAuthor & { bestNameCount: number };
40+
const byEmail = new Map<string, Accum>();
41+
for (const a of authors) {
42+
const key = a.email.toLowerCase();
43+
const existing = byEmail.get(key);
44+
if (!existing) {
45+
byEmail.set(key, { ...a, bestNameCount: a.commitCount });
46+
} else {
47+
existing.commitCount += a.commitCount;
48+
if (a.commitCount > existing.bestNameCount) {
49+
existing.name = a.name;
50+
existing.email = a.email;
51+
existing.bestNameCount = a.commitCount;
52+
}
53+
}
54+
}
55+
return Array.from(byEmail.values())
56+
.map(({ name, email, commitCount }) => ({ name, email, commitCount }))
57+
.sort((a, b) => b.commitCount - a.commitCount);
58+
};
59+
60+
/**
61+
* Escapes a literal string so it matches verbatim under git's default regex
62+
* (POSIX BRE with GNU extensions, used by `git log --author` and `--grep`).
63+
*
64+
* BRE treats `. [ ] ^ $ *` as metacharacters; `+ ? | ( ) { }` are literal
65+
* in BRE (their `\` forms are the GNU extensions with meta meaning), so we
66+
* do NOT escape them here.
67+
*/
68+
export const escapeGitBreLiteral = (s: string): string =>
69+
s.replace(/[.[\]^$*\\]/g, '\\$&');
70+
3171
export const formatAuthorsText = (authors: Author[]): string => {
3272
if (authors.length === 1) {
3373
return authors[0].name;

packages/web/src/app/(app)/browse/[...path]/components/commitsPagination.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,23 @@ interface CommitsPaginationProps {
66
page: number;
77
perPage: number;
88
totalCount: number;
9+
extraParams?: Record<string, string | undefined>;
910
}
1011

11-
export const CommitsPagination = ({ page, perPage, totalCount }: CommitsPaginationProps) => {
12+
const buildHref = (page: number, extraParams?: Record<string, string | undefined>) => {
13+
const params = new URLSearchParams();
14+
params.set('page', String(page));
15+
if (extraParams) {
16+
for (const [key, value] of Object.entries(extraParams)) {
17+
if (value !== undefined && value !== '') {
18+
params.set(key, value);
19+
}
20+
}
21+
}
22+
return `?${params.toString()}`;
23+
};
24+
25+
export const CommitsPagination = ({ page, perPage, totalCount, extraParams }: CommitsPaginationProps) => {
1226
const hasPrev = page > 1;
1327
const hasNext = page * perPage < totalCount;
1428

@@ -22,7 +36,7 @@ export const CommitsPagination = ({ page, perPage, totalCount }: CommitsPaginati
2236
return (
2337
<div className="flex flex-row items-center justify-center gap-6 py-6">
2438
{hasPrev ? (
25-
<Link href={`?page=${page - 1}`} className={linkClass}>
39+
<Link href={buildHref(page - 1, extraParams)} className={linkClass}>
2640
<ChevronLeft className="h-4 w-4" />
2741
Previous
2842
</Link>
@@ -33,7 +47,7 @@ export const CommitsPagination = ({ page, perPage, totalCount }: CommitsPaginati
3347
</span>
3448
)}
3549
{hasNext ? (
36-
<Link href={`?page=${page + 1}`} className={linkClass}>
50+
<Link href={buildHref(page + 1, extraParams)} className={linkClass}>
3751
Next
3852
<ChevronRight className="h-4 w-4" />
3953
</Link>

0 commit comments

Comments
 (0)