~jan0sch/smederee

Showing details for patch fc0eb30673fb0a6ec3f765965899490ab951d05d.
2022-09-17 (Sat), 3:58 PM - Jens Grassel - fc0eb30673fb0a6ec3f765965899490ab951d05d

VCS: Overhaul the display of changes (history)

- add functionality to extract more details from darcs xml log
- add twirl template for rendering patch details
- adjust templates for overview and history view
- add i18n
- use CSS for more styling
Summary of changes
1 files added
  • modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html
6 files modified with 387 lines added and 13 lines removed
  • modules/hub/src/main/resources/assets/css/main.css with 52 added and 4 removed lines
  • modules/hub/src/main/resources/messages_en.properties with 7 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala with 306 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 10 added and 4 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html with 6 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html with 6 added and 2 removed lines
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css
--- old-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-03-10 01:29:04.057306411 +0000
+++ new-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-03-10 01:29:04.057306411 +0000
@@ -138,6 +138,58 @@
   word-wrap: break-word;
 }
 
+.overview-latest-changes {
+  font-size: 85%;
+}
+
+.patch {
+  background-color: #eee;
+  margin: 0em 0em 1em 0em;
+  padding: 0em 0.25em 0em 0.25em;
+}
+
+.patch-summary-added, .patch-summary-added-details {
+  font-size: 0.8em;
+}
+
+.patch-summary-added-details ul {
+  list-style: none;
+  padding-inline-start: 1em;
+}
+
+.patch-summary-added-details ul li:before {
+  content: "+ ";
+  font-weight: bold;
+}
+
+.patch-summary-modified, .patch-summary-modified-details {
+  font-size: 0.8em;
+}
+
+.patch-summary-modified-details ul {
+  list-style: none;
+  padding-inline-start: 1em;
+}
+
+.patch-summary-modified-details ul li:before {
+  content: "~ ";
+  font-weight: bold;
+}
+
+.patch-summary-removed, .patch-summary-removed-details {
+  font-size: 0.8em;
+}
+
+.patch-summary-removed-details ul {
+  list-style: none;
+  padding-inline-start: 1em;
+}
+
+.patch-summary-removed-details ul li:before {
+  content: "- ";
+  font-weight: bold;
+}
+
 pre.latest-changes {
   overflow-x: auto;
   overflow-y: hidden;
@@ -148,10 +200,6 @@
   word-wrap: break-word;
 }
 
-pre.latest-changes code {
-  font-size: 85%;
-}
-
 pre.repository-file-content {
   display: block;
   overflow-x: auto;
diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties
--- old-smederee/modules/hub/src/main/resources/messages_en.properties	2025-03-10 01:29:04.057306411 +0000
+++ new-smederee/modules/hub/src/main/resources/messages_en.properties	2025-03-10 01:29:04.057306411 +0000
@@ -123,6 +123,12 @@
 repositories.yours.column.name=Name
 repositories.yours.none-found=Looks like you don't have any repositories created yet.
 
+repository.changes.patch.summary.title=Summary of changes
+repository.changes.patch.summary.added={0} files added
+repository.changes.patch.summary.modified={0} files modified with {1} lines added and {2} lines removed
+repository.changes.patch.summary.modified.details={0} with {1} added and {2} removed lines
+repository.changes.patch.summary.removed={0} files removed
+
 repository.menu.changes.next=Next
 repository.menu.changes=Changes
 repository.menu.files=Files
@@ -136,4 +142,5 @@
 repository.overview.clone.read-only=read-only
 repository.overview.clone.read-write=read-write
 repository.overview.latest-changes=Latest changes
+repository.overview.latest-changes.timestamp={0,date,yyyy-MM-dd (E)}, {0,time,short}
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-03-10 01:29:04.057306411 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-03-10 01:29:04.057306411 +0000
@@ -187,7 +187,9 @@
           )
         )
       )
