Repositories / gitweb2.git
gitweb2.git
Clone (read-only): git clone http://git.guha-anderson.com/git/gitweb2.git
@@ -28,7 +28,7 @@ The app uses Ktor Native with the CIO engine for HTTP and the local Options: ```sh -./build/bin/native/releaseExecutable/gitweb2.kexe ~/repos --host 127.0.0.1 --port 8080 +./build/bin/native/releaseExecutable/gitweb2.kexe ~/repos --host 127.0.0.1 --port 8080 --pygments "uv run --with pygments pygmentize" ``` Press `Ctrl+C` to stop the server.
@@ -32,11 +32,13 @@ kotlin { nativeTarget.apply { binaries { + all { + linkerOpts("-L${pkgConfigValue("--variable=libdir", "libgit2")}") + linkerOpts(pkgConfig("--libs", "libgit2")) + } executable { baseName = "gitweb2" entryPoint = "main" - linkerOpts("-L${pkgConfigValue("--variable=libdir", "libgit2")}") - linkerOpts(pkgConfig("--libs", "libgit2")) } } }
@@ -15,6 +15,9 @@ import io.ktor.server.routing.get import io.ktor.server.routing.routing import io.ktor.utils.io.charsets.Charsets import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.memScoped import kotlinx.cinterop.pointed import kotlinx.cinterop.staticCFunction import kotlinx.cinterop.toKString @@ -30,9 +33,12 @@ import platform.posix.SIGINT import platform.posix.SIGTERM import platform.posix.access import platform.posix.closedir +import platform.posix.fgets import platform.posix.getenv import platform.posix.opendir +import platform.posix.pclose import platform.posix.pause +import platform.posix.popen import platform.posix.readdir import platform.posix.signal import platform.posix._exit @@ -41,25 +47,28 @@ import kotlin.system.exitProcess private const val DefaultHost = "127.0.0.1" private const val DefaultPort = 8080 private const val MaxScanDepth = 5 +private const val DefaultPygmentsCommand = "pygmentize" fun runGitWeb2(args: Array<String>) { val config = parseArgs(args) ?: return - GitWebServer(config.root, config.host, config.port).start() + GitWebServer(config.root, config.host, config.port, config.pygmentsCommand).start() } -private data class Config(val root: String, val host: String, val port: Int) +private data class Config(val root: String, val host: String, val port: Int, val pygmentsCommand: String) private data class RepoInfo(val key: String, val name: String, val path: String) -private data class Response(val status: Int, val body: String) +internal data class Response(val status: Int, val body: String) -private class GitWebServer( +internal class GitWebServer( private val root: String, private val host: String, private val port: Int, + private val pygmentsCommand: String = DefaultPygmentsCommand, ) { private val repos: List<RepoInfo> = discoverRepos(root) private val reposByKey: Map<String, RepoInfo> = repos.associateBy { it.key } + private val fileRenderer = FileRenderer(pygmentsCommand) fun start() { println("gitweb2 serving ${repos.size} repositories from $root at http://$host:$port/") @@ -171,7 +180,8 @@ private class GitWebServer( path: String, branches: List<Branch>, ): String { - val listing = git.tree(path, git.branchCommit(branch)) + val commit = git.branchCommit(branch) + val listing = git.tree(path, commit) return buildString { append(repoHeader(repo, branch, path, branches, "code")) append("<div class=\"panel\"><table><tbody>") @@ -179,12 +189,25 @@ private class GitWebServer( if (path.isNotBlank()) { append("<tr><td><a href=\"${repoUrl(repo, branch, parent)}\">..</a></td><td class=\"kind\">tree</td></tr>") } - for (entry in listing.entries) { + for (entry in listing.entries.entriesForDisplay()) { val child = joinPath(path, entry.name) append("<tr><td><a href=\"${repoUrl(repo, branch, child)}\">${html(entry.name)}</a></td>") append("<td class=\"kind\">${entry.kind.name.lowercase()}</td></tr>") } append("</tbody></table></div>") + val readme = listing.entries.entriesForDisplay().firstOrNull { isReadme(it.name) && it.kind == TreeEntryKind.Blob } + if (readme != null) { + val readmePath = joinPath(path, readme.name) + val blob = git.blob(readmePath, commit) + if (!blob.isBinary) { + append("<section class=\"readme\">") + append("<h2>${html(readme.name)}</h2>") + append("<div class=\"panel file\">") + append(fileRenderer.render(joinPath(repo.path, readmePath), blob.text())) + append("</div>") + append("</section>") + } + } } } @@ -201,7 +224,7 @@ private class GitWebServer( if (blob.isBinary) { append("<p class=\"notice\">Binary file</p>") } else { - append("<pre><code>${html(blob.text())}</code></pre>") + append(fileRenderer.render(joinPath(repo.path, path), blob.text())) } append("</div>") } @@ -279,6 +302,7 @@ main { max-width: 1180px; margin: 0 auto; padding: 28px 20px 56px; } .hero, .repo-head { border-bottom: 1px solid var(--line); margin-bottom: 20px; padding-bottom: 16px; } .eyebrow, .repo-head p, .file-meta, .kind, small, .commits span, .repo-list span { color: var(--muted); } h1 { margin: 4px 0 8px; font-size: 28px; line-height: 1.2; overflow-wrap: anywhere; } +h2 { margin: 0 0 10px; font-size: 20px; line-height: 1.25; overflow-wrap: anywhere; } .repo-list, .commits { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } .repo-list li, .commits li { border-top: 1px solid var(--line); } .repo-list li:first-child, .commits li:first-child { border-top: 0; } @@ -296,6 +320,10 @@ tr:first-child td { border-top: 0; } .toolbar a.active, .tabs a.active { border-color: var(--accent); color: var(--accent); font-weight: 600; } .file pre { margin: 0; padding: 16px; overflow: auto; background: var(--wash); } .file code { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; } +.highlight { margin: 0; padding: 16px; overflow: auto; background: var(--wash); } +.highlight pre { margin: 0; padding: 0; background: transparent; } +.highlight, .highlight pre { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; } +.readme { margin-top: 24px; } .file-meta { padding: 10px 12px; border-bottom: 1px solid var(--line); background: #fff; } .notice { padding: 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--wash); } @media (max-width: 640px) { @@ -309,6 +337,49 @@ tr:first-child td { border-top: 0; } </html> """.trimIndent() +internal class FileRenderer(private val pygmentsCommand: String = DefaultPygmentsCommand) { + fun render(filePath: String, text: String): String = + renderWithPygments(filePath, text) ?: plain(text) + + private fun renderWithPygments(filePath: String, text: String): String? { + val lexer = readCommandOutput("$pygmentsCommand -N ${shellQuote(filePath)} 2>/dev/null")?.trim() + val lexerOption = if (lexer.isNullOrBlank()) "-g" else "-l ${shellQuote(lexer)}" + val command = "printf %s ${shellQuote(text)} | $pygmentsCommand -f html -O nowrap,noclasses=True $lexerOption 2>/dev/null" + val highlighted = readCommandOutput(command) ?: return null + if (highlighted.isBlank()) return null + return "<div class=\"highlight\">$highlighted</div>" + } + + private fun plain(text: String): String = + "<pre><code>${html(text)}</code></pre>" +} + +private fun List<kotlinx.git.TreeEntry>.entriesForDisplay(): List<kotlinx.git.TreeEntry> = + sortedBy { it.name.lowercase() } + +private fun isReadme(name: String): Boolean = + name == "README.md" || name == "README.txt" || name == "README" + +private fun shellQuote(value: String): String = + "'" + value.replace("'", "'\"'\"'") + "'" + +private fun readCommandOutput(command: String): String? { + val stream = popen(command, "r") ?: return null + return try { + memScoped { + val buffer = allocArray<ByteVar>(4096) + buildString { + while (true) { + val line = fgets(buffer, 4096, stream) ?: break + append(line.toKString()) + } + } + } + } finally { + pclose(stream) + } +} + private fun discoverRepos(root: String): List<RepoInfo> { val found = mutableListOf<RepoInfo>() fun scan(path: String, relative: String, depth: Int) { @@ -431,21 +502,23 @@ private fun normalizeRoot(path: String): String { private fun parseArgs(args: Array<String>): Config? { if (args.isEmpty() || args[0] == "-h" || args[0] == "--help") { - println("usage: gitweb2 <repo-root> [--host 127.0.0.1] [--port 8080]") + println("usage: gitweb2 <repo-root> [--host 127.0.0.1] [--port 8080] [--pygments pygmentize]") return null } var host = DefaultHost var port = DefaultPort + var pygmentsCommand = DefaultPygmentsCommand var index = 1 while (index < args.size) { when (args[index]) { "--host" -> host = args.getOrNull(++index) ?: die("missing --host value") "--port" -> port = args.getOrNull(++index)?.toIntOrNull() ?: die("missing or invalid --port value") + "--pygments" -> pygmentsCommand = args.getOrNull(++index) ?: die("missing --pygments value") else -> die("unknown argument ${args[index]}") } index++ } - return Config(root = args[0], host = host, port = port) + return Config(root = args[0], host = host, port = port, pygmentsCommand = pygmentsCommand) } private fun die(message: String): Nothing {
@@ -0,0 +1,56 @@ +package gitweb + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import platform.posix.getenv +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +@OptIn(ExperimentalForeignApi::class) +class FileRendererTest { + private val repoRoot: String = getenv("PWD")!!.toKString() + + @Test + fun filesCanBeRenderedThroughCustomPygmentsCommand() { + val html = FileRenderer("uv run --with pygments pygmentize").render( + "$repoRoot/src/main/kotlin/Main.kt", + """ + private class Example { + fun answer(): Int = 42 + } + """.trimIndent(), + ) + + assertContains(html, "<div class=\"highlight\">") + assertContains(html, "<span style=\"color: #008000; font-weight: bold\">private</span>") + assertContains(html, "<span style=\"color: #008000; font-weight: bold\">class</span>") + assertContains(html, "<span style=\"color: #008000; font-weight: bold\">fun</span>") + assertContains(html, "Example") + } + + @Test + fun pygmentsUsesProvidedBlobTextEvenWhenThePathIsNotOnDisk() { + val html = FileRenderer("uv run --with pygments pygmentize").render( + "$repoRoot/not-checked-out/Example.kt", + """ + private class BlobOnly { + fun answer(): Int = 42 + } + """.trimIndent(), + ) + + assertContains(html, "<div class=\"highlight\">") + assertContains(html, "<span style=\"color: #008000; font-weight: bold\">private</span>") + assertContains(html, "<span style=\"color: #008000; font-weight: bold\">class</span>") + assertContains(html, "<span style=\"color: #008000; font-weight: bold\">fun</span>") + assertContains(html, "BlobOnly") + } + + @Test + fun filesFallBackToEscapedPlainTextWhenPygmentsIsUnavailable() { + val html = FileRenderer("__missing_pygmentize__").render("$repoRoot/README.md", "<plain>") + + assertEquals("<pre><code><plain></code></pre>", html) + } +}
@@ -0,0 +1,48 @@ +package gitweb + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import platform.posix.getenv +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalForeignApi::class) +class GitWebServerTest { + private val repoRoot: String = getenv("PWD")!!.toKString() + private val repoName: String = repoRoot.trimEnd('/').substringAfterLast('/') + + @Test + fun directoryEntriesStayAlphabetical() { + val response = GitWebServer(repoRoot, "127.0.0.1", 0).route("/repo/$repoName") + + assertEquals(200, response.status) + val buildIndex = response.body.indexOf(">build.gradle.kts</a>") + val settingsIndex = response.body.indexOf(">settings.gradle.kts</a>") + val readmeIndex = response.body.indexOf(">README.md</a>") + + assertNotEquals(-1, buildIndex) + assertNotEquals(-1, settingsIndex) + assertNotEquals(-1, readmeIndex) + assertTrue(readmeIndex > buildIndex, "README.md should be listed after build.gradle.kts") + assertTrue(readmeIndex < settingsIndex, "README.md should be listed before settings.gradle.kts") + } + + @Test + fun directoriesRenderTheirReadmeBelowTheListing() { + val response = GitWebServer(repoRoot, "127.0.0.1", 0, "uv run --with pygments pygmentize").route("/repo/$repoName") + + assertEquals(200, response.status) + val listingIndex = response.body.indexOf(">README.md</a>") + val renderedIndex = response.body.indexOf("<h2>README.md</h2>") + + assertNotEquals(-1, listingIndex) + assertNotEquals(-1, renderedIndex) + assertTrue(renderedIndex > listingIndex, "Rendered README should appear below the directory listing") + assertContains(response.body, "<div class=\"highlight\">") + assertContains(response.body, "gitweb2") + assertContains(response.body, "A Kotlin/Native, read-only web viewer") + } +}