Add tree view to navigation and polish the date view.

Authorfritjof@alokat.org
Date3 weeks ago
Hash53ab61013f3768bf02ab2fb2763023d16998afad

Summary

M ./darcsweb.cabal +1
M ./src/DarcsWeb/Darcs.hs -2 +45
M ./src/DarcsWeb/Html.hs -53 +177
M ./src/DarcsWeb/Types.hs +8
M ./src/Main.hs -7 +60
M ./static/style.css +57

Diff

patch 53ab61013f3768bf02ab2fb2763023d16998afad
Author: fritjof@alokat.org
Date:   Wed Mar 11 20:15:19 UTC 2026
  * Add tree view to navigation and polish the date view.
hunk ./darcsweb.cabal 32
+                  , time >= 1.9 && < 2
hunk ./src/DarcsWeb/Darcs.hs 12
+  , getRepoTree
+  , getRepoBlob
hunk ./src/DarcsWeb/Darcs.hs 19
-import           Data.List (sortBy)
+import           Data.List (isPrefixOf, partition, sortBy)
hunk ./src/DarcsWeb/Darcs.hs 21
-import           System.Directory (listDirectory, doesDirectoryExist, doesFileExist)
+import           System.Directory (listDirectory, doesDirectoryExist, doesFileExist,
+                                   getFileSize)
hunk ./src/DarcsWeb/Darcs.hs 151
+-- | List entries in a directory within a repository's working tree.
+--   The subPath is relative to the repo root. Returns Nothing if the path
+--   is not a directory or doesn't exist.
+getRepoTree :: FilePath -> FilePath -> IO (Maybe [TreeEntry])
+getRepoTree repoPath subPath = do
+    let fullDir = if null subPath then repoPath else repoPath </> subPath
+    isDir <- doesDirectoryExist fullDir
+    if not isDir
+      then return Nothing
+      else do
+        entries <- listDirectory fullDir
+        let visible = filter (\e -> e /= "_darcs" && not ("." `isPrefixOf` e)) entries
+        items <- mapM (mkEntry fullDir) (sortBy compare visible)
+        -- Sort: directories first, then files, alphabetically within each group
+        let (dirs, files) = partition teIsDir items
+        return (Just (dirs ++ files))
+  where
+    mkEntry dir name = do
+      let path = dir </> name
+      isDir <- doesDirectoryExist path
+      size <- if isDir then return 0
+              else fromIntegral <$> getFileSize path
+      return TreeEntry
+        { teName  = T.pack name
+        , teIsDir = isDir
+        , teSize  = size
+        }
+
+-- | Read a file's contents from the repository working tree.
+--   Returns Nothing if the file doesn't exist or is a directory.
+getRepoBlob :: FilePath -> FilePath -> IO (Maybe Text)
+getRepoBlob repoPath subPath = do
+    let fullPath = repoPath </> subPath
+    exists <- doesFileExist fullPath
+    if not exists
+      then return Nothing
+      else do
+        txt <- T.pack <$> Prelude.readFile fullPath
+        return (Just txt)
+
hunk ./src/DarcsWeb/Html.hs 10
+  , renderTree
+  , renderBlob
hunk ./src/DarcsWeb/Html.hs 18
+import           Data.Time.Clock (UTCTime, diffUTCTime)
+import           Data.Time.Format (parseTimeM, defaultTimeLocale)
hunk ./src/DarcsWeb/Html.hs 47
-renderRepoList :: Text -> [RepoInfo] -> Text
-renderRepoList siteTitle repos =
+renderRepoList :: UTCTime -> Text -> [RepoInfo] -> Text
+renderRepoList now siteTitle repos =
hunk ./src/DarcsWeb/Html.hs 53
-            else renderRepoTable repos
+            else renderRepoTable now repos
hunk ./src/DarcsWeb/Html.hs 57
-renderRepoTable :: [RepoInfo] -> Text
-renderRepoTable repos = T.concat
+renderRepoTable :: UTCTime -> [RepoInfo] -> Text
+renderRepoTable now repos = T.concat
hunk ./src/DarcsWeb/Html.hs 65
-    , T.concat (map renderRepoRow repos)
+    , T.concat (map (renderRepoRow now) repos)
hunk ./src/DarcsWeb/Html.hs 69
-renderRepoRow :: RepoInfo -> Text
-renderRepoRow ri = T.concat
+renderRepoRow :: UTCTime -> RepoInfo -> Text
+renderRepoRow now ri = T.concat
hunk ./src/DarcsWeb/Html.hs 74
-    , "<td>", esc (riLastChange ri), "</td>\n"
+    , "<td>", esc (relativeDate now (riLastChange ri)), "</td>\n"
hunk ./src/DarcsWeb/Html.hs 84
-renderRepoSummary :: Text -> RepoInfo -> [PatchSummary] -> [PatchSummary] -> Text
-renderRepoSummary repoName ri recentPatches tags =
+renderRepoSummary :: UTCTime -> Text -> RepoInfo -> [PatchSummary] -> [PatchSummary] -> Text
+renderRepoSummary now repoName ri recentPatches tags =
hunk ./src/DarcsWeb/Html.hs 92
-          , "<div class=\"repo-nav\">\n"
-          , navLink repoName "summary" "summary"
-          , navLink repoName "shortlog" "shortlog"
-          , navLink repoName "log" "log"
-          , navLink repoName "tags" "tags"
-          , "</div>\n"
+          , repoNavBar repoName "summary"
hunk ./src/DarcsWeb/Html.hs 97
-            else renderShortLogTable repoName (take 10 recentPatches)
+            else renderShortLogTable now repoName (take 10 recentPatches)
hunk ./src/DarcsWeb/Html.hs 106
-              , renderTagList repoName (take 5 tags)
+              , renderTagList now repoName (take 5 tags)
hunk ./src/DarcsWeb/Html.hs 115
-renderShortLog :: Text -> [PatchSummary] -> Text
-renderShortLog repoName patches =
+renderShortLog :: UTCTime -> Text -> [PatchSummary] -> Text
+renderShortLog now repoName patches =
hunk ./src/DarcsWeb/Html.hs 123
-            else renderShortLogTable repoName patches
+            else renderShortLogTable now repoName patches
hunk ./src/DarcsWeb/Html.hs 127
-renderShortLogTable :: Text -> [PatchSummary] -> Text
-renderShortLogTable repoName patches = T.concat
+renderShortLogTable :: UTCTime -> Text -> [PatchSummary] -> Text
+renderShortLogTable now repoName patches = T.concat
hunk ./src/DarcsWeb/Html.hs 133
-    , T.concat (map (renderShortLogRow repoName) patches)
+    , T.concat (map (renderShortLogRow now repoName) patches)
hunk ./src/DarcsWeb/Html.hs 137
-renderShortLogRow :: Text -> PatchSummary -> Text
-renderShortLogRow repoName ps = T.concat
+renderShortLogRow :: UTCTime -> Text -> PatchSummary -> Text
+renderShortLogRow now repoName ps = T.concat
hunk ./src/DarcsWeb/Html.hs 140
-    , "<td class=\"date\">", esc (shortDate (psDate ps)), "</td>\n"
+    , "<td class=\"date\">", esc (relativeDate now (psDate ps)), "</td>\n"
hunk ./src/DarcsWeb/Html.hs 156
-renderFullLog :: Text -> [PatchSummary] -> Text
-renderFullLog repoName patches =
+renderFullLog :: UTCTime -> Text -> [PatchSummary] -> Text
+renderFullLog now repoName patches =
hunk ./src/DarcsWeb/Html.hs 164
-            else T.concat (map (renderFullLogEntry repoName) patches)
+            else T.concat (map (renderFullLogEntry now repoName) patches)
hunk ./src/DarcsWeb/Html.hs 168
-renderFullLogEntry :: Text -> PatchSummary -> Text
-renderFullLogEntry repoName ps = T.concat
+renderFullLogEntry :: UTCTime -> Text -> PatchSummary -> Text
+renderFullLogEntry now repoName ps = T.concat
hunk ./src/DarcsWeb/Html.hs 177
-    , "<span class=\"log-date\">", esc (psDate ps), "</span>\n"
+    , "<span class=\"log-date\">", esc (relativeDate now (psDate ps)), "</span>\n"
hunk ./src/DarcsWeb/Html.hs 192
-renderPatchDetail :: Text -> PatchSummary -> Text
-renderPatchDetail repoName ps =
+renderPatchDetail :: UTCTime -> Text -> PatchSummary -> Text
+renderPatchDetail now repoName ps =
hunk ./src/DarcsWeb/Html.hs 205
-          , "<tr><th>Date</th><td>", esc (psDate ps), "</td></tr>\n"
+          , "<tr><th>Date</th><td>", esc (relativeDate now (psDate ps)), "</td></tr>\n"
hunk ./src/DarcsWeb/Html.hs 223
-renderTags :: Text -> [PatchSummary] -> Text
-renderTags repoName tags =
+renderTags :: UTCTime -> Text -> [PatchSummary] -> Text
+renderTags now repoName tags =
hunk ./src/DarcsWeb/Html.hs 231
-            else renderTagList repoName tags
+            else renderTagList now repoName tags
hunk ./src/DarcsWeb/Html.hs 235
-renderTagList :: Text -> [PatchSummary] -> Text
-renderTagList repoName tags = T.concat
+renderTagList :: UTCTime -> Text -> [PatchSummary] -> Text
+renderTagList now repoName tags = T.concat
hunk ./src/DarcsWeb/Html.hs 240
-    , T.concat (map (renderTagRow repoName) tags)
+    , T.concat (map (renderTagRow now repoName) tags)
hunk ./src/DarcsWeb/Html.hs 244
-renderTagRow :: Text -> PatchSummary -> Text
-renderTagRow repoName ps = T.concat
+renderTagRow :: UTCTime -> Text -> PatchSummary -> Text
+renderTagRow now repoName ps = T.concat
hunk ./src/DarcsWeb/Html.hs 251
-    , "<td class=\"date\">", esc (shortDate (psDate ps)), "</td>\n"
+    , "<td class=\"date\">", esc (relativeDate now (psDate ps)), "</td>\n"
hunk ./src/DarcsWeb/Html.hs 259
+-- | 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"
+
hunk ./src/DarcsWeb/Html.hs 375
+    , navLink' repoName "tree" "tree" active
hunk ./src/DarcsWeb/Html.hs 380
-navLink :: Text -> Text -> Text -> Text
-navLink repoName path label =
-    "<a href=\"/repo/" <> esc repoName <> "/" <> path <> "\">" <> label <> "</a>\n"
-
hunk ./src/DarcsWeb/Html.hs 397
--- | Shorten a date string to just the date part
-shortDate :: Text -> Text
-shortDate d = T.take 19 d
+-- | 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
hunk ./src/DarcsWeb/Types.hs 4
+  , TreeEntry(..)
hunk ./src/DarcsWeb/Types.hs 31
+  } deriving (Show)
+
+-- | An entry in the repository file tree
+data TreeEntry = TreeEntry
+  { teName   :: !Text     -- ^ File or directory name
+  , teIsDir  :: !Bool     -- ^ True if directory
+  , teSize   :: !Int      -- ^ File size in bytes (0 for directories)
hunk ./src/Main.hs 17
+import           Data.Time.Clock (getCurrentTime)
hunk ./src/Main.hs 192
+        now <- liftIO getCurrentTime
hunk ./src/Main.hs 194
-        html $ TL.fromStrict $ renderRepoList (cfgTitle cfg) repos
+        html $ TL.fromStrict $ renderRepoList now (cfgTitle cfg) repos
hunk ./src/Main.hs 200
+            now <- liftIO getCurrentTime
hunk ./src/Main.hs 210
-                html $ TL.fromStrict $ renderRepoSummary name ri (take 10 patches) tags
+                html $ TL.fromStrict $ renderRepoSummary now name ri (take 10 patches) tags
hunk ./src/Main.hs 216
+            now <- liftIO getCurrentTime
hunk ./src/Main.hs 218
-            html $ TL.fromStrict $ renderShortLog name patches
+            html $ TL.fromStrict $ renderShortLog now name patches
hunk ./src/Main.hs 224
+            now <- liftIO getCurrentTime
hunk ./src/Main.hs 226
-            html $ TL.fromStrict $ renderFullLog name patches
+            html $ TL.fromStrict $ renderFullLog now name patches
hunk ./src/Main.hs 232
+            now <- liftIO getCurrentTime
hunk ./src/Main.hs 234
-            html $ TL.fromStrict $ renderTags name tags
+            html $ TL.fromStrict $ renderTags now name tags
+
+    -- Tree view (root)
+    get "/repo/:name/tree" $ do
+        name <- pathParam "name"
+        withRepo cfg name $ \repoPath -> do
+            mEntries <- liftIO $ getRepoTree repoPath ""
+            case mEntries of
+              Nothing -> do
+                status status404
+                html $ TL.fromStrict $ render404 "Tree not found."
+              Just entries ->
+                html $ TL.fromStrict $ renderTree name "" entries
+
+    -- Tree view (subdirectory)
+    get (regex "^/repo/([^/]+)/tree/(.+)$") $ do
+        name <- pathParam "1"
+        subPath <- pathParam "2"
+        withRepo cfg name $ \repoPath -> do
+            let sub = T.unpack subPath
+            mEntries <- liftIO $ getRepoTree repoPath sub
+            case mEntries of
+              Nothing -> do
+                status status404
+                html $ TL.fromStrict $ render404 "Directory not found."
+              Just entries ->
+                html $ TL.fromStrict $ renderTree name subPath entries
+
+    -- Blob view (file contents)
+    get (regex "^/repo/([^/]+)/blob/(.+)$") $ do
+        name <- pathParam "1"
+        subPath <- pathParam "2"
+        withRepo cfg name $ \repoPath -> do
+            let sub = T.unpack subPath
+            -- Reject paths with ".." to prevent directory traversal
+            if ".." `T.isInfixOf` subPath
+              then do
+                status status403
+                html $ TL.fromStrict $ render404 "Access denied."
+              else do
+                mContent <- liftIO $ getRepoBlob repoPath sub
+                case mContent of
+                  Nothing -> do
+                    status status404
+                    html $ TL.fromStrict $ render404 "File not found."
+                  Just content ->
+                    html $ TL.fromStrict $ renderBlob name subPath content
hunk ./src/Main.hs 292
-              Just ps ->
-                html $ TL.fromStrict $ renderPatchDetail name ps
+              Just ps -> do
+                now <- liftIO getCurrentTime
+                html $ TL.fromStrict $ renderPatchDetail now name ps
hunk ./static/style.css 395
+
+/* === Tree View === */
+.tree-path {
+  margin: 8px 0 12px 0;
+  font-size: 13px;
+}
+
+.tree-path a {
+  color: #0000cc;
+}
+
+.tree-list td.tree-icon {
+  width: 20px;
+  text-align: center;
+  padding: 3px 4px;
+}
+
+.tree-list td.tree-name {
+  padding: 3px 8px;
+}
+
+.tree-list td.tree-name a {
+  color: #000;
+}
+
+.tree-list td.tree-name a:hover {
+  color: #0000cc;
+}
+
+.tree-list td.tree-size {
+  text-align: right;
+  color: #666;
+  font-family: monospace;
+  font-size: 12px;
+  width: 80px;
+  white-space: nowrap;
+}
+
+/* === Blob View === */
+.blob-content {
+  margin-top: 12px;
+}
+
+.blob-content pre {
+  background: #fafaf6;
+  padding: 8px;
+  border: 1px solid #d9d8d1;
+  overflow-x: auto;
+  white-space: pre;
+  line-height: 1.4;
+  tab-size: 4;
+}
+
+.blob-content code {
+  font-family: monospace;
+  font-size: 12px;
+}