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> & "
+ , "<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 '<' = "<"
+ escChar '>' = ">"
+ escChar '&' = "&"
+ escChar '"' = """
+ escChar '\'' = "'"
+ 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;
+}