@@ -6,7 +6,9 @@ import ( |
| 6 | 6 | "errors" |
| 7 | 7 | "html/template" |
| 8 | 8 | "net/http" |
| 9 | + "net/url" |
| 9 | 10 | "regexp" |
| 11 | + "sort" |
| 10 | 12 | "strconv" |
| 11 | 13 | "strings" |
| 12 | 14 | "time" |
@@ -84,8 +86,10 @@ func (h *Handlers) commitsList(w http.ResponseWriter, r *http.Request) { |
| 84 | 86 | } |
| 85 | 87 | pathFilter := strings.TrimSpace(q.Get("path")) |
| 86 | 88 | authorFilter := strings.TrimSpace(q.Get("author")) |
| 87 | | - since := parseDateParam(q.Get("since")) |
| 88 | | - until := parseDateParam(q.Get("until")) |
| 89 | + sinceRaw := strings.TrimSpace(q.Get("since")) |
| 90 | + untilRaw := strings.TrimSpace(q.Get("until")) |
| 91 | + since := parseDateParam(sinceRaw) |
| 92 | + until := parseUntilDateParam(untilRaw) |
| 89 | 93 | |
| 90 | 94 | commits, err := git.Log(r.Context(), gitDir, git.LogOptions{ |
| 91 | 95 | Ref: ref, |
@@ -106,26 +110,61 @@ func (h *Handlers) commitsList(w http.ResponseWriter, r *http.Request) { |
| 106 | 110 | resolver := identity.New(h.d.Pool) |
| 107 | 111 | rows := make([]commitRow, 0, len(commits)) |
| 108 | 112 | for _, c := range commits { |
| 109 | | - rows = append(rows, commitRow{Commit: c, Author: resolver.Resolve(r.Context(), c.AuthorEmail)}) |
| 113 | + rows = append(rows, newCommitRow(c, resolver.Resolve(r.Context(), c.AuthorEmail))) |
| 114 | + } |
| 115 | + |
| 116 | + filterValues := commitFilterValues(pathFilter, authorFilter, sinceRaw, untilRaw) |
| 117 | + olderHref := "" |
| 118 | + if len(commits) == perPage { |
| 119 | + v := cloneURLValues(filterValues) |
| 120 | + v.Set("page", strconv.Itoa(page+1)) |
| 121 | + olderHref = commitsHref(owner.Username, row.Name, ref, v) |
| 122 | + } |
| 123 | + newerHref := "" |
| 124 | + if page > 1 { |
| 125 | + v := cloneURLValues(filterValues) |
| 126 | + v.Set("page", strconv.Itoa(page-1)) |
| 127 | + newerHref = commitsHref(owner.Username, row.Name, ref, v) |
| 128 | + } |
| 129 | + pathClearHref := "" |
| 130 | + if pathFilter != "" { |
| 131 | + v := cloneURLValues(filterValues) |
| 132 | + v.Del("path") |
| 133 | + pathClearHref = commitsHref(owner.Username, row.Name, ref, v) |
| 134 | + } |
| 135 | + allAuthorsValues := cloneURLValues(filterValues) |
| 136 | + allAuthorsValues.Del("author") |
| 137 | + selectedDate := until |
| 138 | + if selectedDate.IsZero() { |
| 139 | + selectedDate = since |
| 110 | 140 | } |
| 111 | 141 | |
| 112 | 142 | h.d.Render.RenderPage(w, r, "repo/commits", map[string]any{ |
| 113 | | - "Title": "Commits · " + row.Name, |
| 114 | | - "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 115 | | - "Owner": owner.Username, |
| 116 | | - "Repo": row, |
| 117 | | - "Ref": ref, |
| 118 | | - "PathFilter": pathFilter, |
| 119 | | - "Author": authorFilter, |
| 120 | | - "Since": q.Get("since"), |
| 121 | | - "Until": q.Get("until"), |
| 122 | | - "Rows": rows, |
| 123 | | - "Page": page, |
| 124 | | - "NextPage": page + 1, |
| 125 | | - "PrevPage": page - 1, |
| 126 | | - "HasMore": len(commits) == perPage, |
| 127 | | - "Branches": refs.Branches, |
| 128 | | - "Tags": refs.Tags, |
| 143 | + "Title": "Commits · " + row.Name, |
| 144 | + "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 145 | + "Owner": owner.Username, |
| 146 | + "Repo": row, |
| 147 | + "RepoActions": h.repoActions(r, row.ID), |
| 148 | + "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount), |
| 149 | + "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())), |
| 150 | + "ActiveSubnav": "code", |
| 151 | + "Ref": ref, |
| 152 | + "PathFilter": pathFilter, |
| 153 | + "PathClearHref": pathClearHref, |
| 154 | + "Author": authorFilter, |
| 155 | + "AuthorLabel": commitAuthorSummary(rows, authorFilter), |
| 156 | + "AllAuthorsHref": commitsHref(owner.Username, row.Name, ref, allAuthorsValues), |
| 157 | + "AuthorFilters": commitAuthorFilters(owner.Username, row.Name, ref, filterValues, rows, authorFilter), |
| 158 | + "Since": sinceRaw, |
| 159 | + "Until": untilRaw, |
| 160 | + "DateLabel": commitDateSummary(sinceRaw, untilRaw), |
| 161 | + "Calendar": commitCalendar(owner.Username, row.Name, ref, filterValues, selectedDate, q.Get("calendar_month"), rows, time.Now()), |
| 162 | + "CommitGroups": groupCommitRows(rows), |
| 163 | + "RefMenu": commitRefMenu(owner.Username, row.Name, ref, row.DefaultBranch, refs), |
| 164 | + "Page": page, |
| 165 | + "NewerHref": newerHref, |
| 166 | + "OlderHref": olderHref, |
| 167 | + "HasActiveFilters": pathFilter != "" || authorFilter != "" || sinceRaw != "" || untilRaw != "", |
| 129 | 168 | }) |
| 130 | 169 | } |
| 131 | 170 | |
@@ -267,8 +306,64 @@ func (h *Handlers) commitsAtom(w http.ResponseWriter, r *http.Request) { |
| 267 | 306 | // git data so templates can render avatars and profile links without |
| 268 | 307 | // re-running the resolver. |
| 269 | 308 | type commitRow struct { |
| 270 | | - Commit git.Commit |
| 271 | | - Author identity.Resolved |
| 309 | + Commit git.Commit |
| 310 | + Author identity.Resolved |
| 311 | + AuthorLabel string |
| 312 | + AuthorHref string |
| 313 | +} |
| 314 | + |
| 315 | +func newCommitRow(c git.Commit, author identity.Resolved) commitRow { |
| 316 | + return commitRow{ |
| 317 | + Commit: c, |
| 318 | + Author: author, |
| 319 | + AuthorLabel: commitAuthorLabel(c, author), |
| 320 | + AuthorHref: commitAuthorHref(author), |
| 321 | + } |
| 322 | +} |
| 323 | + |
| 324 | +type commitGroup struct { |
| 325 | + Title string |
| 326 | + Rows []commitRow |
| 327 | +} |
| 328 | + |
| 329 | +type commitAuthorFilter struct { |
| 330 | + Label string |
| 331 | + Query string |
| 332 | + Href string |
| 333 | + Active bool |
| 334 | + User bool |
| 335 | + AvatarURL string |
| 336 | + IdenticonSeed string |
| 337 | +} |
| 338 | + |
| 339 | +type commitRefOption struct { |
| 340 | + Name string |
| 341 | + Href string |
| 342 | + Active bool |
| 343 | + IsDefault bool |
| 344 | +} |
| 345 | + |
| 346 | +type commitRefMenuView struct { |
| 347 | + Branches []commitRefOption |
| 348 | + Tags []commitRefOption |
| 349 | +} |
| 350 | + |
| 351 | +type commitCalendarView struct { |
| 352 | + MonthLabel string |
| 353 | + YearLabel string |
| 354 | + PrevMonthHref string |
| 355 | + NextMonthHref string |
| 356 | + ClearHref string |
| 357 | + TodayHref string |
| 358 | + Weeks [][]commitCalendarDay |
| 359 | +} |
| 360 | + |
| 361 | +type commitCalendarDay struct { |
| 362 | + Label string |
| 363 | + Href string |
| 364 | + InMonth bool |
| 365 | + IsSelected bool |
| 366 | + IsToday bool |
| 272 | 367 | } |
| 273 | 368 | |
| 274 | 369 | type blameChunkRow struct { |
@@ -276,6 +371,293 @@ type blameChunkRow struct { |
| 276 | 371 | Author identity.Resolved |
| 277 | 372 | } |
| 278 | 373 | |
| 374 | +func commitAuthorLabel(c git.Commit, author identity.Resolved) string { |
| 375 | + if author.User { |
| 376 | + if author.Username != "" { |
| 377 | + return author.Username |
| 378 | + } |
| 379 | + return author.DisplayName |
| 380 | + } |
| 381 | + if strings.TrimSpace(c.AuthorName) != "" { |
| 382 | + return strings.TrimSpace(c.AuthorName) |
| 383 | + } |
| 384 | + return strings.TrimSpace(c.AuthorEmail) |
| 385 | +} |
| 386 | + |
| 387 | +func commitAuthorHref(author identity.Resolved) string { |
| 388 | + if !author.User || author.Username == "" { |
| 389 | + return "" |
| 390 | + } |
| 391 | + return "/" + pathEscapeSegments(author.Username) |
| 392 | +} |
| 393 | + |
| 394 | +func commitAuthorQuery(row commitRow) string { |
| 395 | + if row.Author.User && row.Author.Username != "" { |
| 396 | + return row.Author.Username |
| 397 | + } |
| 398 | + if email := strings.TrimSpace(row.Commit.AuthorEmail); email != "" { |
| 399 | + return email |
| 400 | + } |
| 401 | + return strings.TrimSpace(row.Commit.AuthorName) |
| 402 | +} |
| 403 | + |
| 404 | +func groupCommitRows(rows []commitRow) []commitGroup { |
| 405 | + groups := make([]commitGroup, 0) |
| 406 | + last := "" |
| 407 | + for _, row := range rows { |
| 408 | + title := row.Commit.AuthorWhen.Local().Format("January 2, 2006") |
| 409 | + if title != last { |
| 410 | + groups = append(groups, commitGroup{Title: title}) |
| 411 | + last = title |
| 412 | + } |
| 413 | + groups[len(groups)-1].Rows = append(groups[len(groups)-1].Rows, row) |
| 414 | + } |
| 415 | + return groups |
| 416 | +} |
| 417 | + |
| 418 | +func commitAuthorSummary(rows []commitRow, active string) string { |
| 419 | + active = strings.TrimSpace(active) |
| 420 | + if active == "" { |
| 421 | + return "All users" |
| 422 | + } |
| 423 | + for _, row := range rows { |
| 424 | + if strings.EqualFold(commitAuthorQuery(row), active) || strings.EqualFold(row.AuthorLabel, active) { |
| 425 | + return row.AuthorLabel |
| 426 | + } |
| 427 | + } |
| 428 | + return active |
| 429 | +} |
| 430 | + |
| 431 | +func commitAuthorFilters(owner, repoName, ref string, base url.Values, rows []commitRow, active string) []commitAuthorFilter { |
| 432 | + type candidate struct { |
| 433 | + filter commitAuthorFilter |
| 434 | + key string |
| 435 | + } |
| 436 | + seen := make(map[string]struct{}) |
| 437 | + candidates := make([]candidate, 0, len(rows)) |
| 438 | + for _, row := range rows { |
| 439 | + query := commitAuthorQuery(row) |
| 440 | + if query == "" { |
| 441 | + continue |
| 442 | + } |
| 443 | + key := strings.ToLower(query) |
| 444 | + if _, ok := seen[key]; ok { |
| 445 | + continue |
| 446 | + } |
| 447 | + seen[key] = struct{}{} |
| 448 | + values := cloneURLValues(base) |
| 449 | + values.Set("author", query) |
| 450 | + values.Del("page") |
| 451 | + values.Del("calendar_month") |
| 452 | + candidates = append(candidates, candidate{ |
| 453 | + key: key, |
| 454 | + filter: commitAuthorFilter{ |
| 455 | + Label: row.AuthorLabel, |
| 456 | + Query: query, |
| 457 | + Href: commitsHref(owner, repoName, ref, values), |
| 458 | + Active: strings.EqualFold(active, query) || strings.EqualFold(active, row.AuthorLabel), |
| 459 | + User: row.Author.User, |
| 460 | + AvatarURL: row.Author.AvatarURL, |
| 461 | + IdenticonSeed: row.Author.IdenticonSeed, |
| 462 | + }, |
| 463 | + }) |
| 464 | + } |
| 465 | + if active != "" { |
| 466 | + key := strings.ToLower(active) |
| 467 | + if _, ok := seen[key]; !ok { |
| 468 | + values := cloneURLValues(base) |
| 469 | + values.Set("author", active) |
| 470 | + values.Del("page") |
| 471 | + values.Del("calendar_month") |
| 472 | + candidates = append(candidates, candidate{ |
| 473 | + key: key, |
| 474 | + filter: commitAuthorFilter{ |
| 475 | + Label: active, |
| 476 | + Query: active, |
| 477 | + Href: commitsHref(owner, repoName, ref, values), |
| 478 | + Active: true, |
| 479 | + }, |
| 480 | + }) |
| 481 | + } |
| 482 | + } |
| 483 | + sort.SliceStable(candidates, func(i, j int) bool { |
| 484 | + if candidates[i].filter.Active != candidates[j].filter.Active { |
| 485 | + return candidates[i].filter.Active |
| 486 | + } |
| 487 | + return strings.ToLower(candidates[i].filter.Label) < strings.ToLower(candidates[j].filter.Label) |
| 488 | + }) |
| 489 | + out := make([]commitAuthorFilter, 0, len(candidates)) |
| 490 | + for _, c := range candidates { |
| 491 | + out = append(out, c.filter) |
| 492 | + } |
| 493 | + return out |
| 494 | +} |
| 495 | + |
| 496 | +func commitDateSummary(sinceRaw, untilRaw string) string { |
| 497 | + sinceRaw = strings.TrimSpace(sinceRaw) |
| 498 | + untilRaw = strings.TrimSpace(untilRaw) |
| 499 | + switch { |
| 500 | + case sinceRaw == "" && untilRaw == "": |
| 501 | + return "All time" |
| 502 | + case sinceRaw != "" && untilRaw != "": |
| 503 | + return formatCommitFilterDate(sinceRaw) + " - " + formatCommitFilterDate(untilRaw) |
| 504 | + case sinceRaw != "": |
| 505 | + return "Since " + formatCommitFilterDate(sinceRaw) |
| 506 | + default: |
| 507 | + return "Until " + formatCommitFilterDate(untilRaw) |
| 508 | + } |
| 509 | +} |
| 510 | + |
| 511 | +func formatCommitFilterDate(raw string) string { |
| 512 | + t, err := time.Parse("2006-01-02", raw) |
| 513 | + if err != nil { |
| 514 | + return raw |
| 515 | + } |
| 516 | + return t.Format("Jan 2, 2006") |
| 517 | +} |
| 518 | + |
| 519 | +func commitRefMenu(owner, repoName, current, defaultBranch string, refs git.RefListing) commitRefMenuView { |
| 520 | + out := commitRefMenuView{ |
| 521 | + Branches: make([]commitRefOption, 0, len(refs.Branches)), |
| 522 | + Tags: make([]commitRefOption, 0, len(refs.Tags)), |
| 523 | + } |
| 524 | + for _, ref := range refs.Branches { |
| 525 | + out.Branches = append(out.Branches, commitRefOption{ |
| 526 | + Name: ref.Name, |
| 527 | + Href: commitsHref(owner, repoName, ref.Name, nil), |
| 528 | + Active: ref.Name == current, |
| 529 | + IsDefault: ref.Name == defaultBranch, |
| 530 | + }) |
| 531 | + } |
| 532 | + for _, ref := range refs.Tags { |
| 533 | + out.Tags = append(out.Tags, commitRefOption{ |
| 534 | + Name: ref.Name, |
| 535 | + Href: commitsHref(owner, repoName, ref.Name, nil), |
| 536 | + Active: ref.Name == current, |
| 537 | + }) |
| 538 | + } |
| 539 | + return out |
| 540 | +} |
| 541 | + |
| 542 | +func commitCalendar(owner, repoName, ref string, base url.Values, selected time.Time, monthParam string, rows []commitRow, now time.Time) commitCalendarView { |
| 543 | + if now.IsZero() { |
| 544 | + now = time.Now() |
| 545 | + } |
| 546 | + anchor := selected |
| 547 | + if anchor.IsZero() && len(rows) > 0 { |
| 548 | + anchor = rows[0].Commit.AuthorWhen |
| 549 | + } |
| 550 | + if anchor.IsZero() { |
| 551 | + anchor = now |
| 552 | + } |
| 553 | + if t, err := time.Parse("2006-01", strings.TrimSpace(monthParam)); err == nil { |
| 554 | + anchor = t |
| 555 | + } |
| 556 | + loc := anchor.Location() |
| 557 | + if loc == nil { |
| 558 | + loc = time.Local |
| 559 | + } |
| 560 | + monthStart := time.Date(anchor.Year(), anchor.Month(), 1, 0, 0, 0, 0, loc) |
| 561 | + gridStart := monthStart.AddDate(0, 0, -int(monthStart.Weekday())) |
| 562 | + weeks := make([][]commitCalendarDay, 6) |
| 563 | + for week := 0; week < 6; week++ { |
| 564 | + weeks[week] = make([]commitCalendarDay, 7) |
| 565 | + for day := 0; day < 7; day++ { |
| 566 | + d := gridStart.AddDate(0, 0, week*7+day) |
| 567 | + values := cloneURLValues(base) |
| 568 | + values.Set("until", d.Format("2006-01-02")) |
| 569 | + values.Del("page") |
| 570 | + values.Del("calendar_month") |
| 571 | + weeks[week][day] = commitCalendarDay{ |
| 572 | + Label: strconv.Itoa(d.Day()), |
| 573 | + Href: commitsHref(owner, repoName, ref, values), |
| 574 | + InMonth: d.Month() == monthStart.Month(), |
| 575 | + IsSelected: sameCalendarDate(d, selected), |
| 576 | + IsToday: sameCalendarDate(d, now), |
| 577 | + } |
| 578 | + } |
| 579 | + } |
| 580 | + |
| 581 | + prevValues := cloneURLValues(base) |
| 582 | + prevValues.Set("calendar_month", monthStart.AddDate(0, -1, 0).Format("2006-01")) |
| 583 | + prevValues.Del("page") |
| 584 | + nextValues := cloneURLValues(base) |
| 585 | + nextValues.Set("calendar_month", monthStart.AddDate(0, 1, 0).Format("2006-01")) |
| 586 | + nextValues.Del("page") |
| 587 | + clearValues := cloneURLValues(base) |
| 588 | + clearValues.Del("since") |
| 589 | + clearValues.Del("until") |
| 590 | + clearValues.Del("calendar_month") |
| 591 | + clearValues.Del("page") |
| 592 | + todayValues := cloneURLValues(base) |
| 593 | + todayValues.Set("until", now.Format("2006-01-02")) |
| 594 | + todayValues.Del("calendar_month") |
| 595 | + todayValues.Del("page") |
| 596 | + |
| 597 | + return commitCalendarView{ |
| 598 | + MonthLabel: monthStart.Format("January"), |
| 599 | + YearLabel: monthStart.Format("2006"), |
| 600 | + PrevMonthHref: commitsHref(owner, repoName, ref, prevValues), |
| 601 | + NextMonthHref: commitsHref(owner, repoName, ref, nextValues), |
| 602 | + ClearHref: commitsHref(owner, repoName, ref, clearValues), |
| 603 | + TodayHref: commitsHref(owner, repoName, ref, todayValues), |
| 604 | + Weeks: weeks, |
| 605 | + } |
| 606 | +} |
| 607 | + |
| 608 | +func sameCalendarDate(a, b time.Time) bool { |
| 609 | + if a.IsZero() || b.IsZero() { |
| 610 | + return false |
| 611 | + } |
| 612 | + bb := b.In(a.Location()) |
| 613 | + return a.Year() == bb.Year() && a.Month() == bb.Month() && a.Day() == bb.Day() |
| 614 | +} |
| 615 | + |
| 616 | +func commitFilterValues(pathFilter, authorFilter, sinceRaw, untilRaw string) url.Values { |
| 617 | + values := url.Values{} |
| 618 | + if pathFilter != "" { |
| 619 | + values.Set("path", pathFilter) |
| 620 | + } |
| 621 | + if authorFilter != "" { |
| 622 | + values.Set("author", authorFilter) |
| 623 | + } |
| 624 | + if sinceRaw != "" { |
| 625 | + values.Set("since", sinceRaw) |
| 626 | + } |
| 627 | + if untilRaw != "" { |
| 628 | + values.Set("until", untilRaw) |
| 629 | + } |
| 630 | + return values |
| 631 | +} |
| 632 | + |
| 633 | +func commitsHref(owner, repoName, ref string, values url.Values) string { |
| 634 | + path := "/" + url.PathEscape(owner) + "/" + url.PathEscape(repoName) + "/commits/" + pathEscapeSegments(ref) |
| 635 | + if len(values) == 0 { |
| 636 | + return path |
| 637 | + } |
| 638 | + encoded := values.Encode() |
| 639 | + if encoded == "" { |
| 640 | + return path |
| 641 | + } |
| 642 | + return path + "?" + encoded |
| 643 | +} |
| 644 | + |
| 645 | +func cloneURLValues(values url.Values) url.Values { |
| 646 | + out := make(url.Values, len(values)) |
| 647 | + for k, vv := range values { |
| 648 | + out[k] = append([]string(nil), vv...) |
| 649 | + } |
| 650 | + return out |
| 651 | +} |
| 652 | + |
| 653 | +func pathEscapeSegments(s string) string { |
| 654 | + parts := strings.Split(s, "/") |
| 655 | + for i, part := range parts { |
| 656 | + parts[i] = url.PathEscape(part) |
| 657 | + } |
| 658 | + return strings.Join(parts, "/") |
| 659 | +} |
| 660 | + |
| 279 | 661 | // validateSHA accepts 7..40 hex chars. Git resolves short SHAs when |
| 280 | 662 | // unambiguous; we cap at 40 (full). |
| 281 | 663 | func validateSHA(s string) bool { |
@@ -299,6 +681,14 @@ func parseDateParam(s string) time.Time { |
| 299 | 681 | return t |
| 300 | 682 | } |
| 301 | 683 | |
| 684 | +func parseUntilDateParam(s string) time.Time { |
| 685 | + t := parseDateParam(s) |
| 686 | + if t.IsZero() { |
| 687 | + return t |
| 688 | + } |
| 689 | + return t.Add(24*time.Hour - time.Second) |
| 690 | +} |
| 691 | + |
| 302 | 692 | // linkifyCommitBody produces escaped + linkified HTML from a commit |
| 303 | 693 | // message body. Two transformations: |
| 304 | 694 | // 1. URL detection (http/https) → `<a href="...">URL</a>` |