View raw

1 (** The repository pages: summary, commit list, file tree, blob, commit detail, 2 branches and tags. 3 4 Each page is a description: it names the parts it is made of and hands them 5 to {!Layout}. Markup lives in {!Ui}, ogit's page parts in {!Components}, 6 language guessing in {!Syntax}, and date formatting in {!Time_format}. *) 7 8 type context = { repo : string; description : string; site : Layout.site } 9 (** What every repository page needs to know about its subject. *) 10 11 type commit_message = Commit_message.t = { summary : string; body : string } 12 13 let context ~site ~repo ~description = { repo; description; site } 14 15 (** {1 Commit messages} *) 16 17 let parse_commit_message = Commit_message.parse 18 let parse_conventional = Commit_message.parse_conventional 19 20 (** {1 Links into the commit list} *) 21 22 let commits_url = Components.commits_url 23 24 (** A person's name, linking to the commits attributed to them. Keeps the other 25 active filters intact so identities compose with type filters. *) 26 let identity_link ?filter_type ?author ?committer ?(show_email = false) ~role 27 repo (user : Resolvers.Commit.user) = 28 let href, role_name = 29 match role with 30 | `Author -> 31 (commits_url ?filter_type ~author:user.email ?committer repo, "author") 32 | `Committer -> 33 ( commits_url ?filter_type ?author ~committer:user.email repo, 34 "committer" ) 35 in 36 Ui.text_link ~class_:"commit-identity" 37 ~label:(Printf.sprintf "Filter commits by %s %s" role_name user.name) 38 ~href 39 (if show_email then Printf.sprintf "%s <%s>" user.name user.email 40 else user.name) 41 42 (** {1 Page shell} *) 43 44 let page_title context = context.repo ^ "" ^ context.description 45 46 let render_page ?heading ?(toolbar = []) context ~active content = 47 Ui.respond 48 @@ Layout.render context.site ~page_title:(page_title context) 49 { 50 repo = Some context.repo; 51 title = Option.value heading ~default:context.repo; 52 subtitle = context.description; 53 active; 54 toolbar; 55 home_href = None; 56 content; 57 } 58 59 (** {1 Rows} *) 60 61 (** One line of the commit list: when it happened, what changed, and who did it. 62 63 @param hide_pill 64 suppresses the type badge when the list is already filtered to a single 65 type, where repeating it on every row adds nothing. 66 @param author 67 when filtering by author, the author column is dropped for the same 68 reason. *) 69 let commit_row ?filter_type ?author ?committer ?(hide_pill = false) repo 70 (commit : Resolvers.Commit.t) = 71 let message = parse_commit_message commit.message in 72 let commit_type, title = parse_conventional message.summary in 73 let badge = 74 match commit_type with 75 | Some commit_type when not hide_pill -> 76 Components.commit_type_badge 77 ~href:(commits_url ~filter_type:commit_type ?author ?committer repo) 78 commit_type 79 | _ -> Ui.nothing 80 in 81 Ui.item 82 [ 83 Ui.inline_text ~class_:"timestamp" 84 (Time_format.short_time commit.author.date); 85 badge; 86 Ui.inline ~class_:"commit-title" 87 [ Components.route_link (Commit (repo, commit.hash)) title ]; 88 Ui.inline_text ~class_:"commit-ago" 89 (Time_format.relative_time commit.author.date); 90 (match author with 91 | Some _ -> Ui.nothing 92 | None -> 93 Ui.inline ~class_:"commit-author" 94 [ 95 identity_link ?filter_type ?author ?committer ~role:`Author repo 96 commit.author; 97 ]); 98 ] 99 100 (** Directories with more than seven entries show three children and a link to 101 the full directory page, keeping the tree scannable without hiding anything 102 permanently. The repository root is exempt — it always shows all entries. *) 103 let tree_truncation_threshold = 7 104 105 let tree_display_limit = 3 106 107 let rec tree_row repo ~prefix (node : Resolvers.Tree.tree_node) = 108 let entry = node.entry in 109 (* Entry names may span several segments when single-child directories were 110 collapsed, so appending them to the prefix always yields the full path. *) 111 let path = if prefix = "" then entry.name else prefix ^ "/" ^ entry.name in 112 let route = Routes.File_at (repo, path) in 113 (* Dotfiles stay visible but are de-emphasised. *) 114 let modifier = 115 if String.length entry.name > 0 && entry.name.[0] = '.' then "tree-hidden" 116 else "" 117 in 118 match node.children with 119 | None -> Components.file_entry ~modifier ~route entry.name 120 | Some children -> 121 let total = List.length children in 122 let shown, omitted = 123 if total <= tree_truncation_threshold then (children, 0) 124 else 125 (List_ext.take tree_display_limit children, total - tree_display_limit) 126 in 127 let overflow = 128 if omitted = 0 then [] else [ Components.overflow_row ~route omitted ] 129 in 130 Components.directory ~modifier ~route ~name:entry.name 131 (List.map (tree_row repo ~prefix:path) shown @ overflow) 132 133 (** {1 Trails} *) 134 135 (** The path from the repository root to the entry being viewed. Each crumb 136 links to the accumulated path, so intermediate directories resolve by name 137 rather than by object id. *) 138 let path_trail repo (trail : (string * string) list) = 139 let repo_name = 140 match List.rev (String.split_on_char '/' repo) with 141 | name :: _ -> name 142 | [] -> repo 143 in 144 let root = Ui.crumb ~href:(Components.url (Files repo)) repo_name in 145 let entries = 146 let rec go prefix acc = function 147 | [] -> List.rev acc 148 | (name, _hash) :: rest -> 149 let path = if prefix = "" then name else prefix ^ "/" ^ name in 150 let crumb = 151 Ui.crumb ~href:(Components.url (File_at (repo, path))) name 152 in 153 go path (crumb :: acc) rest 154 in 155 go "" [] trail 156 in 157 Ui.breadcrumb ~class_:"path-pill" ~link_class:"path-pill-link" 158 ~separator_class:"path-pill-sep" ~separator:"/" (root :: entries) 159 160 (** {1 Pages} *) 161 162 let summary context ?readme () = 163 let readme_panel = 164 match readme with 165 | None -> Ui.nothing 166 | Some (readme : Resolvers.Readme.t) -> 167 Components.inline_readme ~filename:readme.name readme.content 168 in 169 render_page context ~active:Summary ~toolbar:[] [ readme_panel ] 170 171 let commits ?filter_type ?author ?committer ?(truncated = false) ~page_number 172 ~has_prev ~has_next context commits = 173 (* Each active filter offers a control that clears just itself, leaving the 174 others applied. *) 175 let active_filters = 176 (match filter_type with 177 | None -> [] 178 | Some commit_type -> 179 [ 180 ( "commit type", 181 commit_type, 182 commits_url ?author ?committer context.repo, 183 "commit-pill commit-pill-" ^ commit_type ); 184 ]) 185 @ (match author with 186 | None -> [] 187 | Some email -> 188 [ 189 ( "author", 190 "Author: " ^ email, 191 commits_url ?filter_type ?committer context.repo, 192 "toolbar-filter-value" ); 193 ]) 194 @ 195 match committer with 196 | None -> [] 197 | Some email -> 198 [ 199 ( "committer", 200 "Committer: " ^ email, 201 commits_url ?filter_type ?author context.repo, 202 "toolbar-filter-value" ); 203 ] 204 in 205 let filters = 206 match active_filters with 207 | [] -> [] 208 | filters -> 209 [ 210 Ui.block ~class_:"toolbar-filters" 211 (List.map 212 (fun (name, value, dismiss_href, value_class) -> 213 Ui.dismissible ~value_class ~dismiss_href 214 ~dismiss_label:(Printf.sprintf "Remove %s filter" name) 215 value) 216 filters); 217 ] 218 in 219 let page_url n = 220 commits_url ?filter_type ?author ?committer ~page_number:n context.repo 221 in 222 let pagination = 223 if not (has_prev || has_next) then [] 224 else 225 [ 226 Ui.pagination 227 ?previous_href: 228 (if has_prev then Some (page_url (page_number - 1)) else None) 229 ?next_href: 230 (if has_next then Some (page_url (page_number + 1)) else None) 231 page_number; 232 ] 233 in 234 render_page context ~active:Commits ~toolbar:(filters @ pagination) 235 [ 236 Ui.items_of ~id:"commit-list" 237 (commit_row 238 ~hide_pill:(Option.is_some filter_type) 239 ?filter_type ?author ?committer context.repo) 240 commits; 241 (if truncated then 242 Ui.paragraph_text ~class_:"commit-list-note" 243 "The search stopped before it reached the oldest history. Older \ 244 matching commits are not shown." 245 else Ui.nothing); 246 ] 247 248 let files context trail (entries : Resolvers.Tree.tree_node list) = 249 (* Rows link to their full path, so a listing below the root needs the trail 250 as the path prefix. *) 251 let prefix = String.concat "/" (List.map fst trail) in 252 render_page context ~active:Files 253 ~toolbar:[ path_trail context.repo trail ] 254 [ Ui.items_of ~id:"file-tree" (tree_row context.repo ~prefix) entries ] 255 256 let is_image_filename filename = 257 match Filename.extension filename |> String.lowercase_ascii with 258 | ".png" | ".jpg" | ".jpeg" | ".gif" | ".svg" | ".webp" | ".ico" | ".bmp" 259 | ".avif" -> 260 true 261 | _ -> false 262 263 let file context trail (blob : Resolvers.Blob.t) = 264 let filename = 265 match List.rev trail with (name, _) :: _ -> Some name | [] -> None 266 in 267 let full_path = String.concat "/" (List.map fst trail) in 268 let raw_url = 269 if full_path = "" then None 270 else Some (Components.url (Raw_at (context.repo, full_path))) 271 in 272 let raw_link = 273 match raw_url with 274 | Some href -> Ui.paragraph [ Ui.text_link ~href "View raw" ] 275 | None -> Ui.nothing 276 in 277 let body = 278 match filename with 279 | Some filename when is_image_filename filename -> 280 let src = Option.value raw_url ~default:"" in 281 Ui.block ~class_:"image-preview" 282 [ Ui.image ~class_:"file-image" ~alt:filename ~src () ] 283 | Some filename when Prose.Render.is_doc_filename filename -> 284 Prose.Render.render ~filename blob.content 285 | _ -> 286 let language = Highlight.Detect.detect ~filename blob.content in 287 let lines = Highlight.Engine.highlight ~lang:language blob.content in 288 Ui.highlighted_code_listing ~id:"blob" lines 289 in 290 let toolbar = [ path_trail context.repo trail ] in 291 render_page context ~active:Files ~toolbar [ raw_link; body ] 292 293 let commit context (commit : Resolvers.Commit.t) diff = 294 let message = parse_commit_message commit.message in 295 let commit_type, title = parse_conventional message.summary in 296 let number = function Some n -> string_of_int n | None -> "" in 297 let diff_line (line : Resolvers.Diff.line) : Ui.Diff.line = 298 { 299 before = number line.old_number; 300 after = number line.new_number; 301 change = 302 (match line.kind with 303 | Resolvers.Diff.Context -> Ui.Diff.Unchanged 304 | Resolvers.Diff.Addition -> Ui.Diff.Added 305 | Resolvers.Diff.Deletion -> Ui.Diff.Removed); 306 content = line.text; 307 } 308 in 309 let diff_section (hunk : Resolvers.Diff.hunk) : Ui.Diff.section = 310 { 311 section_heading = 312 Printf.sprintf "@@ -%d,%d +%d,%d @@" hunk.old_start hunk.old_count 313 hunk.new_start hunk.new_count; 314 lines = List.map diff_line hunk.lines; 315 } 316 in 317 let mode = function 318 | None -> "000000" 319 | Some mode -> Printf.sprintf "%06o" mode 320 in 321 let hash = function 322 | None -> "00000000" 323 | Some hash -> Resolvers.short_hash hash 324 in 325 let diff_file (file : Resolvers.Diff.file) : Ui.Diff.file = 326 { 327 path = file.path; 328 detail = 329 Printf.sprintf "index %s..%s %s..%s" (hash file.old_hash) 330 (hash file.new_hash) (mode file.old_mode) (mode file.new_mode); 331 sections = List.map diff_section file.hunks; 332 note = (if file.binary then Some "Binary files differ" else None); 333 } 334 in 335 let badge = 336 match commit_type with 337 | None -> Ui.nothing 338 | Some commit_type -> 339 Components.commit_type_badge 340 ~href:(commits_url ~filter_type:commit_type context.repo) 341 commit_type 342 in 343 let body = 344 if message.body = "" then [] 345 else [ Ui.paragraph_text ~class_:"commit-body" message.body ] 346 in 347 let timestamp date = 348 let machine, display = Time_format.exact_time date in 349 Ui.timestamp ~machine display 350 in 351 let metadata = 352 Ui.definitions ~class_:"commit-meta" 353 [ 354 ("Commit", [ Ui.text commit.hash ]); 355 ( "Author", 356 [ 357 identity_link ~show_email:true ~role:`Author context.repo 358 commit.author; 359 ] ); 360 ("Author date", [ timestamp commit.author.date ]); 361 ( "Committer", 362 [ 363 identity_link ~show_email:true ~role:`Committer context.repo 364 commit.committer; 365 ] ); 366 ("Committer date", [ timestamp commit.committer.date ]); 367 ] 368 in 369 render_page 370 ~heading:(context.repo ^ " : " ^ Resolvers.short_hash commit.hash) 371 context ~active:Commits 372 ((Ui.heading ~level:3 [ badge; Ui.text (" " ^ title) ] :: body) 373 @ [ metadata ] 374 @ Ui.Diff.view ~empty_message:"No file changes in this commit." 375 (List.map diff_file diff)) 376