darcsweb - src/DarcsWeb/Html.hs

summary shortlog log tree tags
[root] / src / DarcsWeb / Html.hs
{-# LANGUAGE OverloadedStrings #-}

module DarcsWeb.Html
  ( renderPage
  , renderRepoList
  , renderShortLog
  , renderFullLog
  , renderPatchDetail
  , renderTags
  , renderTree
  , renderBlob
  , renderRepoSummary
  , render404
  -- Exported for testing
  , esc
  , highlightDiff
  , shortAuthor
  , formatSize
  , formatAbsolute
  ) where

import           Data.Text (Text)
import qualified Data.Text as T
import           Data.Time.Clock (UTCTime, diffUTCTime)
import           Data.Time.Format (parseTimeM, defaultTimeLocale)

import qualified HtmlPure
import           DarcsWeb.Types

-- | Wrap content in a full HTML page
renderPage :: Text -> Text -> [Text] -> Text
renderPage title breadcrumbs bodyParts = T.concat
    [ "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
    , "<meta charset=\"utf-8\">\n"
    , "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
    , "<title>", esc title, "</title>\n"
    , "<link rel=\"stylesheet\" href=\"/static/style.css\">\n"
    , "</head>\n<body>\n"
    , "<div class=\"page-header\">\n"
    , "<a href=\"/\"><img src=\"/static/darcs-logo.png\" alt=\"darcs\" class=\"header-logo\"></a>\n"
    , breadcrumbs
    , "</div>\n"
    , "<div class=\"page-body\">\n"
    , T.concat bodyParts
    , "</div>\n"
    , "<div class=\"page-footer\">\n"
    , "<p>Powered by <a href=\"https://darcs.net\">darcs</a> &amp; "
    , "<a href=\"https://github.com/scotty-web/scotty\">scotty</a></p>\n"
    , "</div>\n"
    , "</body>\n</html>\n"
    ]

-- | Render the list of repositories (main index page)
renderRepoList :: UTCTime -> Text -> [RepoInfo] -> Text
renderRepoList now siteTitle repos =
    let breadcrumbs = ""
        body = T.concat
          [ if null repos
            then "<p class=\"empty\">No repositories found.</p>\n"
            else renderRepoTable now repos
          ]
    in renderPage siteTitle breadcrumbs [body]

renderRepoTable :: UTCTime -> [RepoInfo] -> Text
renderRepoTable now repos = T.concat
    [ "<table class=\"repo-list\">\n"
    , "<thead><tr>\n"
    , "<th>Repository</th><th>Description</th>"
    , "<th>Last Change</th><th>Patches</th>"
    , "<th></th>\n"
    , "</tr></thead>\n<tbody>\n"
    , T.concat (map (renderRepoRow now) repos)
    , "</tbody></table>\n"
    ]

renderRepoRow :: UTCTime -> RepoInfo -> Text
renderRepoRow now ri = T.concat
    [ "<tr>\n"
    , "<td class=\"repo-name\"><a href=\"/repo/", esc (riName ri), "/summary\">", esc (riName ri), "</a></td>\n"
    , "<td>", esc (riDescription ri), "</td>\n"
    , "<td>", esc (relativeDate now (riLastChange ri)), "</td>\n"
    , "<td class=\"num\">", T.pack (show (riPatchCount ri)), "</td>\n"
    , "<td class=\"actions\">"
    , "<a href=\"/repo/", esc (riName ri), "/shortlog\">log</a> | "
    , "<a href=\"/repo/", esc (riName ri), "/tags\">tags</a>"
    , "</td>\n"
    , "</tr>\n"
    ]

-- | Render repository summary page
renderRepoSummary :: UTCTime -> Text -> RepoInfo -> [PatchSummary] -> [PatchSummary] -> Text
renderRepoSummary now repoName ri recentPatches tags =
    let breadcrumbs = " / <a href=\"/repo/" <> esc repoName <> "/summary\">" <> esc repoName <> "</a>"
        body = T.concat
          [ "<h1>", esc repoName, "</h1>\n"
          , if T.null (riDescription ri)
            then ""
            else "<p class=\"description\">" <> esc (riDescription ri) <> "</p>\n"
          , repoNavBar repoName "summary"
          -- Recent activity
          , "<h2>Recent Activity</h2>\n"
          , if null recentPatches
            then "<p class=\"empty\">No patches.</p>\n"
            else renderShortLogTable now repoName (take 10 recentPatches)
          , if length recentPatches > 0
            then "<p class=\"more\"><a href=\"/repo/" <> esc repoName <> "/shortlog\">...</a></p>\n"
            else ""
          -- Tags
          , if null tags
            then ""
            else T.concat
              [ "<h2>Tags</h2>\n"
              , renderTagList now repoName (take 5 tags)
              , if length tags > 5
                then "<p class=\"more\"><a href=\"/repo/" <> esc repoName <> "/tags\">all tags...</a></p>\n"
                else ""
              ]
          ]
    in renderPage (repoName <> " - Summary") breadcrumbs [body]

-- | Render shortlog (compact patch list)
renderShortLog :: UTCTime -> Text -> [PatchSummary] -> Text
renderShortLog now repoName patches =
    let breadcrumbs = repoBreadcrumb repoName <> " / shortlog"
        body = T.concat
          [ "<h1>", esc repoName, " - Shortlog</h1>\n"
          , repoNavBar repoName "shortlog"
          , if null patches
            then "<p class=\"empty\">No patches.</p>\n"
            else renderShortLogTable now repoName patches
          ]
    in renderPage (repoName <> " - Shortlog") breadcrumbs [body]

renderShortLogTable :: UTCTime -> Text -> [PatchSummary] -> Text
renderShortLogTable now repoName patches = T.concat
    [ "<table class=\"shortlog\">\n"
    , "<thead><tr>"
    , "<th>Date</th><th>Author</th><th>Description</th><th></th>"
    , "</tr></thead>\n<tbody>\n"
    , T.concat (map (renderShortLogRow now repoName) patches)
    , "</tbody></table>\n"
    ]

renderShortLogRow :: UTCTime -> Text -> PatchSummary -> Text
renderShortLogRow now repoName ps = T.concat
    [ "<tr", if psIsTag ps then " class=\"tag-row\"" else "", ">\n"
    , "<td class=\"date\">", esc (relativeDate now (psDate ps)), "</td>\n"
    , "<td class=\"author\">", esc (shortAuthor (psAuthor ps)), "</td>\n"
    , "<td class=\"subject\">"
    , "<a href=\"/repo/", esc repoName, "/patch/", esc (psHash ps), "\">"
    , if psIsTag ps
      then "<span class=\"tag-badge\">TAG</span> "
      else ""
    , esc (psName ps), "</a>"
    , "</td>\n"
    , "<td class=\"actions\">"
    , "<a href=\"/repo/", esc repoName, "/patch/", esc (psHash ps), "\">diff</a>"
    , "</td>\n"
    , "</tr>\n"
    ]

-- | Render full log (detailed patch list)
renderFullLog :: UTCTime -> Text -> [PatchSummary] -> Text
renderFullLog now repoName patches =
    let breadcrumbs = repoBreadcrumb repoName <> " / log"
        body = T.concat
          [ "<h1>", esc repoName, " - Log</h1>\n"
          , repoNavBar repoName "log"
          , if null patches
            then "<p class=\"empty\">No patches.</p>\n"
            else T.concat (map (renderFullLogEntry now repoName) patches)
          ]
    in renderPage (repoName <> " - Log") breadcrumbs [body]

renderFullLogEntry :: UTCTime -> Text -> PatchSummary -> Text
renderFullLogEntry now repoName ps = T.concat
    [ "<div class=\"log-entry", if psIsTag ps then " tag-entry" else "", "\">\n"
    , "<div class=\"log-header\">\n"
    , "<span class=\"log-name\">"
    , "<a href=\"/repo/", esc repoName, "/patch/", esc (psHash ps), "\">"
    , if psIsTag ps then "<span class=\"tag-badge\">TAG</span> " else ""
    , esc (psName ps)
    , "</a></span>\n"
    , "<span class=\"log-date\">", esc (relativeDate now (psDate ps)), "</span>\n"
    , "</div>\n"
    , "<div class=\"log-meta\">\n"
    , "<span class=\"log-author\">", esc (psAuthor ps), "</span>\n"
    , "</div>\n"
    , if T.null (T.strip (psLog ps))
      then ""
      else "<div class=\"log-body\"><pre>" <> esc (T.strip (psLog ps)) <> "</pre></div>\n"
    , if T.null (T.strip (psSummary ps))
      then ""
      else "<div class=\"log-summary\"><pre>" <> esc (T.strip (psSummary ps)) <> "</pre></div>\n"
    , "</div>\n"
    ]

-- | Render a single patch detail with diff
renderPatchDetail :: UTCTime -> Text -> PatchSummary -> Text
renderPatchDetail now repoName ps =
    let breadcrumbs = repoBreadcrumb repoName
                   <> " / <a href=\"/repo/" <> esc repoName <> "/shortlog\">shortlog</a>"
                   <> " / " <> esc (T.take 12 (psHash ps)) <> "..."
        body = T.concat
          [ "<div class=\"patch-detail\">\n"
          , "<h1>"
          , if psIsTag ps then "<span class=\"tag-badge\">TAG</span> " else ""
          , esc (psName ps)
          , "</h1>\n"
          , "<table class=\"patch-meta\">\n"
          , "<tr><th>Author</th><td>", esc (psAuthor ps), "</td></tr>\n"
          , "<tr><th>Date</th><td>", esc (relativeDate now (psDate ps)), "</td></tr>\n"
          , "<tr><th>Hash</th><td class=\"hash\">", esc (psHash ps), "</td></tr>\n"
          , "</table>\n"
          , if T.null (T.strip (psLog ps))
            then ""
            else "<div class=\"patch-log\"><h2>Description</h2><pre>" <> esc (T.strip (psLog ps)) <> "</pre></div>\n"
          , if T.null (T.strip (psSummary ps))
            then ""
            else "<div class=\"patch-summary\"><h2>Summary</h2><pre>" <> esc (T.strip (psSummary ps)) <> "</pre></div>\n"
          , "<div class=\"patch-diff\">\n"
          , "<h2>Diff</h2>\n"
          , "<pre class=\"diff\">", highlightDiff (psDiff ps), "</pre>\n"
          , "</div>\n"
          , "</div>\n"
          ]
    in renderPage (psName ps <> " - " <> repoName) breadcrumbs [body]

-- | Render tags list
renderTags :: UTCTime -> Text -> [PatchSummary] -> Text
renderTags now repoName tags =
    let breadcrumbs = repoBreadcrumb repoName <> " / tags"
        body = T.concat
          [ "<h1>", esc repoName, " - Tags</h1>\n"
          , repoNavBar repoName "tags"
          , if null tags
            then "<p class=\"empty\">No tags found.</p>\n"
            else renderTagList now repoName tags
          ]
    in renderPage (repoName <> " - Tags") breadcrumbs [body]

renderTagList :: UTCTime -> Text -> [PatchSummary] -> Text
renderTagList now repoName tags = T.concat
    [ "<table class=\"tag-list\">\n"
    , "<thead><tr><th>Tag</th><th>Date</th><th>Author</th><th></th></tr></thead>\n"
    , "<tbody>\n"
    , T.concat (map (renderTagRow now repoName) tags)
    , "</tbody></table>\n"
    ]

renderTagRow :: UTCTime -> Text -> PatchSummary -> Text
renderTagRow now repoName ps = T.concat
    [ "<tr>\n"
    , "<td class=\"tag-name\">"
    , "<a href=\"/repo/", esc repoName, "/patch/", esc (psHash ps), "\">"
    , esc (maybe (psName ps) id (psTagName ps))
    , "</a></td>\n"
    , "<td class=\"date\">", esc (relativeDate now (psDate ps)), "</td>\n"
    , "<td class=\"author\">", esc (shortAuthor (psAuthor ps)), "</td>\n"
    , "<td class=\"actions\">"
    , "<a href=\"/repo/", esc repoName, "/patch/", esc (psHash ps), "\">details</a>"
    , "</td>\n"
    , "</tr>\n"
    ]

-- | Render repository tree (directory listing)
renderTree :: Text -> Text -> [TreeEntry] -> Text
renderTree repoName subPath entries =
    let pathSuffix = if T.null subPath then "" else " / " <> subPath
        breadcrumbs = repoBreadcrumb repoName <> " / tree" <> pathSuffix
        body = T.concat
          [ "<h1>", esc repoName, " - ", esc (if T.null subPath then "/" else subPath), "</h1>\n"
          , repoNavBar repoName "tree"
          , renderTreeBreadcrumb repoName subPath
          , renderTreeTable repoName subPath entries
          ]
    in renderPage (repoName <> " - Tree") breadcrumbs [body]

renderTreeBreadcrumb :: Text -> Text -> Text
renderTreeBreadcrumb repoName subPath =
    let parts = if T.null subPath then [] else T.splitOn "/" subPath
        buildCrumbs _ [] = ""
        buildCrumbs acc (p:ps) =
          let newAcc = if T.null acc then p else acc <> "/" <> p
          in " / <a href=\"/repo/" <> esc repoName <> "/tree/" <> esc newAcc <> "\">"
             <> esc p <> "</a>" <> buildCrumbs newAcc ps
    in "<div class=\"tree-path\">"
       <> "<a href=\"/repo/" <> esc repoName <> "/tree\">[root]</a>"
       <> buildCrumbs "" parts
       <> "</div>\n"

renderTreeTable :: Text -> Text -> [TreeEntry] -> Text
renderTreeTable repoName subPath entries = T.concat
    [ "<table class=\"tree-list\">\n"
    , "<thead><tr><th></th><th>Name</th><th>Size</th></tr></thead>\n"
    , "<tbody>\n"
    , if not (T.null subPath)
      then let parent = case T.breakOnEnd "/" subPath of
                 ("", _)  -> ""
                 (pre, _) -> T.dropEnd 1 pre
               parentHref = "/repo/" <> esc repoName <> "/tree"
                          <> (if T.null parent then "" else "/" <> esc parent)
           in "<tr><td class=\"tree-icon\">\xf0\x9f\x93\x81</td>"
              <> "<td class=\"tree-name\"><a href=\"" <> parentHref <> "\">..</a></td>"
              <> "<td></td></tr>\n"
      else ""
    , T.concat (map (renderTreeRow repoName subPath) entries)
    , "</tbody></table>\n"
    ]

renderTreeRow :: Text -> Text -> TreeEntry -> Text
renderTreeRow repoName subPath entry =
    let name = teName entry
        entryPath = if T.null subPath then name else subPath <> "/" <> name
        (icon, href) = if teIsDir entry
          then ("\xf0\x9f\x93\x81", "/repo/" <> esc repoName <> "/tree/" <> esc entryPath)
          else ("\xf0\x9f\x93\x84", "/repo/" <> esc repoName <> "/blob/" <> esc entryPath)
        sizeText = if teIsDir entry then "" else formatSize (teSize entry)
    in T.concat
        [ "<tr>"
        , "<td class=\"tree-icon\">", icon, "</td>"
        , "<td class=\"tree-name\"><a href=\"", href, "\">", esc name
        , if teIsDir entry then "/" else ""
        , "</a></td>"
        , "<td class=\"tree-size\">", sizeText, "</td>"
        , "</tr>\n"
        ]

formatSize :: Int -> Text
formatSize n
    | n < 1024       = T.pack (show n) <> " B"
    | n < 1048576    = T.pack (show (n `div` 1024)) <> " KiB"
    | otherwise      = T.pack (show (n `div` 1048576)) <> " MiB"

-- | Render blob (file contents)
renderBlob :: Text -> Text -> Text -> Text
renderBlob repoName subPath content =
    let breadcrumbs = repoBreadcrumb repoName <> " / blob / " <> esc subPath
        body = T.concat
          [ "<h1>", esc repoName, " - ", esc subPath, "</h1>\n"
          , repoNavBar repoName "tree"
          , renderBlobBreadcrumb repoName subPath
          , "<div class=\"blob-content\"><pre><code>"
          , esc content
          , "</code></pre></div>\n"
          ]
    in renderPage (repoName <> " - " <> subPath) breadcrumbs [body]

renderBlobBreadcrumb :: Text -> Text -> Text
renderBlobBreadcrumb repoName subPath =
    let parts = T.splitOn "/" subPath
        dirParts = init parts
        fileName = last parts
        buildCrumbs _ [] = ""
        buildCrumbs acc (p:ps) =
          let newAcc = if T.null acc then p else acc <> "/" <> p
          in " / <a href=\"/repo/" <> esc repoName <> "/tree/" <> esc newAcc <> "\">"
             <> esc p <> "</a>" <> buildCrumbs newAcc ps
    in "<div class=\"tree-path\">"
       <> "<a href=\"/repo/" <> esc repoName <> "/tree\">[root]</a>"
       <> buildCrumbs "" dirParts
       <> " / " <> esc fileName
       <> "</div>\n"

-- | Render 404 page
render404 :: Text -> Text
render404 msg =
    renderPage "Not Found" "" ["<h1>404 - Not Found</h1>\n<p>" <> esc msg <> "</p>\n"]

-- Helpers

repoBreadcrumb :: Text -> Text
repoBreadcrumb repoName =
    " / <a href=\"/repo/" <> esc repoName <> "/summary\">" <> esc repoName <> "</a>"

repoNavBar :: Text -> Text -> Text
repoNavBar repoName active = T.concat
    [ "<div class=\"repo-nav\">\n"
    , navLink' repoName "summary" "summary" active
    , navLink' repoName "shortlog" "shortlog" active
    , navLink' repoName "log" "log" active
    , navLink' repoName "tree" "tree" active
    , navLink' repoName "tags" "tags" active
    , "</div>\n"
    ]

navLink' :: Text -> Text -> Text -> Text -> Text
navLink' repoName path label active =
    if path == active
    then "<a href=\"/repo/" <> esc repoName <> "/" <> path <> "\" class=\"active\">" <> label <> "</a>\n"
    else "<a href=\"/repo/" <> esc repoName <> "/" <> path <> "\">" <> label <> "</a>\n"

-- | HTML-escape text.
--   Uses formally verified implementation from Coq/Rocq.
esc :: Text -> Text
esc = T.pack . HtmlPure.esc . T.unpack

-- | Highlight diff output: added lines in green, removed lines in red,
--   hunk headers in blue.
highlightDiff :: Text -> Text
highlightDiff = T.concat . map highlightLine . T.lines
  where
    highlightLine l
      | T.isPrefixOf "+" l  = "<span class=\"diff-add\">" <> esc l <> "</span>\n"
      | T.isPrefixOf "-" l  = "<span class=\"diff-del\">" <> esc l <> "</span>\n"
      | T.isPrefixOf "@@" l = "<span class=\"diff-hunk\">" <> esc l <> "</span>\n"
      | otherwise            = esc l <> "\n"

-- | Format a darcs date (YYYYMMDDHHMMSS) as relative time or absolute date.
--   Shows relative time (e.g. "5 minutes ago") if within 4 weeks,
--   otherwise shows the date as YYYY-MM-DD HH:MM:SS.
relativeDate :: UTCTime -> Text -> Text
relativeDate now dateStr =
    case parseDarcsDate (T.unpack dateStr) of
      Nothing -> dateStr  -- unparseable, return as-is
      Just t  ->
        let secs = floor (diffUTCTime now t) :: Int
        in if secs < 0 then formatAbsolute dateStr
           else if secs < 60 then pluralize secs "second"
           else if secs < 3600 then pluralize (secs `div` 60) "minute"
           else if secs < 86400 then pluralize (secs `div` 3600) "hour"
           else if secs < 604800 then pluralize (secs `div` 86400) "day"
           else if secs < 2419200 then pluralize (secs `div` 604800) "week"
           else formatAbsolute dateStr
  where
    pluralize n unit =
      T.pack (show n) <> " " <> T.pack unit <> if n == 1 then " ago" else "s ago"

-- | Parse darcs YYYYMMDDHHMMSS format to UTCTime
parseDarcsDate :: String -> Maybe UTCTime
parseDarcsDate = parseTimeM False defaultTimeLocale "%Y%m%d%H%M%S"

-- | Format a darcs date string as YYYY-MM-DD HH:MM:SS
formatAbsolute :: Text -> Text
formatAbsolute d =
    let s = T.unpack d
    in if length s >= 14
       then T.pack $ take 4 s ++ "-" ++ take 2 (drop 4 s) ++ "-" ++ take 2 (drop 6 s)
                   ++ " " ++ take 2 (drop 8 s) ++ ":" ++ take 2 (drop 10 s) ++ ":" ++ take 2 (drop 12 s)
       else d

-- | Shorten author to just the name part (before email)
shortAuthor :: Text -> Text
shortAuthor a =
    let trimmed = T.strip a
    in case T.breakOn "<" trimmed of
         (name, rest)
           | T.null rest -> trimmed
           | otherwise   -> T.strip name