Initial commit of darcs web.

Authorfritjof@alokat.org
Date3 weeks ago
Hash1e8d2592dd56707f31b361cc43069cc06f818c9e

Summary

A ./.claude/
A ./.claude/settings.local.json
A ./LICENSE
A ./README.md
A ./darcsweb.cabal
A ./darcsweb.conf.example
A ./src/
A ./src/DarcsWeb/
A ./src/DarcsWeb/Config.hs
A ./src/DarcsWeb/Darcs.hs
A ./src/DarcsWeb/Html.hs
A ./src/DarcsWeb/Types.hs
A ./src/Main.hs
A ./stack.yaml
A ./stack.yaml.lock
A ./static/
A ./static/style.css

Diff

patch 1e8d2592dd56707f31b361cc43069cc06f818c9e
Author: fritjof@alokat.org
Date:   Wed Mar 11 19:41:54 UTC 2026
  * Initial commit of darcs web.
adddir ./.claude
addfile ./.claude/settings.local.json
hunk ./.claude/settings.local.json 1
+{
+  "permissions": {
+    "allow": [
+      "WebFetch(domain:hackage.haskell.org)",
+      "WebFetch(domain:github.com)",
+      "WebFetch(domain:hackage-content.haskell.org)",
+      "Bash(which ghc:*)",
+      "Read(//home/fritjof/.ghcup/**)",
+      "Bash(apt list:*)",
+      "Bash(which stack:*)",
+      "WebFetch(domain:www.stackage.org)"
+    ]
+  }
+}
addfile ./LICENSE
hunk ./LICENSE 1
+Copyright (c) 2026, darcsweb contributors
+
+This program is free software; you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation; either version 2 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
addfile ./README.md
hunk ./README.md 1
+# DarcsWeb
+
+A web interface for browsing [darcs](https://darcs.net) repositories, inspired
+by gitweb. Built in Haskell using the darcs library API directly (no CLI
+invocation) and the [Scotty](https://github.com/scotty-web/scotty) web
+framework. Pure server-side HTML and CSS, no client-side JavaScript.
+
+## Building
+
+DarcsWeb uses [Stack](https://docs.haskellstack.org/) as its build system.
+
+### Prerequisites
+
+Install Stack if you don't have it:
+
+```
+curl -sSL https://get.haskellstack.org/ | sh
+```
+
+Or via your package manager. A recent version of Stack is recommended (>= 2.9).
+If you have an older version, upgrade with:
+
+```
+stack upgrade
+```
+
+Stack will automatically download and manage the correct GHC version.
+
+### Build
+
+```
+stack build
+```
+
+On the first run this downloads GHC and all dependencies, which takes a while.
+Subsequent builds are fast.
+
+### Install
+
+To install the `darcsweb` binary into `~/.local/bin`:
+
+```
+stack install
+```
+
+## Configuration
+
+DarcsWeb is configured through a plain-text configuration file. The `-c` flag
+pointing to this file is required to start the application.
+
+Copy the example and adjust it:
+
+```
+cp darcsweb.conf.example /etc/darcsweb.conf
+```
+
+### Configuration keys
+
+| Key      | Description                                       | Default       |
+|----------|---------------------------------------------------|---------------|
+| `bind`   | IP address to bind to                             | `127.0.0.1`   |
+| `port`   | TCP port to listen on                             | `3000`        |
+| `repos`  | Directory containing darcs repositories           | `.`           |
+| `title`  | Site title shown in the page header               | `DarcsWeb`    |
+| `static` | Path to the static assets directory               | `static`      |
+
+The file format is one `key = value` pair per line. Blank lines and lines
+starting with `#` are ignored.
+
+Example configuration:
+
+```
+# /etc/darcsweb.conf
+bind = 0.0.0.0
+port = 8080
+repos = /srv/darcs
+title = My Repositories
+static = /usr/share/darcsweb/static
+```
+
+### Repository descriptions
+
+To show a description for a repository on the index page, create the file
+`_darcs/prefs/repo_description` inside the repository:
+
+```
+echo "My awesome project" > /srv/darcs/myrepo/_darcs/prefs/repo_description
+```
+
+## Running
+
+```
+darcsweb -c FILE [-d]
+```
+
+| Flag         | Description                              |
+|--------------|------------------------------------------|
+| `-c FILE`    | Path to the configuration file (required)|
+| `-d`         | Run as daemon                            |
+
+If `-c` is omitted, a usage message is printed and the process exits.
+
+### Foreground mode (default)
+
+Without `-d`, DarcsWeb runs in the foreground and logs to stdout:
+
+```
+stack run -- -c /etc/darcsweb.conf
+```
+
+Or, if installed:
+
+```
+darcsweb -c /etc/darcsweb.conf
+```
+
+### Daemon mode
+
+With `-d`, DarcsWeb forks into the background, detaches from the terminal, and
+logs to syslog (facility `daemon`):
+
+```
+darcsweb -c /etc/darcsweb.conf -d
+```
+
+Messages appear in the system log under the identifier `darcsweb`, e.g.:
+
+```
+journalctl -t darcsweb
+```
+
+## Pages
+
+| Route                          | Description                                  |
+|--------------------------------|----------------------------------------------|
+| `/`                            | Repository index -- lists all repositories   |
+| `/repo/:name/summary`         | Repository overview with recent patches and tags |
+| `/repo/:name/shortlog`        | Compact patch log (date, author, name)       |
+| `/repo/:name/log`             | Full patch log with descriptions and summaries |
+| `/repo/:name/tags`            | All tagged patches                           |
+| `/repo/:name/patch/:hash`     | Single patch detail with full diff           |
+
+## License
+
+GPL-2.0-or-later (same as darcs).
addfile ./darcsweb.cabal
hunk ./darcsweb.cabal 1
+cabal-version:      3.0
+name:               darcsweb
+version:            0.1.0.0
+synopsis:           A web interface for browsing darcs repositories
+license:            GPL-2.0-or-later
+license-file:       LICENSE
+author:             Fritjof
+build-type:         Simple
+
+executable darcsweb
+  default-language: Haskell2010
+  hs-source-dirs:   src
+  main-is:          Main.hs
+  other-modules:    DarcsWeb.Config
+                  , DarcsWeb.Darcs
+                  , DarcsWeb.Html
+                  , DarcsWeb.Types
+  ghc-options:      -Wall -threaded -rtsopts -with-rtsopts=-N
+  build-depends:    base >= 4.12 && < 5
+                  , darcs >= 2.18 && < 3
+                  , scotty >= 0.20 && < 1
+                  , warp >= 3.3 && < 4
+                  , text >= 1.2 && < 3
+                  , bytestring >= 0.10 && < 1
+                  , containers >= 0.6 && < 1
+                  , directory >= 1.3 && < 2
+                  , filepath >= 1.4 && < 2
+                  , wai >= 3.0 && < 4
+                  , http-types >= 0.12 && < 1
+                  , unix >= 2.7 && < 3
+                  , hsyslog >= 5.0 && < 6
addfile ./darcsweb.conf.example
hunk ./darcsweb.conf.example 1
+# DarcsWeb configuration file
+
+# IP address to bind to
+bind = 127.0.0.1
+
+# TCP port to listen on
+port = 3000
+
+# Directory containing darcs repositories (each subdirectory with _darcs/ is served)
+repos = /srv/darcs
+
+# Site title shown in the page header
+title = DarcsWeb
+
+# Path to the static assets directory (contains style.css)
+static = /usr/share/darcsweb/static
adddir ./src
adddir ./src/DarcsWeb
addfile ./src/DarcsWeb/Config.hs
hunk ./src/DarcsWeb/Config.hs 1
+{-# LANGUAGE OverloadedStrings #-}
+
+module DarcsWeb.Config
+  ( parseConfigFile
+  , CfgMap
+  , cfgLookup
+  , cfgLookupDefault
+  ) where
+
+import           Data.Char (isSpace)
+import           Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+
+type CfgMap = Map String String
+
+-- | Parse a simple key = value config file.
+--   Blank lines and lines starting with # are ignored.
+parseConfigFile :: FilePath -> IO CfgMap
+parseConfigFile path = do
+    contents <- readFile path
+    let ls = filter meaningful (lines contents)
+    return $ Map.fromList (map parseLine ls)
+  where
+    meaningful l =
+      let stripped = dropWhile isSpace l
+      in not (null stripped) && head stripped /= '#'
+
+    parseLine l =
+      case break (== '=') l of
+        (key, '=':val) -> (trim key, trim val)
+        (key, _)       -> (trim key, "")
+
+    trim = reverse . dropWhile isSpace . reverse . dropWhile isSpace
+
+cfgLookup :: String -> CfgMap -> Maybe String
+cfgLookup = Map.lookup
+
+cfgLookupDefault :: String -> String -> CfgMap -> String
+cfgLookupDefault key def m = maybe def id (Map.lookup key m)
addfile ./src/DarcsWeb/Darcs.hs
hunk ./src/DarcsWeb/Darcs.hs 1
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module DarcsWeb.Darcs
+  ( listRepos
+  , getRepoPatches
+  , getRepoPatch
+  , getRepoTags
+  , isDarcsRepo
+  ) where
+
+import qualified Data.Text as T
+import           Data.Text (Text)
+import           Data.List (sortBy)
+import           Data.Ord (comparing)
+import           System.Directory (listDirectory, doesDirectoryExist, doesFileExist)
+import           System.FilePath ((</>))
+import           Control.Exception (try, SomeException, evaluate)
+
+import           Darcs.Repository
+                   ( withRepositoryLocation
+                   , RepoJob(..)
+                   , readPatches
+                   )
+import           Darcs.Repository.Flags (UseCache(..))
+import           Darcs.Patch.Info
+                   ( PatchInfo
+                   , piName
+                   , piAuthor
+                   , piDateString
+                   , piLog
+                   , piTag
+                   , isTag
+                   , makePatchname
+                   )
+import           Darcs.Patch.PatchInfoAnd (PatchInfoAnd, info, hopefullyM)
+import           Darcs.Patch.Set (patchSet2RL)
+import           Darcs.Patch.Witnesses.Ordered (mapRL)
+import           Darcs.Patch.Show (ShowPatchBasic(..), ShowPatch(..), ShowPatchFor(..))
+import           Darcs.Patch.RepoPatch (RepoPatch)
+import           Darcs.Util.Printer (renderString)
+import           Darcs.Util.Hash (sha1Show)
+import qualified Data.ByteString.Char8 as BC
+
+import           DarcsWeb.Types
+
+-- | Check if a directory is a darcs repository
+isDarcsRepo :: FilePath -> IO Bool
+isDarcsRepo path = doesDirectoryExist (path </> "_darcs")
+
+-- | List all darcs repositories in a directory
+listRepos :: FilePath -> IO [RepoInfo]
+listRepos baseDir = do
+    entries <- listDirectory baseDir
+    repos <- mapM (checkRepo baseDir) entries
+    return $ sortBy (comparing riName) (concat repos)
+
+checkRepo :: FilePath -> String -> IO [RepoInfo]
+checkRepo baseDir name = do
+    let fullPath = baseDir </> name
+    isDir <- doesDirectoryExist fullPath
+    if not isDir
+      then return []
+      else do
+        isRepo <- isDarcsRepo fullPath
+        if not isRepo
+          then return []
+          else do
+            desc <- readRepoDescription fullPath
+            mInfo <- getBasicRepoInfo fullPath
+            case mInfo of
+              Nothing -> return [RepoInfo
+                { riName        = T.pack name
+                , riPath        = fullPath
+                , riDescription = desc
+                , riLastChange  = ""
+                , riPatchCount  = 0
+                }]
+              Just (lastDate, count) -> return [RepoInfo
+                { riName        = T.pack name
+                , riPath        = fullPath
+                , riDescription = desc
+                , riLastChange  = lastDate
+                , riPatchCount  = count
+                }]
+
+readRepoDescription :: FilePath -> IO Text
+readRepoDescription path = do
+    let descFile = path </> "_darcs" </> "prefs" </> "repo_description"
+    exists <- doesFileExist descFile
+    if exists
+      then T.strip . T.pack <$> readFile descFile
+      else return ""
+
+getBasicRepoInfo :: FilePath -> IO (Maybe (Text, Int))
+getBasicRepoInfo repoPath = do
+    result <- try $ withRepositoryLocation YesUseCache repoPath $ RepoJob $ \repository -> do
+      patches <- readPatches repository
+      let infos = mapRL info (patchSet2RL patches)
+          count = length infos
+          lastDate = case infos of
+            (i:_) -> T.pack (piDateString i)
+            []    -> ""
+      return (lastDate, count)
+    case result of
+      Left (_ :: SomeException) -> return Nothing
+      Right val                 -> return (Just val)
+
+-- | Get all patches from a repository (metadata + summary only, no diff)
+getRepoPatches :: FilePath -> IO [PatchSummary]
+getRepoPatches repoPath = do
+    result <- try $ withRepositoryLocation YesUseCache repoPath $ RepoJob $ \repository -> do
+      patches <- readPatches repository
+      let patchRL = patchSet2RL patches
+          results = mapRL extractPatchListing patchRL
+      _ <- evaluate (length results)
+      return results
+    case result of
+      Left (_ :: SomeException) -> return []
+      Right val                 -> return val
+
+-- | Get a single patch by hash (with full diff)
+getRepoPatch :: FilePath -> Text -> IO (Maybe PatchSummary)
+getRepoPatch repoPath targetHash = do
+    result <- try $ withRepositoryLocation YesUseCache repoPath $ RepoJob $ \repository -> do
+      patches <- readPatches repository
+      let patchRL = patchSet2RL patches
+          allPatches = mapRL extractPatchFull patchRL
+          found = filter (\ps -> psHash ps == targetHash) allPatches
+      case found of
+        (p:_) -> do
+          _ <- evaluate (T.length (psDiff p))
+          return (Just p)
+        [] -> return Nothing
+    case result of
+      Left (_ :: SomeException) -> return Nothing
+      Right val                 -> return val
+
+-- | Get tags from a repository
+getRepoTags :: FilePath -> IO [PatchSummary]
+getRepoTags repoPath = do
+    allPatches <- getRepoPatches repoPath
+    return $ filter psIsTag allPatches
+
+-- | Extract patch for listing (no diff content, just metadata + summary)
+extractPatchListing :: RepoPatch p
+                    => PatchInfoAnd p wA wB -> PatchSummary
+extractPatchListing piap =
+    let pinfo = info piap
+        summaryText = case hopefullyM piap of
+          Just p  -> T.pack $ renderString $ summary p
+          Nothing -> ""
+    in patchInfoToSummary pinfo "" summaryText
+
+-- | Extract patch with full diff content
+extractPatchFull :: RepoPatch p
+                 => PatchInfoAnd p wA wB -> PatchSummary
+extractPatchFull piap =
+    let pinfo = info piap
+        (diffText, summaryText) = case hopefullyM piap of
+          Just p  -> ( T.pack $ renderString $ showPatch ForDisplay p
+                     , T.pack $ renderString $ summary p
+                     )
+          Nothing -> ("(patch content unavailable)", "")
+    in patchInfoToSummary pinfo diffText summaryText
+
+patchInfoToSummary :: PatchInfo -> Text -> Text -> PatchSummary
+patchInfoToSummary pinfo diffText summaryText = PatchSummary
+    { psName    = T.pack (piName pinfo)
+    , psAuthor  = T.pack (piAuthor pinfo)
+    , psDate    = T.pack (piDateString pinfo)
+    , psLog     = T.pack (unlines (piLog pinfo))
+    , psHash    = T.pack (BC.unpack (sha1Show (makePatchname pinfo)))
+    , psIsTag   = isTag pinfo
+    , psTagName = T.pack <$> piTag pinfo
+    , psDiff    = diffText
+    , psSummary = summaryText
+    }
addfile ./src/DarcsWeb/Html.hs
hunk ./src/DarcsWeb/Html.hs 1
+{-# LANGUAGE OverloadedStrings #-}
+
+module DarcsWeb.Html
+  ( renderPage
+  , renderRepoList
+  , renderShortLog
+  , renderFullLog
+  , renderPatchDetail
+  , renderTags
+  , renderRepoSummary
+  , render404
+  ) where
+
+import           Data.Text (Text)
+import qualified Data.Text as T
+
+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=\"/\">DarcsWeb</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 :: Text -> [RepoInfo] -> Text
+renderRepoList siteTitle repos =
+    let breadcrumbs = ""
+        body = T.concat
+          [ "<h1>", esc siteTitle, "</h1>\n"
+          , if null repos
+            then "<p class=\"empty\">No repositories found.</p>\n"
+            else renderRepoTable repos
+          ]
+    in renderPage siteTitle breadcrumbs [body]
+
+renderRepoTable :: [RepoInfo] -> Text
+renderRepoTable 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 repos)
+    , "</tbody></table>\n"
+    ]
+
+renderRepoRow :: RepoInfo -> Text
+renderRepoRow 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 (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 :: Text -> RepoInfo -> [PatchSummary] -> [PatchSummary] -> Text
+renderRepoSummary 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"
+          , "<div class=\"repo-nav\">\n"
+          , navLink repoName "summary" "summary"
+          , navLink repoName "shortlog" "shortlog"
+          , navLink repoName "log" "log"
+          , navLink repoName "tags" "tags"
+          , "</div>\n"
+          -- Recent activity
+          , "<h2>Recent Activity</h2>\n"
+          , if null recentPatches
+            then "<p class=\"empty\">No patches.</p>\n"
+            else renderShortLogTable 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 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 :: Text -> [PatchSummary] -> Text
+renderShortLog 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 repoName patches
+          ]
+    in renderPage (repoName <> " - Shortlog") breadcrumbs [body]
+
+renderShortLogTable :: Text -> [PatchSummary] -> Text
+renderShortLogTable 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 repoName) patches)
+    , "</tbody></table>\n"
+    ]
+
+renderShortLogRow :: Text -> PatchSummary -> Text
+renderShortLogRow repoName ps = T.concat
+    [ "<tr", if psIsTag ps then " class=\"tag-row\"" else "", ">\n"
+    , "<td class=\"date\">", esc (shortDate (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 :: Text -> [PatchSummary] -> Text
+renderFullLog 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 repoName) patches)
+          ]
+    in renderPage (repoName <> " - Log") breadcrumbs [body]
+
+renderFullLogEntry :: Text -> PatchSummary -> Text
+renderFullLogEntry 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 (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 :: Text -> PatchSummary -> Text
+renderPatchDetail 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 (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\">", esc (psDiff ps), "</pre>\n"
+          , "</div>\n"
+          , "</div>\n"
+          ]
+    in renderPage (psName ps <> " - " <> repoName) breadcrumbs [body]
+
+-- | Render tags list
+renderTags :: Text -> [PatchSummary] -> Text
+renderTags 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 repoName tags
+          ]
+    in renderPage (repoName <> " - Tags") breadcrumbs [body]
+
+renderTagList :: Text -> [PatchSummary] -> Text
+renderTagList 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 repoName) tags)
+    , "</tbody></table>\n"
+    ]
+
+renderTagRow :: Text -> PatchSummary -> Text
+renderTagRow 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 (shortDate (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 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 "tags" "tags" active
+    , "</div>\n"
+    ]
+
+navLink :: Text -> Text -> Text -> Text
+navLink repoName path label =
+    "<a href=\"/repo/" <> esc repoName <> "/" <> path <> "\">" <> label <> "</a>\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
+esc :: Text -> Text
+esc = T.concatMap escChar
+  where
+    escChar '<'  = "&lt;"
+    escChar '>'  = "&gt;"
+    escChar '&'  = "&amp;"
+    escChar '"'  = "&quot;"
+    escChar '\'' = "&#39;"
+    escChar c    = T.singleton c
+
+-- | Shorten a date string to just the date part
+shortDate :: Text -> Text
+shortDate d = T.take 19 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
addfile ./src/DarcsWeb/Types.hs
hunk ./src/DarcsWeb/Types.hs 1
+module DarcsWeb.Types
+  ( PatchSummary(..)
+  , RepoInfo(..)
+  , DarcsWebConfig(..)
+  , LogFunc
+  ) where
+
+import Data.Text (Text)
+
+-- | Summary information about a single patch
+data PatchSummary = PatchSummary
+  { psName        :: !Text
+  , psAuthor      :: !Text
+  , psDate        :: !Text
+  , psLog         :: !Text
+  , psHash        :: !Text
+  , psIsTag       :: !Bool
+  , psTagName     :: !(Maybe Text)
+  , psDiff        :: !Text    -- ^ Full patch diff content
+  , psSummary     :: !Text    -- ^ Short summary of changes
+  } deriving (Show)
+
+-- | Information about a repository
+data RepoInfo = RepoInfo
+  { riName        :: !Text    -- ^ Display name (directory basename)
+  , riPath        :: !FilePath -- ^ Absolute path on disk
+  , riDescription :: !Text    -- ^ From _darcs/prefs/repo_description or empty
+  , riLastChange  :: !Text    -- ^ Date of most recent patch
+  , riPatchCount  :: !Int
+  } deriving (Show)
+
+-- | Application configuration
+data DarcsWebConfig = DarcsWebConfig
+  { cfgPort       :: !Int
+  , cfgBind       :: !String    -- ^ IP address to bind to
+  , cfgRepoDir    :: !FilePath  -- ^ Directory containing darcs repos
+  , cfgTitle      :: !Text
+  , cfgStaticDir  :: !FilePath  -- ^ Directory for static assets
+  , cfgLog        :: !LogFunc   -- ^ Logging function
+  }
+
+-- | A logging function that takes a message string
+type LogFunc = String -> IO ()
+
+instance Show DarcsWebConfig where
+  show c = "DarcsWebConfig {bind=" ++ show (cfgBind c)
+        ++ ", port=" ++ show (cfgPort c)
+        ++ ", repos=" ++ show (cfgRepoDir c)
+        ++ ", title=" ++ show (cfgTitle c)
+        ++ ", static=" ++ show (cfgStaticDir c) ++ "}"
addfile ./src/Main.hs
hunk ./src/Main.hs 1
+{-# LANGUAGE ForeignFunctionInterface #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main (main) where
+
+import           Web.Scotty (scottyOpts, ScottyM, ActionM, get, notFound,
+                             pathParam, html, status, liftIO,
+                             Options(..), defaultOptions, file, setHeader,
+                             request, regex)
+import           Network.Wai.Handler.Warp (setPort, setHost, defaultSettings)
+import           Network.HTTP.Types.Status (status404, status403)
+import           Network.Wai (pathInfo)
+import           Data.String (fromString)
+
+import qualified Data.Text as T
+import qualified Data.Text.Lazy as TL
+import           System.Console.GetOpt
+import           System.Environment (getArgs, getProgName)
+import           System.Directory (makeAbsolute, canonicalizePath,
+                                   doesFileExist)
+import           System.FilePath ((</>), takeExtension)
+import           System.Exit (exitFailure, exitSuccess)
+import           System.IO (hPutStrLn, stderr, hFlush, stdout)
+import           System.Posix.Process (forkProcess, createSession)
+import           System.Posix.IO (dupTo, stdInput, stdOutput, stdError)
+import           System.Posix.Syslog (withSyslog, syslog, Facility(..),
+                                      Priority(..), Option(..))
+import           Foreign.C.String (withCStringLen, withCString, CString)
+import           Foreign.C.Types (CInt(..))
+import           System.Posix.Types (Fd(..))
+import           Data.List (isPrefixOf)
+
+import           DarcsWeb.Types
+import           DarcsWeb.Config
+import           DarcsWeb.Darcs
+import           DarcsWeb.Html
+
+-- Command-line options
+data Opts = Opts
+  { optConfigFile :: Maybe FilePath
+  , optDaemon     :: Bool
+  } deriving (Show)
+
+defaultOpts :: Opts
+defaultOpts = Opts
+  { optConfigFile = Nothing
+  , optDaemon     = False
+  }
+
+optDescr :: [OptDescr (Opts -> Opts)]
+optDescr =
+  [ Option "c" ["config"]
+      (ReqArg (\f o -> o { optConfigFile = Just f }) "FILE")
+      "configuration file (required)"
+  , Option "d" ["daemon"]
+      (NoArg (\o -> o { optDaemon = True }))
+      "run as daemon (log to syslog)"
+  ]
+
+parseOpts :: [String] -> IO Opts
+parseOpts argv =
+    case getOpt Permute optDescr argv of
+      (o, _, [])   -> return (foldl (flip id) defaultOpts o)
+      (_, _, errs) -> do
+        hPutStrLn stderr (concat errs)
+        usage
+
+usage :: IO a
+usage = do
+    prog <- getProgName
+    hPutStrLn stderr (usageInfo ("Usage: " ++ prog ++ " -c FILE [-d]") optDescr)
+    exitFailure
+
+main :: IO ()
+main = do
+    args <- getArgs
+    opts <- parseOpts args
+
+    configPath <- case optConfigFile opts of
+      Nothing -> usage
+      Just f  -> return f
+
+    cfgMap <- parseConfigFile configPath
+
+    let port      = read (cfgLookupDefault "port" "3000" cfgMap)
+        bind      = cfgLookupDefault "bind" "127.0.0.1" cfgMap
+        repoDir   = cfgLookupDefault "repos" "." cfgMap
+        title     = T.pack (cfgLookupDefault "title" "DarcsWeb" cfgMap)
+        staticDir = cfgLookupDefault "static" "static" cfgMap
+
+    absRepoDir   <- makeAbsolute repoDir >>= canonicalizePath
+    absStaticDir <- makeAbsolute staticDir >>= canonicalizePath
+
+    if optDaemon opts
+      then do
+        logStdout $ "Daemonizing (bind " ++ bind ++ ":" ++ show port ++ ")"
+        hFlush stdout
+        daemonize $ withSyslog "darcsweb" [LogPID] Daemon $ do
+          let logf = logSyslog
+          logf $ "Starting on " ++ bind ++ ":" ++ show port
+          logf $ "Serving repositories from: " ++ absRepoDir
+          let cfg = mkConfig port bind absRepoDir title absStaticDir logf
+          runServer cfg
+      else do
+        let logf = logStdout
+        logf $ "Starting on " ++ bind ++ ":" ++ show port
+        logf $ "Serving repositories from: " ++ absRepoDir
+        let cfg = mkConfig port bind absRepoDir title absStaticDir logf
+        runServer cfg
+
+mkConfig :: Int -> String -> FilePath -> T.Text -> FilePath -> LogFunc
+         -> DarcsWebConfig
+mkConfig port bind repoDir title staticDir logf = DarcsWebConfig
+    { cfgPort      = port
+    , cfgBind      = bind
+    , cfgRepoDir   = repoDir
+    , cfgTitle     = title
+    , cfgStaticDir = staticDir
+    , cfgLog       = logf
+    }
+
+runServer :: DarcsWebConfig -> IO ()
+runServer cfg = do
+    let warpSettings = setPort (cfgPort cfg)
+                     $ setHost (fromString (cfgBind cfg))
+                       defaultSettings
+        scottyConfig = defaultOptions { verbose = 0, settings = warpSettings }
+    scottyOpts scottyConfig (app cfg)
+
+-- | Log to stdout (foreground mode)
+logStdout :: String -> IO ()
+logStdout msg = putStrLn ("[darcsweb] " ++ msg)
+
+-- | Log to syslog (daemon mode). Must be called inside withSyslog.
+logSyslog :: String -> IO ()
+logSyslog msg = withCStringLen msg $ \cstr ->
+    syslog (Just Daemon) Info cstr
+
+-- | Double-fork daemonize
+daemonize :: IO () -> IO ()
+daemonize action = do
+    _ <- forkProcess $ do
+      _ <- createSession
+      _ <- forkProcess $ do
+        redirectToDevNull
+        action
+      exitSuccess
+    exitSuccess
+
+redirectToDevNull :: IO ()
+redirectToDevNull = do
+    nullFd <- openDevNull
+    _ <- dupTo nullFd stdInput
+    _ <- dupTo nullFd stdOutput
+    _ <- dupTo nullFd stdError
+    return ()
+
+-- Open /dev/null using Posix.openFd. Works across unix package versions
+-- by using the FFI directly.
+foreign import ccall "open" c_open :: CString -> CInt -> IO CInt
+
+openDevNull :: IO Fd
+openDevNull = withCString "/dev/null" $ \cstr -> do
+    fd <- c_open cstr 2 -- O_RDWR = 2
+    return (Fd fd)
+
+-- | Map file extension to MIME type for static assets
+mimeType :: String -> TL.Text
+mimeType ".css"  = "text/css; charset=utf-8"
+mimeType ".js"   = "application/javascript"
+mimeType ".png"  = "image/png"
+mimeType ".jpg"  = "image/jpeg"
+mimeType ".jpeg" = "image/jpeg"
+mimeType ".gif"  = "image/gif"
+mimeType ".svg"  = "image/svg+xml"
+mimeType ".ico"  = "image/x-icon"
+mimeType ".woff" = "font/woff"
+mimeType ".woff2" = "font/woff2"
+mimeType _       = "application/octet-stream"
+
+app :: DarcsWebConfig -> ScottyM ()
+app cfg = do
+    -- Serve static files strictly from the configured static directory.
+    -- The route only matches /static/*, so no other paths are served.
+    -- The resolved file path is canonicalized and verified to be inside
+    -- the static directory before serving.
+    get (regex "^/static/.*") $ serveStatic cfg
+
+    -- Index page: list all repositories
+    get "/" $ do
+        repos <- liftIO $ listRepos (cfgRepoDir cfg)
+        html $ TL.fromStrict $ renderRepoList (cfgTitle cfg) repos
+
+    -- Repository summary
+    get "/repo/:name/summary" $ do
+        name <- pathParam "name"
+        withRepo cfg name $ \repoPath -> do
+            repos <- liftIO $ listRepos (cfgRepoDir cfg)
+            let mRepoInfo = findRepo name repos
+            case mRepoInfo of
+              Nothing -> do
+                status status404
+                html $ TL.fromStrict $ render404 "Repository not found."
+              Just ri -> do
+                patches <- liftIO $ getRepoPatches repoPath
+                tags <- liftIO $ getRepoTags repoPath
+                html $ TL.fromStrict $ renderRepoSummary name ri (take 10 patches) tags
+
+    -- Shortlog
+    get "/repo/:name/shortlog" $ do
+        name <- pathParam "name"
+        withRepo cfg name $ \repoPath -> do
+            patches <- liftIO $ getRepoPatches repoPath
+            html $ TL.fromStrict $ renderShortLog name patches
+
+    -- Full log
+    get "/repo/:name/log" $ do
+        name <- pathParam "name"
+        withRepo cfg name $ \repoPath -> do
+            patches <- liftIO $ getRepoPatches repoPath
+            html $ TL.fromStrict $ renderFullLog name patches
+
+    -- Tags
+    get "/repo/:name/tags" $ do
+        name <- pathParam "name"
+        withRepo cfg name $ \repoPath -> do
+            tags <- liftIO $ getRepoTags repoPath
+            html $ TL.fromStrict $ renderTags name tags
+
+    -- Single patch detail (diff view)
+    get "/repo/:name/patch/:hash" $ do
+        name <- pathParam "name"
+        patchHash <- pathParam "hash"
+        withRepo cfg name $ \repoPath -> do
+            mPatch <- liftIO $ getRepoPatch repoPath patchHash
+            case mPatch of
+              Nothing -> do
+                status status404
+                html $ TL.fromStrict $ render404 "Patch not found."
+              Just ps ->
+                html $ TL.fromStrict $ renderPatchDetail name ps
+
+    -- 404 fallback
+    notFound $ do
+        status status404
+        html $ TL.fromStrict $ render404 "Page not found."
+
+-- | Serve a static file, strictly jailed to cfgStaticDir.
+--   Rejects any request whose canonicalized path escapes the static directory.
+serveStatic :: DarcsWebConfig -> ActionM ()
+serveStatic cfg = do
+    req <- request
+    -- Reconstruct the sub-path from the WAI pathInfo segments after "static"
+    let segments = pathInfo req
+        -- Drop the leading "static" segment
+        subSegments = drop 1 segments
+    -- Reject empty path, segments with "..", and any segment starting with "."
+    if null subSegments
+       || any (\s -> s == ".." || s == "." || T.null s) subSegments
+       || any (T.isPrefixOf ".") subSegments
+      then do
+        status status404
+        html $ TL.fromStrict $ render404 "Not found."
+      else do
+        let relPath = T.unpack (T.intercalate "/" subSegments)
+            candidate = cfgStaticDir cfg </> relPath
+        -- Canonicalize to resolve symlinks and verify jail
+        exists <- liftIO $ doesFileExist candidate
+        if not exists
+          then do
+            status status404
+            html $ TL.fromStrict $ render404 "Not found."
+          else do
+            canonical <- liftIO $ canonicalizePath candidate
+            let jailDir = addTrailingSlash (cfgStaticDir cfg)
+            if jailDir `isPrefixOf` canonical
+              then do
+                setHeader "Content-Type" (mimeType (takeExtension canonical))
+                file canonical
+              else do
+                status status403
+                html $ TL.fromStrict $ render404 "Access denied."
+
+-- | Validate repo name and resolve path, then run an action.
+--   Rejects names with path separators or "..".
+--   Canonicalizes the resolved path and verifies it is inside the repos directory.
+withRepo :: DarcsWebConfig -> T.Text -> (FilePath -> ActionM ()) -> ActionM ()
+withRepo cfg name action
+    | T.any (== '/') name || T.any (== '\\') name || ".." `T.isInfixOf` name
+    || T.isPrefixOf "." name = do
+        status status404
+        html $ TL.fromStrict $ render404 "Invalid repository name."
+    | otherwise = do
+        let candidate = cfgRepoDir cfg </> T.unpack name
+        isRepo <- liftIO $ isDarcsRepo candidate
+        if not isRepo
+          then do
+            status status404
+            html $ TL.fromStrict $ render404 ("Repository not found: " <> name)
+          else do
+            -- Canonicalize to defeat symlink escapes
+            canonical <- liftIO $ canonicalizePath candidate
+            let jailDir = addTrailingSlash (cfgRepoDir cfg)
+            if jailDir `isPrefixOf` canonical
+              then action canonical
+              else do
+                status status403
+                html $ TL.fromStrict $ render404 "Access denied."
+
+-- | Ensure a directory path ends with exactly one /
+addTrailingSlash :: FilePath -> FilePath
+addTrailingSlash [] = "/"
+addTrailingSlash p
+    | last p == '/' = p
+    | otherwise     = p ++ "/"
+
+findRepo :: T.Text -> [RepoInfo] -> Maybe RepoInfo
+findRepo name = foldr (\r acc -> if riName r == name then Just r else acc) Nothing
addfile ./stack.yaml
hunk ./stack.yaml 1
+resolver: lts-22.43
+
+packages:
+  - .
+
+extra-deps:
+  - darcs-2.18.5
+  - scotty-0.22
+  - hsyslog-5.0.2
+
+allow-newer: true
addfile ./stack.yaml.lock
hunk ./stack.yaml.lock 1
+# This file was autogenerated by Stack.
+# You should not edit this file by hand.
+# For more information, please see the documentation at:
+#   https://docs.haskellstack.org/en/stable/topics/lock_files
+
+packages:
+- completed:
+    hackage: darcs-2.18.5@sha256:895e25d78a27627a5a0445d3506ac8ccac73005125ed9f7e5fa4f9e4d5f02ea1,28394
+    pantry-tree:
+      sha256: 56e283b2f46de84c8ffd58048a48f266b853da107d4afec7e94cf06d36f36005
+      size: 58172
+  original:
+    hackage: darcs-2.18.5
+- completed:
+    hackage: scotty-0.22@sha256:b3c799b3c4896176342062c1140c290ffb9a8d81e6da2ea3e12f7a83cbda78d4,6013
+    pantry-tree:
+      sha256: ec781405d9d771ad8e0b0d6f350b4c4296104665668fc7263ba36839f674d835
+      size: 2138
+  original:
+    hackage: scotty-0.22
+- completed:
+    hackage: hsyslog-5.0.2@sha256:0b604c9f3d1bcbe7cd223b1b530110309ae01f2d7c57bc08ffc4fc31ad21324c,2491
+    pantry-tree:
+      sha256: 618ec0c6d6a3a4d6c7a093935481de5a9a9708b3548ef8eb34bbeb458dbd656b
+      size: 780
+  original:
+    hackage: hsyslog-5.0.2
+snapshots:
+- completed:
+    sha256: 08bd13ce621b41a8f5e51456b38d5b46d7783ce114a50ab604d6bbab0d002146
+    size: 720271
+    url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/22/43.yaml
+  original: lts-22.43
adddir ./static
addfile ./static/style.css
hunk ./static/style.css 1
+/* DarcsWeb - gitweb-inspired darcs repository browser */
+
+/* === Reset & Base === */
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: sans-serif;
+  font-size: 13px;
+  color: #000;
+  background: #fff;
+}
+
+a {
+  color: #0000cc;
+  text-decoration: none;
+}
+
+a:hover {
+  text-decoration: underline;
+}
+
+pre {
+  font-family: monospace;
+  font-size: 12px;
+}
+
+h1 {
+  font-size: 18px;
+  font-weight: bold;
+  margin-bottom: 8px;
+}
+
+h2 {
+  font-size: 14px;
+  font-weight: bold;
+  margin: 16px 0 8px 0;
+  padding-bottom: 4px;
+  border-bottom: 1px solid #d9d8d1;
+}
+
+/* === Page Layout === */
+.page-header {
+  background: #d9d8d1;
+  padding: 8px 12px;
+  font-size: 18px;
+  font-weight: bold;
+  border-bottom: 1px solid #808080;
+}
+
+.page-header a {
+  color: #000;
+  text-decoration: none;
+}
+
+.page-header a:hover {
+  text-decoration: underline;
+}
+
+.page-body {
+  padding: 12px;
+  min-height: 400px;
+}
+
+.page-footer {
+  background: #d9d8d1;
+  padding: 6px 12px;
+  border-top: 1px solid #808080;
+  font-size: 11px;
+  color: #555;
+  text-align: center;
+}
+
+.page-footer a {
+  color: #333;
+}
+
+/* === Repository Navigation === */
+.repo-nav {
+  margin: 8px 0 16px 0;
+  padding: 4px 0;
+  border-bottom: 1px solid #d9d8d1;
+}
+
+.repo-nav a {
+  display: inline-block;
+  padding: 4px 12px;
+  margin-right: 2px;
+  color: #0000cc;
+  background: #edece6;
+  border: 1px solid #d9d8d1;
+  border-bottom: none;
+  font-size: 12px;
+}
+
+.repo-nav a:hover {
+  background: #d9d8d1;
+  text-decoration: none;
+}
+
+.repo-nav a.active {
+  background: #fff;
+  border-bottom: 1px solid #fff;
+  font-weight: bold;
+  color: #000;
+}
+
+/* === Tables === */
+table {
+  width: 100%;
+  border-collapse: collapse;
+}
+
+thead th {
+  text-align: left;
+  padding: 4px 8px;
+  background: #edece6;
+  border-bottom: 1px solid #d9d8d1;
+  font-size: 12px;
+  font-weight: bold;
+  color: #333;
+}
+
+tbody tr {
+  border-bottom: 1px solid #eee;
+}
+
+tbody tr:hover {
+  background: #f6f5ee;
+}
+
+td {
+  padding: 4px 8px;
+  vertical-align: top;
+}
+
+td.num {
+  text-align: right;
+  font-family: monospace;
+}
+
+/* === Repository List === */
+.repo-list td.repo-name a {
+  font-weight: bold;
+}
+
+.repo-list td.actions {
+  white-space: nowrap;
+  font-size: 12px;
+}
+
+.repo-list td.actions a {
+  color: #555;
+}
+
+.repo-list td.actions a:hover {
+  color: #0000cc;
+}
+
+/* === Shortlog Table === */
+.shortlog td.date {
+  white-space: nowrap;
+  color: #666;
+  font-family: monospace;
+  font-size: 12px;
+  width: 150px;
+}
+
+.shortlog td.author {
+  white-space: nowrap;
+  font-style: italic;
+  color: #006699;
+  width: 180px;
+  max-width: 180px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.shortlog td.subject a {
+  color: #000;
+}
+
+.shortlog td.subject a:hover {
+  color: #0000cc;
+}
+
+.shortlog td.actions {
+  white-space: nowrap;
+  font-size: 12px;
+  width: 60px;
+}
+
+.shortlog td.actions a {
+  color: #555;
+}
+
+.shortlog tr.tag-row {
+  background: #ffffee;
+}
+
+/* === Tag Badge === */
+.tag-badge {
+  display: inline-block;
+  background: #ffc800;
+  color: #000;
+  font-size: 10px;
+  font-weight: bold;
+  padding: 1px 4px;
+  border-radius: 3px;
+  margin-right: 4px;
+  vertical-align: middle;
+}
+
+/* === Tags Table === */
+.tag-list td.tag-name a {
+  font-weight: bold;
+  color: #006600;
+}
+
+.tag-list td.date {
+  white-space: nowrap;
+  color: #666;
+  font-family: monospace;
+  font-size: 12px;
+  width: 150px;
+}
+
+.tag-list td.author {
+  color: #006699;
+  font-style: italic;
+}
+
+/* === Full Log === */
+.log-entry {
+  margin-bottom: 16px;
+  padding: 8px;
+  border: 1px solid #d9d8d1;
+  background: #fafaf6;
+}
+
+.log-entry.tag-entry {
+  background: #ffffee;
+  border-color: #e0d000;
+}
+
+.log-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  margin-bottom: 4px;
+}
+
+.log-name {
+  font-weight: bold;
+  font-size: 14px;
+}
+
+.log-name a {
+  color: #000;
+}
+
+.log-name a:hover {
+  color: #0000cc;
+}
+
+.log-date {
+  color: #666;
+  font-family: monospace;
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.log-meta {
+  font-size: 12px;
+  color: #006699;
+  margin-bottom: 4px;
+}
+
+.log-body {
+  margin-top: 8px;
+}
+
+.log-body pre {
+  background: #fff;
+  padding: 6px 8px;
+  border: 1px solid #eee;
+  color: #333;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+}
+
+.log-summary {
+  margin-top: 6px;
+}
+
+.log-summary pre {
+  color: #555;
+  font-size: 11px;
+  white-space: pre-wrap;
+}
+
+/* === Patch Detail === */
+.patch-detail h1 {
+  font-size: 18px;
+  margin-bottom: 12px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid #d9d8d1;
+}
+
+.patch-meta {
+  width: auto;
+  margin-bottom: 16px;
+}
+
+.patch-meta th {
+  background: none;
+  border: none;
+  text-align: right;
+  padding: 2px 12px 2px 0;
+  font-weight: bold;
+  color: #333;
+  width: 80px;
+}
+
+.patch-meta td {
+  border: none;
+  padding: 2px 0;
+}
+
+.patch-meta td.hash {
+  font-family: monospace;
+  font-size: 12px;
+  color: #666;
+}
+
+.patch-log pre {
+  background: #fafaf6;
+  padding: 8px;
+  border: 1px solid #d9d8d1;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+}
+
+.patch-summary pre {
+  background: #f5f5f0;
+  padding: 8px;
+  border: 1px solid #d9d8d1;
+  color: #333;
+  white-space: pre-wrap;
+}
+
+.patch-diff pre.diff {
+  background: #fafafa;
+  padding: 8px;
+  border: 1px solid #d9d8d1;
+  overflow-x: auto;
+  white-space: pre;
+  line-height: 1.4;
+  tab-size: 8;
+}
+
+/* === Description & Empty states === */
+.description {
+  color: #555;
+  font-style: italic;
+  margin-bottom: 8px;
+}
+
+.empty {
+  color: #999;
+  font-style: italic;
+  padding: 20px 0;
+}
+
+.more {
+  margin-top: 4px;
+}
+
+.more a {
+  color: #555;
+  font-style: italic;
+}