feat link commit identities to filtered history

Show authors and committers as recognizable links to commit history filtered by exact email identity. Combine author, committer, and conventional commit type filters across links, pagination, and removable toolbar controls. Collect enough matching first-parent commits before paginating so selective filters can reach older matches.

Commit
c3a77faf30a487a9745292bf52afa0c3215a8a54
Author
Claude Sonnet 4 <agent@anthropic.com>
Author date
Committer
Kiro Auto Reasoning <kiro-auto@amazon.example>
Committer date
Changed files
lib/handlers.ml
index d3238051..4bacf56c 100644..100644
@@ -80,28 +80,41 @@
80 80 in
81 81 Views.Repo.summary context branches commits
82 82
83 Removed: let filter_commits filter_type commits =
84 Removed: match filter_type with
85 Removed: | None -> commits
86 Removed: | Some ct ->
87 Removed: List.filter
88 Removed: (fun (commit : Resolvers.Commit.t) ->
83 Added: let commit_matches ?filter_type ?author ?committer
84 Added: (commit : Resolvers.Commit.t) =
85 Added: let type_matches =
86 Added: match filter_type with
87 Added: | None -> true
88 Added: | Some expected ->
89 Added: let summary =
89 90 match commit.message with
90 Removed: | None -> false
91 Removed: | Some message ->
92 Removed: let summary =
93 Removed: match String.split_on_char '\n' message with
94 Removed: | [] -> ""
95 Removed: | s :: _ -> s
96 Removed: in
97 Removed: let commit_type, _ = Views.Repo.parse_conventional summary in
98 Removed: commit_type = Some ct)
99 Removed: commits
91 Added: | None -> ""
92 Added: | Some message -> (
93 Added: match String.split_on_char '\n' message with
94 Added: | [] -> ""
95 Added: | summary :: _ -> summary)
96 Added: in
97 Added: let commit_type, _ = Views.Repo.parse_conventional summary in
98 Added: commit_type = Some expected
99 Added: in
100 Added: let author_matches =
101 Added: match author with
102 Added: | None -> true
103 Added: | Some email -> String.equal commit.author.email email
104 Added: in
105 Added: let committer_matches =
106 Added: match committer with
107 Added: | None -> true
108 Added: | Some email -> String.equal commit.committer.email email
109 Added: in
110 Added: type_matches && author_matches && committer_matches
100 111
101 112 let page_size = 20
102 113
103 114 let commits _config request repository context =
104 115 let filter_type = Dream.query request "type" in
116 Added: let author = Dream.query request "author" in
117 Added: let committer = Dream.query request "committer" in
105 118 let page =
106 119 match Dream.query request "page" with
107 120 | None -> 1
@@ -111,8 +124,10 @@
111 124 let offset = (page - 1) * page_size in
112 125 (* Fetch one beyond orphan threshold to detect whether more exist *)
113 126 let fetch_count = offset + page_size + 11 in
114 Removed: let* all_commits = Resolvers.Commit.recent repository fetch_count in
115 Removed: let all_commits = filter_commits filter_type all_commits in
127 Added: let predicate = commit_matches ?filter_type ?author ?committer in
128 Added: let* all_commits =
129 Added: Resolvers.Commit.recent_matching repository fetch_count predicate
130 Added: in
116 131 let total = List.length all_commits in
117 132 let after_offset =
118 133 if offset >= total then []
@@ -129,8 +144,8 @@
129 144 in
130 145 let has_next = remaining > effective_size in
131 146 let has_prev = page > 1 in
132 Removed: Views.Repo.commits ?filter_type ~page ~has_prev ~has_next context
133 Removed: page_commits
147 Added: Views.Repo.commits ?filter_type ?author ?committer ~page ~has_prev ~has_next
148 Added: context page_commits
134 149
135 150 let commits_branch _config repository context branch =
136 151 let* reference = Resolvers.Reference.of_id repository branch in
lib/resolvers.ml
index 6bfbc9b7..108948a6 100644..100644
@@ -233,6 +233,7 @@
233 233 tree : string;
234 234 parents : string list;
235 235 author : user;
236 Added: committer : user;
236 237 message : string option;
237 238 }
238 239
@@ -243,6 +244,7 @@
243 244 tree = Value.Commit.tree commit |> Hash.to_hex;
244 245 parents = Value.Commit.parents commit |> List.map Hash.to_hex;
245 246 author = Value.Commit.author commit;
247 Added: committer = Value.Commit.committer commit;
246 248 message = Value.Commit.message commit;
247 249 }
248 250
@@ -261,21 +263,34 @@
261 263 let* hash = resolve_head_hash repository in
262 264 of_hash repository hash
263 265
264 Removed: let recent_from repository hash count =
266 Added: let recent_matching_from repository hash count predicate =
265 267 let rec walk commits hash remaining =
266 268 if remaining <= 0 then Lwt_result.return (List.rev commits)
267 269 else
268 270 let* commit = of_id repository hash in
269 Removed: match commit.parents with
270 Removed: | parent_hash :: _ ->
271 Removed: walk (commit :: commits) parent_hash (remaining - 1)
272 Removed: | [] -> Lwt_result.return (List.rev (commit :: commits))
271 Added: let commits, remaining =
272 Added: if predicate commit then (commit :: commits, remaining - 1)
273 Added: else (commits, remaining)
274 Added: in
275 Added: if remaining <= 0 then Lwt_result.return (List.rev commits)
276 Added: else
277 Added: match commit.parents with
278 Added: | parent_hash :: _ -> walk commits parent_hash remaining
279 Added: | [] -> Lwt_result.return (List.rev commits)
273 280 in
274 281 walk [] hash count
275 282
276 Removed: let recent repository count =
283 Added: let recent_from repository hash count =
284 Added: recent_matching_from repository hash count (Fun.const true)
285 Added:
286 Added: let recent_matching repository count predicate =
277 287 let* head_hash = resolve_head_hash repository in
278 Removed: recent_from repository (Store.Hash.to_hex head_hash) count
288 Added: recent_matching_from repository
289 Added: (Store.Hash.to_hex head_hash)
290 Added: count predicate
291 Added:
292 Added: let recent repository count =
293 Added: recent_matching repository count (Fun.const true)
279 294 end
280 295
281 296 module Reference = struct
lib/resolvers.mli
index 49900d30..44e9b389 100644..100644
@@ -50,12 +50,17 @@
50 50 tree : string;
51 51 parents : string list;
52 52 author : user;
53 Added: committer : user;
53 54 message : string option;
54 55 }
55 56
56 57 val of_id : repository -> string -> (t, error) Lwt_result.t
57 58 val head : repository -> (t, error) Lwt_result.t
58 59 val recent_from : repository -> string -> int -> (t list, error) Lwt_result.t
60 Added:
61 Added: val recent_matching :
62 Added: repository -> int -> (t -> bool) -> (t list, error) Lwt_result.t
63 Added:
59 64 val recent : repository -> int -> (t list, error) Lwt_result.t
60 65 end
61 66
lib/static/styles.css
index 619390f3..d984adb8 100644..100644
@@ -258,6 +258,27 @@
258 258 white-space: nowrap;
259 259 }
260 260
261 Added: .commit-identity {
262 Added: color: skyblue;
263 Added: text-decoration: underline;
264 Added: text-underline-offset: 0.15em;
265 Added: border-radius: 0.2rem;
266 Added: }
267 Added:
268 Added: .commit-identity:visited {
269 Added: color: skyblue;
270 Added: }
271 Added:
272 Added: .commit-identity:hover {
273 Added: color: black;
274 Added: background-color: white;
275 Added: }
276 Added:
277 Added: .commit-meta .commit-identity,
278 Added: .commit-meta time {
279 Added: font-family: monospace;
280 Added: }
281 Added:
261 282 .commit-pill {
262 283 display: inline-flex;
263 284 align-items: center;
@@ -377,6 +398,17 @@
377 398 align-items: center;
378 399 gap: 0.35em;
379 400 flex-shrink: 0;
401 Added: }
402 Added:
403 Added: .toolbar-filter-value {
404 Added: display: inline-flex;
405 Added: align-items: center;
406 Added: padding: 0.4em 0.9em;
407 Added: border-radius: 9999px;
408 Added: font-family: monospace;
409 Added: font-size: 0.8em;
410 Added: color: white;
411 Added: background-color: #2d2d2d;
380 412 }
381 413
382 414 .toolbar-dismiss {
lib/views/repo.ml
index 0acb8cf2..2919bf23 100644..100644
@@ -324,7 +324,67 @@
324 324 Printf.sprintf "%04d-%02d-%02d %02d:%02d" (tm.tm_year + 1900) (tm.tm_mon + 1)
325 325 tm.tm_mday tm.tm_hour tm.tm_min
326 326
327 Removed: let li_of_commit ?(hide_pill = false) repo (commit : Resolvers.Commit.t) =
327 Added: let detailed_timestamp (date, timezone) =
328 Added: let offset_seconds, suffix =
329 Added: match timezone with
330 Added: | None -> (0, "Z")
331 Added: | Some (offset : Git.User.tz_offset) ->
332 Added: let direction = match offset.sign with `Plus -> 1 | `Minus -> -1 in
333 Added: let seconds = direction * ((offset.hours * 60) + offset.minutes) * 60 in
334 Added: let sign = match offset.sign with `Plus -> "+" | `Minus -> "-" in
335 Added: (seconds, Printf.sprintf "%s%02d:%02d" sign offset.hours offset.minutes)
336 Added: in
337 Added: let adjusted = Int64.add date (Int64.of_int offset_seconds) in
338 Added: let tm = adjusted |> Int64.to_float |> Unix.gmtime in
339 Added: let date =
340 Added: Printf.sprintf "%04d-%02d-%02d" (tm.tm_year + 1900) (tm.tm_mon + 1)
341 Added: tm.tm_mday
342 Added: in
343 Added: let clock = Printf.sprintf "%02d:%02d:%02d" tm.tm_hour tm.tm_min tm.tm_sec in
344 Added: ( Printf.sprintf "%sT%s%s" date clock suffix,
345 Added: Printf.sprintf "%s %s %s" date clock suffix )
346 Added:
347 Added: let time_node date =
348 Added: let machine_time, display_time = detailed_timestamp date in
349 Added: HTML.(time [ datetime "%s" machine_time ] [ txt "%s" display_time ])
350 Added:
351 Added: let commits_url ?filter_type ?author ?committer ?(page = 1) repo =
352 Added: let params =
353 Added: (if page > 1 then [ ("page", string_of_int page) ] else [])
354 Added: @ (match filter_type with Some value -> [ ("type", value) ] | None -> [])
355 Added: @ (match author with Some value -> [ ("author", value) ] | None -> [])
356 Added: @ match committer with Some value -> [ ("committer", value) ] | None -> []
357 Added: in
358 Added: let base = Printf.sprintf "/%s/commits/" repo in
359 Added: match params with
360 Added: | [] -> base
361 Added: | _ -> base ^ "?" ^ Dream.to_form_urlencoded params
362 Added:
363 Added: let identity_link ?filter_type ?author ?committer ?(show_email = false) ~role
364 Added: repo (user : Resolvers.Commit.user) =
365 Added: let url, role_label =
366 Added: match role with
367 Added: | `Author ->
368 Added: (commits_url ?filter_type ~author:user.email ?committer repo, "author")
369 Added: | `Committer ->
370 Added: ( commits_url ?filter_type ?author ~committer:user.email repo,
371 Added: "committer" )
372 Added: in
373 Added: let link_text =
374 Added: if show_email then Printf.sprintf "%s <%s>" user.name user.email
375 Added: else user.name
376 Added: in
377 Added: HTML.(
378 Added: a
379 Added: [
380 Added: href "%s" url;
381 Added: class_ "commit-identity";
382 Added: Aria.label "Filter commits by %s %s" role_label user.name;
383 Added: ]
384 Added: [ txt "%s" link_text ])
385 Added:
386 Added: let li_of_commit ?filter_type ?author ?committer ?(hide_pill = false) repo
387 Added: (commit : Resolvers.Commit.t) =
328 388 let message = parse_commit_message commit.message in
329 389 let commit_type, commit_title = parse_conventional message.summary in
330 390 let commit_route = Routes.Commit (repo, commit.hash) in
@@ -340,7 +400,14 @@
340 400 HTML.(
341 401 span
342 402 [ class_ "commit-pill commit-pill-%s" ct ]
343 Removed: [ a [ href "/%s/commits/?type=%s" repo ct ] [ txt "%s" ct ] ])
403 Added: [
404 Added: a
405 Added: [
406 Added: href "%s"
407 Added: (commits_url ~filter_type:ct ?author ?committer repo);
408 Added: ]
409 Added: [ txt "%s" ct ];
410 Added: ])
344 411 in
345 412 let title_span =
346 413 HTML.(
@@ -355,7 +422,13 @@
355 422 [ txt "%s" (Time_fmt.relative_time commit.author.date) ])
356 423 in
357 424 let author_span =
358 Removed: HTML.(span [ class_ "commit-author" ] [ txt "%s" commit.author.name ])
425 Added: HTML.(
426 Added: span
427 Added: [ class_ "commit-author" ]
428 Added: [
429 Added: identity_link ?filter_type ?author ?committer ~role:`Author repo
430 Added: commit.author;
431 Added: ])
359 432 in
360 433 HTML.(
361 434 li []
@@ -438,52 +511,63 @@
438 511 ul [] (List.map (li_of_commit context.repo) commits);
439 512 ]
440 513
441 Removed: let toolbar ?(filter : (string * string) option) content =
442 Removed: let filter_el =
443 Removed: match filter with
444 Removed: | None -> HTML.null []
445 Removed: | Some (commit_type, dismiss_href) ->
446 Removed: HTML.(
447 Removed: span
448 Removed: [ class_ "toolbar-filter" ]
514 Added: let toolbar ?(filters = []) content =
515 Added: let filter_el (filter_name, display, dismiss_href, value_class) =
516 Added: HTML.(
517 Added: span
518 Added: [ class_ "toolbar-filter" ]
519 Added: [
520 Added: span [ class_ "%s" value_class ] [ txt "%s" display ];
521 Added: a
449 522 [
450 Removed: span
451 Removed: [ class_ "commit-pill commit-pill-%s" commit_type ]
452 Removed: [ txt "%s" commit_type ];
453 Removed: a
454 Removed: [
455 Removed: href "%s" dismiss_href;
456 Removed: class_ "toolbar-dismiss";
457 Removed: Aria.label "Remove filter";
458 Removed: ]
459 Removed: [ txt "\xc3\x97" ];
460 Removed: ])
523 Added: href "%s" dismiss_href;
524 Added: class_ "toolbar-dismiss";
525 Added: Aria.label "Remove %s filter" filter_name;
526 Added: ]
527 Added: [ txt "\xc3\x97" ];
528 Added: ])
461 529 in
462 Removed: let children = content @ [ filter_el ] in
530 Added: let children = content @ List.map filter_el filters in
463 531 HTML.(
464 532 div [ class_ "toolbar"; role `toolbar; Aria.label "View toolbar" ] children)
465 533
466 Removed: let commits ?filter_type ~page ~has_prev ~has_next context commits =
467 Removed: let filter =
468 Removed: match filter_type with
469 Removed: | None -> None
470 Removed: | Some ct -> Some (ct, Printf.sprintf "/%s/commits/" context.repo)
471 Removed: in
534 Added: let commits ?filter_type ?author ?committer ~page ~has_prev ~has_next context
535 Added: commits =
472 536 let hide_pill = Option.is_some filter_type in
473 Removed: let tb =
474 Removed: match filter with None -> HTML.null [] | Some _ -> toolbar ?filter []
475 Removed: in
476 Removed: let page_url p =
477 Removed: let base = Printf.sprintf "/%s/commits/" context.repo in
478 Removed: let params =
479 Removed: (if p > 1 then [ Printf.sprintf "page=%d" p ] else [])
480 Removed: @
481 Removed: match filter_type with
482 Removed: | Some ct -> [ Printf.sprintf "type=%s" ct ]
537 Added: let filters =
538 Added: (match filter_type with
483 539 | None -> []
484 Removed: in
485 Removed: match params with [] -> base | _ -> base ^ "?" ^ String.concat "&" params
540 Added: | Some commit_type ->
541 Added: [
542 Added: ( "commit type",
543 Added: commit_type,
544 Added: commits_url ?author ?committer context.repo,
545 Added: "commit-pill commit-pill-" ^ commit_type );
546 Added: ])
547 Added: @ (match author with
548 Added: | None -> []
549 Added: | Some email ->
550 Added: [
551 Added: ( "author",
552 Added: "Author: " ^ email,
553 Added: commits_url ?filter_type ?committer context.repo,
554 Added: "toolbar-filter-value" );
555 Added: ])
556 Added: @
557 Added: match committer with
558 Added: | None -> []
559 Added: | Some email ->
560 Added: [
561 Added: ( "committer",
562 Added: "Committer: " ^ email,
563 Added: commits_url ?filter_type ?author context.repo,
564 Added: "toolbar-filter-value" );
565 Added: ]
486 566 in
567 Added: let tb = match filters with [] -> HTML.null [] | _ -> toolbar ~filters [] in
568 Added: let page_url page =
569 Added: commits_url ?filter_type ?author ?committer ~page context.repo
570 Added: in
487 571 let show_pagination = has_prev || has_next in
488 572 let pagination =
489 573 if not show_pagination then HTML.null []
@@ -530,7 +614,11 @@
530 614 [
531 615 tb;
532 616 pagination;
533 Removed: ul [] (List.map (li_of_commit ~hide_pill context.repo) commits);
617 Added: ul []
618 Added: (List.map
619 Added: (li_of_commit ~hide_pill ?filter_type ?author ?committer
620 Added: context.repo)
621 Added: commits);
534 622 pagination;
535 623 ]
536 624
@@ -716,7 +804,9 @@
716 804 span
717 805 [ class_ "commit-pill commit-pill-%s" ct ]
718 806 [
719 Removed: a [ href "/%s/commits/?type=%s" context.repo ct ] [ txt "%s" ct ];
807 Added: a
808 Added: [ href "%s" (commits_url ~filter_type:ct context.repo) ]
809 Added: [ txt "%s" ct ];
720 810 ])
721 811 in
722 812 let content =
@@ -731,7 +821,21 @@
731 821 dt [] [ txt "Commit" ];
732 822 dd [] [ txt "%s" commit.hash ];
733 823 dt [] [ txt "Author" ];
734 Removed: dd [] [ txt "%s <%s>" commit.author.name commit.author.email ];
824 Added: dd []
825 Added: [
826 Added: identity_link ~show_email:true ~role:`Author context.repo
827 Added: commit.author;
828 Added: ];
829 Added: dt [] [ txt "Author date" ];
830 Added: dd [] [ time_node commit.author.date ];
831 Added: dt [] [ txt "Committer" ];
832 Added: dd []
833 Added: [
834 Added: identity_link ~show_email:true ~role:`Committer context.repo
835 Added: commit.committer;
836 Added: ];
837 Added: dt [] [ txt "Committer date" ];
838 Added: dd [] [ time_node commit.committer.date ];
735 839 ];
736 840 ]
737 841 @ diff_content)