-      log        <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--max-count=2"))
+      log     <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--max-count=2", "--xml-output"))
+      xmlLog  <- Sync[F].delay(scala.xml.XML.loadString(log.stdout.toList.mkString))
+      patches <- Sync[F].delay((xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList)
       readmeData <- repo.traverse(repo => doLoadReadme(repo))
       readme <- readmeData match {
         case Some((lines, Some(filename))) =>
@@ -217,7 +219,7 @@
               user
             )(
               repo,
-              vcsRepositoryHistory = log.stdout.toList.mkString("\n").some,
+              vcsRepositoryHistory = patches,
               vcsRepositoryReadme = readme,
               vcsRepositoryReadmeFilename = readmeName,
               vcsRepositorySshUri = sshUri
@@ -389,7 +391,11 @@
         else
           from + maxCount
       next = if (to < numberOfChanges) Option(to + 1) else None
-      history <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--index=$from-$to", "--summary"))
+      log <- darcs.log(directory.toNIO)(repositoryName.toString)(
+        Chain(s"--index=$from-$to", "--summary", "--xml-output")
+      )
+      xmlLog  <- Sync[F].delay(scala.xml.XML.loadString(log.stdout.toList.mkString))
+      patches <- Sync[F].delay((xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList)
       actionBaseUri <- Sync[F].delay(
         linkConfig.createFullUri(
           Uri(path =
@@ -430,7 +436,7 @@
               Option(goBackUri),
               s"Smederee - History of ~$repositoryOwnerName/$repositoryName".some,
               user
-            )(history.stdout.toList.mkString("\n"), next, repositoryBaseUri, repo)
+            )(patches, next, repositoryBaseUri, repo)
           )
       }
     } yield resp
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-03-10 01:29:04.057306411 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepository.scala	2025-03-10 01:29:04.057306411 +0000
@@ -17,11 +17,16 @@
 
 package de.smederee.hub
 
+import java.time._
+import java.time.format.DateTimeFormatter
+
 import cats._
 import cats.data._
 import cats.syntax.all._
 import org.http4s.Uri
+import org.slf4j.LoggerFactory
 
+import scala.util.Try
 import scala.util.matching.Regex
 
 /** The types of version control software that we support.
@@ -141,6 +146,306 @@
 
 }
 
+opaque type VcsPatchAuthor = String
+object VcsPatchAuthor {
+  val RemoveEmail: Regex = """<?([a-zA-Z0-9.!#$%&’'*+/=?^_`{|}~-]+)@([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)>?""".r
+
+  /** Create an instance of VcsPatchAuthor from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a VcsPatchAuthor.
+    * @return
+    *   The appropriate instance of VcsPatchAuthor.
+    */
+  def apply(source: String): VcsPatchAuthor = source
+
+  /** Try to create an instance of VcsPatchAuthor from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a VcsPatchAuthor.
+    * @return
+    *   An option to the successfully converted VcsPatchAuthor.
+    */
+  def from(source: String): Option[VcsPatchAuthor] = Option(source).filter(_.nonEmpty)
+
+}
+
+extension (author: VcsPatchAuthor) {
+
+  /** Remove everything from the author string which could be an email address.
+    *
+    * @return
+    *   A string with every occurence of a potential email address replaced by an empty string.
+    */
+  def withoutEmail: String = VcsPatchAuthor.RemoveEmail.replaceAllIn(author.toString, "")
+
+}
+
+opaque type VcsPatchComment = String
+object VcsPatchComment {
+
+  /** Create an instance of VcsPatchComment from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a VcsPatchComment.
+    * @return
+    *   The appropriate instance of VcsPatchComment.
+    */
+  def apply(source: String): VcsPatchComment = source
+
+  /** Try to create an instance of VcsPatchComment from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a VcsPatchComment.
+    * @return
+    *   An option to the successfully converted VcsPatchComment.
+    */
+  def from(source: String): Option[VcsPatchComment] = Option(source).filter(_.nonEmpty)
+
+}
+
+opaque type VcsPatchFilename = String
+object VcsPatchFilename {
+
+  /** Create an instance of VcsPatchFilename from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a VcsPatchFilename.
+    * @return
+    *   The appropriate instance of VcsPatchFilename.
+    */
+  def apply(source: String): VcsPatchFilename = source
+
+  /** Try to create an instance of VcsPatchFilename from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a VcsPatchFilename.
+    * @return
+    *   An option to the successfully converted VcsPatchFilename.
+    */
+  def from(source: String): Option[VcsPatchFilename] = Option(source).filter(_.nonEmpty)
+
+}
+
+opaque type VcsPatchHash = String
+object VcsPatchHash {
+
+  /** Create an instance of VcsPatchHash from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a VcsPatchHash.
+    * @return
+    *   The appropriate instance of VcsPatchHash.
+    */
+  def apply(source: String): VcsPatchHash = source
+
+  /** Try to create an instance of VcsPatchHash from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a VcsPatchHash.
+    * @return
+    *   An option to the successfully converted VcsPatchHash.
+    */
+  def from(source: String): Option[VcsPatchHash] = Option(source).filter(_.nonEmpty)
+
+}
+
+opaque type VcsPatchName = String
+object VcsPatchName {
+
+  /** Create an instance of VcsPatchName from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a VcsPatchName.
+    * @return
+    *   The appropriate instance of VcsPatchName.
+    */
+  def apply(source: String): VcsPatchName = source
+
+  /** Try to create an instance of VcsPatchName from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a VcsPatchName.
+    * @return
+    *   An option to the successfully converted VcsPatchName.
+    */
+  def from(source: String): Option[VcsPatchName] = Option(source).filter(_.nonEmpty)
+
+}
+
+/** A VcsPatchTimestamp is a OffsetDateTime with an `UTC` zone offset.
+  */
+opaque type VcsPatchTimestamp = OffsetDateTime
+object VcsPatchTimestamp {
+
+  /** Create an instance of VcsPatchTimestamp from the given OffsetDateTime type.
+    *
+    * @param source
+    *   An instance of type OffsetDateTime which will be returned as a VcsPatchTimestamp.
+    * @return
+    *   The appropriate instance of VcsPatchTimestamp.
+    */
+  def apply(source: OffsetDateTime): VcsPatchTimestamp = source.withOffsetSameLocal(ZoneOffset.UTC)
+
+  /** Try to create an instance of VcsPatchTimestamp from the given OffsetDateTime.
+    *
+    * @param source
+    *   A OffsetDateTime that should fulfil the requirements to be converted into a VcsPatchTimestamp.
+    * @return
+    *   An option to the successfully converted VcsPatchTimestamp.
+    */
+  def from(source: OffsetDateTime): Option[VcsPatchTimestamp] =
+    source.getOffset() match {
+      case ZoneOffset.UTC => Option(source)
+      case _              => None
+    }
+
+}
+
+extension (timestamp: VcsPatchTimestamp) {
+
+  /** Converts this date-time to an Instant. This returns an Instant representing the same point on the
+    * time-line as this date-time.
+    *
+    * @return
+    *   an Instant representing the same instant, not null
+    */
+  def toInstant: Instant = timestamp.toInstant
+}
+
+/** Summary construct for a file modification within a patch.
+  *
+  * @param added
+  *   The number of lines added to the file which might be 0.
+  * @param name
+  *   The name of the file within the repository.
+  * @param removed
+  *   The number of lines removed from the file which might be 0.
+  */
+final case class VcsPatchSummaryFileModification(added: Int, name: VcsPatchFilename, removed: Int)
+
+/** Summary of file operations within a patch.
+  *
+  * @param added
+  *   A list of file names that are added within this patch.
+  * @param modified
+  *   A list of file modification summaries within this patch.
+  * @param removed
+  *   A list of file names that are removed within this patch.
+  */
+final case class VcsPatchSummary(
+    added: List[VcsPatchFilename],
+    modified: List[VcsPatchSummaryFileModification],
+    removed: List[VcsPatchFilename]
+)
+
+object VcsPatchSummary {
+  private val log = LoggerFactory.getLogger(classOf[VcsPatchSummary])
+
+  /** Parse the given xml element which SHALL originate from the xml output of a darcs log command. Any
+    * exceptional errors which occur during conversion are logged.
+    *
+    * @param darcsLogEntry
+    *   A log entry from a darcs log using the xml output (i.e. the `<patch>` element).
+    * @return
+    *   An option to a successfully extracted patch metadata object.
+    */
+  def fromDarcsXmlLog(darcsLogEntry: scala.xml.Node): Option[VcsPatchSummary] =
+    Try {
+      val summary = (darcsLogEntry \ "summary").headOption
+      val added = summary
+        .map(summary => (summary \ "add_file").flatMap(file => VcsPatchFilename.from(file.text)).toList)
+        .getOrElse(List.empty)
+      val modified = summary
+        .map(summary =>
+          (summary \ "modify_file").toList.flatMap { file =>
+            val filename      = VcsPatchFilename.from(file.text)
+            val added_lines   = (file \ "added_lines" \@ "num").headOption.map(_.toInt).getOrElse(0)
+            val removed_lines = (file \ "removed_lines" \@ "num").headOption.map(_.toInt).getOrElse(0)
+            filename.map(filename => VcsPatchSummaryFileModification(added_lines, filename, removed_lines))
+          }
+        )
+        .getOrElse(List.empty)
+      val removed = summary
+        .map(summary => (summary \ "remove_file").flatMap(file => VcsPatchFilename.from(file.text)).toList)
+        .getOrElse(List.empty)
+      summary.map(_ => VcsPatchSummary(added, modified, removed))
+    } match {
+      case scala.util.Failure(exception) =>
+        log.error(
+          "Error occured while trying to extract patch summary from darcs log xml output!",
+          exception
+        )
+        None
+      case scala.util.Success(patchSummary) => patchSummary
+    }
+}
+
+/** Data describing a patch in a vcs repository.
+  *
+  * @param author
+  *   The author of the patch which is usually a line of the format `Jane Doe <jane@example.com>`.
+  * @param comment
+  *   A comment (which should be a detailed description) for the patch.
+  * @param hash
+  *   A unique hash identifying the patch.
+  * @param name
+  *   The name (title) of the patch.
+  * @param summary
+  *   A summary of the file operations that were done with this patch.
+  * @param timestamp
+  *   The timestamp in UTC when the patch was recorded.
+  */
+final case class VcsRepositoryPatchMetadata(
+    author: VcsPatchAuthor,
+    comment: Option[VcsPatchComment],
+    hash: VcsPatchHash,
+    name: VcsPatchName,
+    summary: Option[VcsPatchSummary],
+    timestamp: VcsPatchTimestamp
+)
+
+object VcsRepositoryPatchMetadata {
+  private val log = LoggerFactory.getLogger(classOf[VcsRepositoryPatchMetadata])
+
+  val DarcsCommentFilter: Regex          = "^Ignore-this: [0-9a-f]+".r
+  val DarcsDateFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
+
+  /** Parse the given xml element which SHALL originate from the xml output of a darcs log command. Any
+    * exceptional errors which occur during conversion are logged.
+    *
+    * @param darcsLogEntry
+    *   A log entry from a darcs log using the xml output (i.e. the `<patch>` element).
+    * @return
+    *   An option to a successfully extracted patch metadata object.
+    */
+  def fromDarcsXmlLog(darcsLogEntry: scala.xml.Node): Option[VcsRepositoryPatchMetadata] =
+    Try {
+      val author = VcsPatchAuthor.from(darcsLogEntry \@ "author")
+      val comment = (darcsLogEntry \ "comment").headOption
+        .map(comment => DarcsCommentFilter.replaceFirstIn(comment.text, "").trim)
+        .flatMap(VcsPatchComment.from)
+      val hash    = VcsPatchHash.from(darcsLogEntry \@ "hash")
+      val name    = (darcsLogEntry \ "name").headOption.map(_.text).flatMap(VcsPatchName.from)
+      val summary = VcsPatchSummary.fromDarcsXmlLog(darcsLogEntry)
+      val timestamp =
+        VcsPatchTimestamp.from(
+          LocalDateTime.parse(darcsLogEntry \@ "date", DarcsDateFormat).atOffset(ZoneOffset.UTC)
+        )
+      (author, hash, name, timestamp).mapN { case (author, hash, name, timestamp) =>
+        VcsRepositoryPatchMetadata(author, comment, hash, name, summary, timestamp)
+      }
+    } match {
+      case scala.util.Failure(exception) =>
+        log.error(
+          "Error occured while trying to extract patch information from darcs log xml output!",
+          exception
+        )
+        None
+      case scala.util.Success(patchMetadata) => patchMetadata
+    }
+}
+
 /** Data about a VCS respository.
   *
   * @param name
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/repositoryPatchMetadata.scala.html	2025-03-10 01:29:04.057306411 +0000
@@ -0,0 +1,51 @@
+@(patch: VcsRepositoryPatchMetadata)(implicit locale: java.util.Locale)
+<div class="patch">
+  <div class="patch-details">
+    <span class="timestamp">@Messages("repository.overview.latest-changes.timestamp", java.util.Date.from(patch.timestamp.toInstant))</span> - <span class="author">@patch.author.withoutEmail</span>
+  </div>
+  <h4 class="patch-name">@patch.name</h4>
+  <div class="patch-comments">
+    <pre class="latest-changes"><code>@patch.comment</code></pre>
+  </div>
+  @for(summary <- patch.summary) {
+    <h5>@Messages("repository.changes.patch.summary.title")</h5>
+    <div class="patch-summary">
+      @if(summary.added.size > 0) {
+        <div class="patch-summary-added">@Messages("repository.changes.patch.summary.added", summary.added.size)</div>
+        <div class="patch-summary-added-details">
+          <ul>
+            @for(filename <- summary.added) {
+              <li>@filename</li>
+            }
+          </ul>
+        </div>
+      } else {
+        <!-- No files were added with this patch. -->
+      }
+      @if(summary.modified.size > 0) {
+        <div class="patch-summary-modified">@Messages("repository.changes.patch.summary.modified", summary.modified.size, summary.modified.foldLeft(0)((acc, mod) => acc + mod.added), summary.modified.foldLeft(0)((acc, mod) => acc + mod.removed))</div>
+        <div class="patch-summary-modified-details">
+          <ul>
+            @for(modified <- summary.modified) {
+              <li>@Messages("repository.changes.patch.summary.modified.details", modified.name, modified.added, modified.removed)</li>
+            }
+          </ul>
+        </div>
+      } else {
+        <!-- No files were modified with this patch. -->
+      }
+      @if(summary.removed.size > 0) {
+        <div class="patch-summary-removed">@Messages("repository.changes.patch.summary.removed", summary.removed.size)</div>
+        <div class="patch-summary-removed-details">
+          <ul>
+            @for(filename <- summary.removed) {
+              <li>@filename</li>
+            }
+          </ul>
+        </div>
+      } else {
+        <!-- No files were removed with this patch. -->
+      }
+    </div>
+  }
+</div>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html	2025-03-10 01:29:04.057306411 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryHistory.scala.html	2025-03-10 01:29:04.061306407 +0000
@@ -1,4 +1,4 @@
-@(baseUri: Uri, lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, goBackUri: Option[Uri] = None, title: Option[String] = None, user: Option[Account])(history: String, nextEntry: Option[Int], repositoryBaseUri: Uri, vcsRepository: VcsRepository)
+@(baseUri: Uri, lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, goBackUri: Option[Uri] = None, title: Option[String] = None, user: Option[Account])(history: List[VcsRepositoryPatchMetadata], nextEntry: Option[Int], repositoryBaseUri: Uri, vcsRepository: VcsRepository)
 @main(baseUri, lang)()(csrf, title, user) {
 @defining(lang.toLocale) { implicit locale =>
   <div class="content">
@@ -23,7 +23,11 @@
   <div class="content">
     <div class="pure-g">
       <div class="pure-u-1-1 pure-u-md-1-1">
-        <div class="l-box"><pre><code>@history</code></pre></div>
+        <div class="l-box">
+          @for(patch <- history) {
+            @repositoryPatchMetadata(patch)
+          }
+        </div>
       </div>
       <div class="pure-u-1-1 pure-u-md-1-1">
         <div class="l-box">
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-03-10 01:29:04.057306411 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-03-10 01:29:04.061306407 +0000
@@ -1,4 +1,4 @@
-@(baseUri: Uri, lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(vcsRepository: VcsRepository, vcsRepositoryHistory: Option[String], vcsRepositoryReadme: Option[String] = None, vcsRepositoryReadmeFilename: Option[String] = None, vcsRepositorySshUri: Option[String] = None)
+@(baseUri: Uri, lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(vcsRepository: VcsRepository, vcsRepositoryHistory: List[VcsRepositoryPatchMetadata], vcsRepositoryReadme: Option[String] = None, vcsRepositoryReadmeFilename: Option[String] = None, vcsRepositorySshUri: Option[String] = None)
 @main(baseUri, lang)()(csrf, title, user) {
 @defining(lang.toLocale) { implicit locale =>
   <div class="content">
@@ -28,7 +28,11 @@
       <div class="pure-u-3-5 pure-u-md-3-5">
         <div class="l-box">
           <h3>@Messages("repository.overview.latest-changes")</h3>
-          <pre class="latest-changes"><code>@vcsRepositoryHistory</code></pre>
+          <div class="overview-latest-changes">
+            @for(patch <- vcsRepositoryHistory) {
+              @repositoryPatchMetadata(patch)
+            }
+          </div>
         </div>
       </div>
       <div class="pure-u-2-5 pure-u-md-2-5">