(** The repository pages: summary, commit list, file tree, blob, commit detail, branches and tags. Each page is a description: it names the parts it is made of and hands them to {!Layout}. Markup lives in {!Ui}, ogit's page parts in {!Components}, language guessing in {!Syntax}, and date formatting in {!Time_format}. *) type context = { repo : string; description : string; site : Layout.site } (** What every repository page needs to know about its subject. *) type commit_message = Commit_message.t = { summary : string; body : string } let context ~site ~repo ~description = { repo; description; site } (** {1 Commit messages} *) let parse_commit_message = Commit_message.parse let parse_conventional = Commit_message.parse_conventional (** {1 Links into the commit list} *) let commits_url = Components.commits_url (** A person's name, linking to the commits attributed to them. Keeps the other active filters intact so identities compose with type filters. *) let identity_link ?filter_type ?author ?committer ?(show_email = false) ~role repo (user : Resolvers.Commit.user) = let href, role_name = match role with | `Author -> (commits_url ?filter_type ~author:user.email ?committer repo, "author") | `Committer -> ( commits_url ?filter_type ?author ~committer:user.email repo, "committer" ) in Ui.text_link ~class_:"commit-identity" ~label:(Printf.sprintf "Filter commits by %s %s" role_name user.name) ~href (if show_email then Printf.sprintf "%s <%s>" user.name user.email else user.name) (** {1 Page shell} *) let page_title context = context.repo ^ " — " ^ context.description let render_page ?heading ?(toolbar = []) context ~active content = Ui.respond @@ Layout.render context.site ~page_title:(page_title context) { repo = Some context.repo; title = Option.value heading ~default:context.repo; subtitle = context.description; active; toolbar; home_href = None; content; } (** {1 Rows} *) (** One line of the commit list: when it happened, what changed, and who did it. @param hide_pill suppresses the type badge when the list is already filtered to a single type, where repeating it on every row adds nothing. @param author when filtering by author, the author column is dropped for the same reason. *) let commit_row ?filter_type ?author ?committer ?(hide_pill = false) repo (commit : Resolvers.Commit.t) = let message = parse_commit_message commit.message in let commit_type, title = parse_conventional message.summary in let badge = match commit_type with | Some commit_type when not hide_pill -> Components.commit_type_badge ~href:(commits_url ~filter_type:commit_type ?author ?committer repo) commit_type | _ -> Ui.nothing in Ui.item [ Ui.inline_text ~class_:"timestamp" (Time_format.short_time commit.author.date); badge; Ui.inline ~class_:"commit-title" [ Components.route_link (Commit (repo, commit.hash)) title ]; Ui.inline_text ~class_:"commit-ago" (Time_format.relative_time commit.author.date); (match author with | Some _ -> Ui.nothing | None -> Ui.inline ~class_:"commit-author" [ identity_link ?filter_type ?author ?committer ~role:`Author repo commit.author; ]); ] (** Directories with more than seven entries show three children and a link to the full directory page, keeping the tree scannable without hiding anything permanently. The repository root is exempt — it always shows all entries. *) let tree_truncation_threshold = 7 let tree_display_limit = 3 let rec tree_row repo ~prefix (node : Resolvers.Tree.tree_node) = let entry = node.entry in (* Entry names may span several segments when single-child directories were collapsed, so appending them to the prefix always yields the full path. *) let path = if prefix = "" then entry.name else prefix ^ "/" ^ entry.name in let route = Routes.File_at (repo, path) in (* Dotfiles stay visible but are de-emphasised. *) let modifier = if String.length entry.name > 0 && entry.name.[0] = '.' then "tree-hidden" else "" in match node.children with | None -> Components.file_entry ~modifier ~route entry.name | Some children -> let total = List.length children in let shown, omitted = if total <= tree_truncation_threshold then (children, 0) else (List_ext.take tree_display_limit children, total - tree_display_limit) in let overflow = if omitted = 0 then [] else [ Components.overflow_row ~route omitted ] in Components.directory ~modifier ~route ~name:entry.name (List.map (tree_row repo ~prefix:path) shown @ overflow) (** {1 Trails} *) (** The path from the repository root to the entry being viewed. Each crumb links to the accumulated path, so intermediate directories resolve by name rather than by object id. *) let path_trail repo (trail : (string * string) list) = let repo_name = match List.rev (String.split_on_char '/' repo) with | name :: _ -> name | [] -> repo in let root = Ui.crumb ~href:(Components.url (Files repo)) repo_name in let entries = let rec go prefix acc = function | [] -> List.rev acc | (name, _hash) :: rest -> let path = if prefix = "" then name else prefix ^ "/" ^ name in let crumb = Ui.crumb ~href:(Components.url (File_at (repo, path))) name in go path (crumb :: acc) rest in go "" [] trail in Ui.breadcrumb ~class_:"path-pill" ~link_class:"path-pill-link" ~separator_class:"path-pill-sep" ~separator:"/" (root :: entries) (** {1 Pages} *) let summary context ?readme () = let readme_panel = match readme with | None -> Ui.nothing | Some (readme : Resolvers.Readme.t) -> Components.inline_readme ~filename:readme.name readme.content in render_page context ~active:Summary ~toolbar:[] [ readme_panel ] let commits ?filter_type ?author ?committer ?(truncated = false) ~page_number ~has_prev ~has_next context commits = (* Each active filter offers a control that clears just itself, leaving the others applied. *) let active_filters = (match filter_type with | None -> [] | Some commit_type -> [ ( "commit type", commit_type, commits_url ?author ?committer context.repo, "commit-pill commit-pill-" ^ commit_type ); ]) @ (match author with | None -> [] | Some email -> [ ( "author", "Author: " ^ email, commits_url ?filter_type ?committer context.repo, "toolbar-filter-value" ); ]) @ match committer with | None -> [] | Some email -> [ ( "committer", "Committer: " ^ email, commits_url ?filter_type ?author context.repo, "toolbar-filter-value" ); ] in let filters = match active_filters with | [] -> [] | filters -> [ Ui.block ~class_:"toolbar-filters" (List.map (fun (name, value, dismiss_href, value_class) -> Ui.dismissible ~value_class ~dismiss_href ~dismiss_label:(Printf.sprintf "Remove %s filter" name) value) filters); ] in let page_url n = commits_url ?filter_type ?author ?committer ~page_number:n context.repo in let pagination = if not (has_prev || has_next) then [] else [ Ui.pagination ?previous_href: (if has_prev then Some (page_url (page_number - 1)) else None) ?next_href: (if has_next then Some (page_url (page_number + 1)) else None) page_number; ] in render_page context ~active:Commits ~toolbar:(filters @ pagination) [ Ui.items_of ~id:"commit-list" (commit_row ~hide_pill:(Option.is_some filter_type) ?filter_type ?author ?committer context.repo) commits; (if truncated then Ui.paragraph_text ~class_:"commit-list-note" "The search stopped before it reached the oldest history. Older \ matching commits are not shown." else Ui.nothing); ] let files context trail (entries : Resolvers.Tree.tree_node list) = (* Rows link to their full path, so a listing below the root needs the trail as the path prefix. *) let prefix = String.concat "/" (List.map fst trail) in render_page context ~active:Files ~toolbar:[ path_trail context.repo trail ] [ Ui.items_of ~id:"file-tree" (tree_row context.repo ~prefix) entries ] let is_image_filename filename = match Filename.extension filename |> String.lowercase_ascii with | ".png" | ".jpg" | ".jpeg" | ".gif" | ".svg" | ".webp" | ".ico" | ".bmp" | ".avif" -> true | _ -> false let file context trail (blob : Resolvers.Blob.t) = let filename = match List.rev trail with (name, _) :: _ -> Some name | [] -> None in let full_path = String.concat "/" (List.map fst trail) in let raw_url = if full_path = "" then None else Some (Components.url (Raw_at (context.repo, full_path))) in let raw_link = match raw_url with | Some href -> Ui.paragraph [ Ui.text_link ~href "View raw" ] | None -> Ui.nothing in let body = match filename with | Some filename when is_image_filename filename -> let src = Option.value raw_url ~default:"" in Ui.block ~class_:"image-preview" [ Ui.image ~class_:"file-image" ~alt:filename ~src () ] | Some filename when Prose.Render.is_doc_filename filename -> Prose.Render.render ~filename blob.content | _ -> let language = Highlight.Detect.detect ~filename blob.content in let lines = Highlight.Engine.highlight ~lang:language blob.content in Ui.highlighted_code_listing ~id:"blob" lines in let toolbar = [ path_trail context.repo trail ] in render_page context ~active:Files ~toolbar [ raw_link; body ] let commit context (commit : Resolvers.Commit.t) diff = let message = parse_commit_message commit.message in let commit_type, title = parse_conventional message.summary in let number = function Some n -> string_of_int n | None -> "" in let diff_line (line : Resolvers.Diff.line) : Ui.Diff.line = { before = number line.old_number; after = number line.new_number; change = (match line.kind with | Resolvers.Diff.Context -> Ui.Diff.Unchanged | Resolvers.Diff.Addition -> Ui.Diff.Added | Resolvers.Diff.Deletion -> Ui.Diff.Removed); content = line.text; } in let diff_section (hunk : Resolvers.Diff.hunk) : Ui.Diff.section = { section_heading = Printf.sprintf "@@ -%d,%d +%d,%d @@" hunk.old_start hunk.old_count hunk.new_start hunk.new_count; lines = List.map diff_line hunk.lines; } in let mode = function | None -> "000000" | Some mode -> Printf.sprintf "%06o" mode in let hash = function | None -> "00000000" | Some hash -> Resolvers.short_hash hash in let diff_file (file : Resolvers.Diff.file) : Ui.Diff.file = { path = file.path; detail = Printf.sprintf "index %s..%s %s..%s" (hash file.old_hash) (hash file.new_hash) (mode file.old_mode) (mode file.new_mode); sections = List.map diff_section file.hunks; note = (if file.binary then Some "Binary files differ" else None); } in let badge = match commit_type with | None -> Ui.nothing | Some commit_type -> Components.commit_type_badge ~href:(commits_url ~filter_type:commit_type context.repo) commit_type in let body = if message.body = "" then [] else [ Ui.paragraph_text ~class_:"commit-body" message.body ] in let timestamp date = let machine, display = Time_format.exact_time date in Ui.timestamp ~machine display in let metadata = Ui.definitions ~class_:"commit-meta" [ ("Commit", [ Ui.text commit.hash ]); ( "Author", [ identity_link ~show_email:true ~role:`Author context.repo commit.author; ] ); ("Author date", [ timestamp commit.author.date ]); ( "Committer", [ identity_link ~show_email:true ~role:`Committer context.repo commit.committer; ] ); ("Committer date", [ timestamp commit.committer.date ]); ] in render_page ~heading:(context.repo ^ " : " ^ Resolvers.short_hash commit.hash) context ~active:Commits ((Ui.heading ~level:3 [ badge; Ui.text (" " ^ title) ] :: body) @ [ metadata ] @ Ui.Diff.view ~empty_message:"No file changes in this commit." (List.map diff_file diff))