summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-08-07 02:52:03 -0500
committerGitHub <[email protected]>2026-08-07 02:52:03 -0500
commit044c33f7f43db1f38d6c44dd8f483298d9efcc06 (patch)
tree649488b2e5d3d901db8c792c0dc215c835fa9d38 /Hutch/Views
parent0a3e55c15cfb9ce1bd3df0c7b352760bc2ee33c3 (diff)
parentb410fd16b76146f4116538c5b82330818dd47da9 (diff)
downloadhutch-044c33f7f43db1f38d6c44dd8f483298d9efcc06.tar.gz
hutch-044c33f7f43db1f38d6c44dd8f483298d9efcc06.tar.bz2
hutch-044c33f7f43db1f38d6c44dd8f483298d9efcc06.zip
Merge pull request #30 from krazywarez/syntax-highlighting
Add multi-language syntax highlighting (#16)
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Repositories/FileTreeView.swift49
-rw-r--r--Hutch/Views/Repositories/MarkdownHTMLRenderer.swift19
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift28
-rw-r--r--Hutch/Views/Repositories/SyntaxHighlighter.swift222
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift12
5 files changed, 302 insertions, 28 deletions
diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift
index 7bfce17..13f72b8 100644
--- a/Hutch/Views/Repositories/FileTreeView.swift
+++ b/Hutch/Views/Repositories/FileTreeView.swift
@@ -456,12 +456,19 @@ final class CodeFileUIView: UIView {
private var wrapLines: Bool
private var needsLineLayoutUpdate = true
private var lastMeasuredCodeWidth: CGFloat = 0
+ private var syntaxHighlighter: SyntaxHighlighter?
+ private var syntaxHighlighterTheme: SyntaxHighlightTheme?
init(text: String, fileName: String, font: UIFont, wrapLines: Bool) {
self.currentFont = font
self.wrapLines = wrapLines
super.init(frame: .zero)
setupViews()
+ registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CodeFileUIView, _: UITraitCollection) in
+ view.rebuildRows()
+ view.needsLineLayoutUpdate = true
+ view.setNeedsLayout()
+ }
updateContent(text: text, fileName: fileName, font: font, wrapLines: wrapLines)
}
@@ -583,11 +590,7 @@ final class CodeFileUIView: UIView {
}
rows.removeAll()
- let attributedText = CodeSyntaxHighlighter.attributedText(
- for: currentText,
- fileName: currentFileName,
- font: currentFont
- )
+ let attributedText = highlightedAttributedText()
let lines = makeLines(from: attributedText, font: currentFont)
gutterWidthConstraint?.constant = Self.gutterWidth(lineCount: lines.count, font: currentFont)
@@ -600,6 +603,30 @@ final class CodeFileUIView: UIView {
}
}
+ /// Highlighting cap: above this size, skip tokenizing to keep the main
+ /// thread responsive and render plain, label-colored text instead.
+ private static let maxHighlightableLength = 100_000
+
+ private func highlightedAttributedText() -> NSAttributedString {
+ let plain = NSAttributedString(
+ string: currentText,
+ attributes: [.font: currentFont, .foregroundColor: UIColor.label]
+ )
+
+ guard currentText.count <= Self.maxHighlightableLength,
+ let language = SyntaxHighlighter.language(forFileName: currentFileName) else {
+ return plain
+ }
+
+ let theme = SyntaxHighlightTheme(userInterfaceStyle: traitCollection.userInterfaceStyle)
+ if syntaxHighlighterTheme != theme || syntaxHighlighter == nil {
+ syntaxHighlighter = SyntaxHighlighter(theme: theme)
+ syntaxHighlighterTheme = theme
+ }
+
+ return syntaxHighlighter?.attributedText(for: currentText, language: language, font: currentFont) ?? plain
+ }
+
private func makeRow(for line: CodeFileLineData) -> LineRow {
let row = LineRow()
@@ -737,18 +764,6 @@ private struct CodeFileLineData {
let text: NSAttributedString
}
-private enum CodeSyntaxHighlighter {
- static func attributedText(for text: String, fileName _: String, font: UIFont) -> NSAttributedString {
- NSAttributedString(
- string: text,
- attributes: [
- .font: font,
- .foregroundColor: UIColor.label
- ]
- )
- }
-}
-
// MARK: - Tree Entry Row
private struct TreeEntryRow: View {
diff --git a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
index bc63f9d..91166ac 100644
--- a/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
+++ b/Hutch/Views/Repositories/MarkdownHTMLRenderer.swift
@@ -2,11 +2,16 @@ import Markdown
nonisolated func markdownToHTML(
_ text: String,
+ codeTheme: SyntaxHighlightTheme = .light,
imageURLResolver: ((String) -> String?)? = nil,
linkURLResolver: ((String) -> String?)? = nil
) -> String {
let document = Document(parsing: text)
- var renderer = MarkdownHTMLRenderer(imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
+ var renderer = MarkdownHTMLRenderer(
+ imageURLResolver: imageURLResolver,
+ linkURLResolver: linkURLResolver,
+ highlighter: SyntaxHighlighter(theme: codeTheme)
+ )
return renderer.visit(document)
}
@@ -15,13 +20,19 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
nonisolated(unsafe) let imageURLResolver: ((String) -> String?)?
nonisolated(unsafe) let linkURLResolver: ((String) -> String?)?
+ nonisolated(unsafe) private let highlighter: SyntaxHighlighter
nonisolated(unsafe) private var isRenderingTableHead = false
nonisolated(unsafe) private var currentTableAlignments: [Markdown.Table.ColumnAlignment?] = []
nonisolated(unsafe) private var currentTableColumnIndex = 0
- nonisolated init(imageURLResolver: ((String) -> String?)?, linkURLResolver: ((String) -> String?)? = nil) {
+ nonisolated init(
+ imageURLResolver: ((String) -> String?)?,
+ linkURLResolver: ((String) -> String?)? = nil,
+ highlighter: SyntaxHighlighter
+ ) {
self.imageURLResolver = imageURLResolver
self.linkURLResolver = linkURLResolver
+ self.highlighter = highlighter
}
nonisolated mutating func visit(_ markup: Markup) -> String {
@@ -78,7 +89,9 @@ private struct MarkdownHTMLRenderer: MarkupVisitor {
} else {
classAttribute = ""
}
- return "<pre><code\(classAttribute)>\(escapeHTML(codeBlock.code))</code></pre>\n"
+ let inner = highlighter.highlightedHTML(for: codeBlock.code, language: codeBlock.language)
+ ?? escapeHTML(codeBlock.code)
+ return "<pre><code\(classAttribute)>\(inner)</code></pre>\n"
}
nonisolated mutating func visitInlineCode(_ inlineCode: InlineCode) -> String {
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
index b8b9622..722af8d 100644
--- a/Hutch/Views/Repositories/ReadmeView.swift
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -234,15 +234,18 @@ struct RenderedMarkupContentView: View {
@State private var renderedHTML: String?
private var cacheKey: String {
+ // Highlighted code colors are baked into the HTML, so the theme is part
+ // of the cache identity — light and dark must not share an entry.
+ let theme = colorScheme == .dark ? "dark" : "light"
switch content {
case .html(let html):
- "html:\(readmePath ?? "custom"):\(html)"
+ return "html:\(readmePath ?? "custom"):\(html)"
case .markdown(let text):
- "markdown:\(readmePath ?? ""):\(text)"
+ return "markdown:\(theme):\(readmePath ?? ""):\(text)"
case .org(let text):
- "org:\(readmePath ?? ""):\(text)"
+ return "org:\(theme):\(readmePath ?? ""):\(text)"
case .plainText(let text):
- "plain:\(readmePath ?? ""):\(text)"
+ return "plain:\(readmePath ?? ""):\(text)"
}
}
@@ -277,9 +280,11 @@ struct RenderedMarkupContentView: View {
renderedHTML = cached
return
}
+ let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
let html = await Task.detached(priority: .userInitiated) {
markdownToHTML(
text,
+ codeTheme: theme,
imageURLResolver: { source in
resolveRepositoryAssetURL(
source,
@@ -308,9 +313,11 @@ struct RenderedMarkupContentView: View {
renderedHTML = cached
return
}
+ let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
let html = await Task.detached(priority: .userInitiated) {
orgToHTML(
text,
+ codeTheme: theme,
imageURLResolver: { source in
resolveRepositoryAssetURL(
source,
@@ -462,9 +469,11 @@ nonisolated func processInline(
nonisolated func orgToHTML(
_ text: String,
+ codeTheme: SyntaxHighlightTheme = .light,
imageURLResolver: ((String) -> String?)? = nil,
linkURLResolver: ((String) -> String?)? = nil
) -> String {
+ let highlighter = SyntaxHighlighter(theme: codeTheme)
let normalizedText = text
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
@@ -496,6 +505,7 @@ nonisolated func orgToHTML(
var inQuoteBlock = false
var inPropertyDrawer = false
var srcLanguage: String?
+ var srcLines: [String] = []
var inExampleBlock = false
var inCenterBlock = false
var inVerseBlock = false
@@ -593,9 +603,14 @@ nonisolated func orgToHTML(
}
func closeSourceBlock() {
- if srcLanguage != nil {
+ if let language = srcLanguage {
+ let code = srcLines.joined(separator: "\n")
+ if !code.isEmpty {
+ html += (highlighter.highlightedHTML(for: code, language: language) ?? escapeHTML(code)) + "\n"
+ }
html += "</code></pre>\n"
srcLanguage = nil
+ srcLines = []
closePendingBlockWrapper()
}
}
@@ -659,7 +674,7 @@ nonisolated func orgToHTML(
if trimmed.lowercased() == "#+end_src" {
closeSourceBlock()
} else {
- html += escapeHTML(line) + "\n"
+ srcLines.append(line)
}
continue
}
@@ -730,6 +745,7 @@ nonisolated func orgToHTML(
let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
html += "<pre><code\(classAttribute)>"
srcLanguage = language ?? ""
+ srcLines = []
continue
}
diff --git a/Hutch/Views/Repositories/SyntaxHighlighter.swift b/Hutch/Views/Repositories/SyntaxHighlighter.swift
new file mode 100644
index 0000000..5ae8f58
--- /dev/null
+++ b/Hutch/Views/Repositories/SyntaxHighlighter.swift
@@ -0,0 +1,222 @@
+import Foundation
+import Highlightr
+import SwiftUI
+import UIKit
+
+enum SyntaxHighlightTheme {
+ case light
+ case dark
+
+ init(userInterfaceStyle: UIUserInterfaceStyle) {
+ self = userInterfaceStyle == .dark ? .dark : .light
+ }
+
+ init(colorScheme: ColorScheme) {
+ self = colorScheme == .dark ? .dark : .light
+ }
+
+ /// highlight.js theme names bundled with Highlightr. Xcode-like in light,
+ /// a muted dark palette in dark, so highlighting reads as native on both.
+ var highlightrName: String {
+ switch self {
+ case .light: "xcode"
+ case .dark: "atom-one-dark"
+ }
+ }
+}
+
+/// Multi-language syntax highlighting backed by Highlightr (highlight.js).
+///
+/// Highlightr wraps a JavaScriptCore context and is not thread-safe, so each
+/// instance must stay on the thread/task that created it. Callers that fail to
+/// resolve a language — or hit an unavailable engine — get `nil` and should
+/// fall back to plain, escaped text.
+final class SyntaxHighlighter {
+ private let highlightr: Highlightr?
+ private let supportedLanguages: Set<String>
+
+ init(theme: SyntaxHighlightTheme) {
+ let engine = Highlightr()
+ engine?.setTheme(to: theme.highlightrName)
+ highlightr = engine
+ supportedLanguages = Set(engine?.supportedLanguages() ?? [])
+ }
+
+ /// Full-document highlighted string, or `nil` when the language is unknown
+ /// or the engine is unavailable.
+ func attributedText(for code: String, language: String?, font: UIFont) -> NSAttributedString? {
+ guard let highlightr, let language = resolvedLanguage(language) else { return nil }
+ highlightr.theme.setCodeFont(font)
+ return highlightr.highlight(code, as: language, fastRender: true)
+ }
+
+ /// Inner HTML for a `<code>` element — color-styled `<span>`s — or `nil` to
+ /// fall back. Colors are inlined, so the caller must regenerate when the
+ /// theme changes.
+ func highlightedHTML(for code: String, language: String?) -> String? {
+ guard let attributed = attributedText(for: code, language: language, font: Self.htmlMeasurementFont) else {
+ return nil
+ }
+ return Self.html(from: attributed)
+ }
+
+ private static let htmlMeasurementFont = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
+
+ private func resolvedLanguage(_ raw: String?) -> String? {
+ guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
+ !trimmed.isEmpty else {
+ return nil
+ }
+ let mapped = Self.languageAliases[trimmed] ?? trimmed
+ return supportedLanguages.contains(mapped) ? mapped : nil
+ }
+
+ /// Resolves a filename to a highlight.js language identifier, or `nil` when
+ /// there is no confident match (so we render plain rather than mis-highlight).
+ static func language(forFileName fileName: String) -> String? {
+ let base = (fileName as NSString).lastPathComponent.lowercased()
+ if let byName = fileNameLanguages[base] {
+ return byName
+ }
+ let ext = (fileName as NSString).pathExtension.lowercased()
+ guard !ext.isEmpty else { return nil }
+ return extensionLanguages[ext]
+ }
+
+ private static func html(from attributed: NSAttributedString) -> String {
+ var html = ""
+ let range = NSRange(location: 0, length: attributed.length)
+ attributed.enumerateAttribute(.foregroundColor, in: range, options: []) { value, subrange, _ in
+ let fragment = (attributed.string as NSString).substring(with: subrange)
+ let escaped = escapeHTML(fragment)
+ if let color = value as? UIColor, let hex = color.hexRGBString {
+ html += "<span style=\"color:\(hex)\">\(escaped)</span>"
+ } else {
+ html += escaped
+ }
+ }
+ return html
+ }
+
+ /// Fence tags / short names that differ from highlight.js identifiers.
+ private static let languageAliases: [String: String] = [
+ "js": "javascript",
+ "jsx": "javascript",
+ "ts": "typescript",
+ "tsx": "typescript",
+ "py": "python",
+ "rb": "ruby",
+ "sh": "bash",
+ "shell": "bash",
+ "zsh": "bash",
+ "yml": "yaml",
+ "c++": "cpp",
+ "cc": "cpp",
+ "h": "cpp",
+ "hpp": "cpp",
+ "cs": "csharp",
+ "objc": "objectivec",
+ "objective-c": "objectivec",
+ "obj-c": "objectivec",
+ "html": "xml",
+ "htm": "xml",
+ "kt": "kotlin",
+ "rs": "rust",
+ "golang": "go",
+ "md": "markdown",
+ "ps1": "powershell",
+ "yaml": "yaml"
+ ]
+
+ /// Filenames without a useful extension.
+ private static let fileNameLanguages: [String: String] = [
+ "dockerfile": "dockerfile",
+ "makefile": "makefile",
+ "gnumakefile": "makefile",
+ "cmakelists.txt": "cmake",
+ "gemfile": "ruby",
+ "rakefile": "ruby",
+ "podfile": "ruby",
+ "package.swift": "swift"
+ ]
+
+ private static let extensionLanguages: [String: String] = [
+ "swift": "swift",
+ "js": "javascript",
+ "mjs": "javascript",
+ "cjs": "javascript",
+ "jsx": "javascript",
+ "ts": "typescript",
+ "tsx": "typescript",
+ "py": "python",
+ "rb": "ruby",
+ "go": "go",
+ "rs": "rust",
+ "c": "c",
+ "h": "c",
+ "cpp": "cpp",
+ "cxx": "cpp",
+ "cc": "cpp",
+ "hpp": "cpp",
+ "hxx": "cpp",
+ "m": "objectivec",
+ "mm": "objectivec",
+ "cs": "csharp",
+ "java": "java",
+ "kt": "kotlin",
+ "kts": "kotlin",
+ "scala": "scala",
+ "php": "php",
+ "pl": "perl",
+ "pm": "perl",
+ "lua": "lua",
+ "r": "r",
+ "dart": "dart",
+ "groovy": "groovy",
+ "hs": "haskell",
+ "ex": "elixir",
+ "exs": "elixir",
+ "erl": "erlang",
+ "clj": "clojure",
+ "sh": "bash",
+ "bash": "bash",
+ "zsh": "bash",
+ "fish": "bash",
+ "ps1": "powershell",
+ "json": "json",
+ "yaml": "yaml",
+ "yml": "yaml",
+ "toml": "ini",
+ "ini": "ini",
+ "cfg": "ini",
+ "conf": "ini",
+ "xml": "xml",
+ "html": "xml",
+ "htm": "xml",
+ "css": "css",
+ "scss": "scss",
+ "sass": "scss",
+ "less": "less",
+ "sql": "sql",
+ "md": "markdown",
+ "markdown": "markdown",
+ "diff": "diff",
+ "patch": "diff",
+ "cmake": "cmake",
+ "gradle": "groovy",
+ "vim": "vim"
+ ]
+}
+
+private extension UIColor {
+ /// `#rrggbb` for HTML inline styles, or `nil` if the color isn't RGB-convertible.
+ var hexRGBString: String? {
+ var red: CGFloat = 0
+ var green: CGFloat = 0
+ var blue: CGFloat = 0
+ var alpha: CGFloat = 0
+ guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return nil }
+ let clamp: (CGFloat) -> Int = { Int((max(0, min(1, $0)) * 255).rounded()) }
+ return String(format: "#%02x%02x%02x", clamp(red), clamp(green), clamp(blue))
+ }
+}
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index 6339776..1ace6b6 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -553,14 +553,22 @@ private struct MarkdownContentView: View {
.frame(minHeight: 80)
}
}
- .task(id: markdown) {
+ .task(id: MarkdownRenderKey(markdown: markdown, isDark: colorScheme == .dark)) {
+ let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
let html = await Task.detached(priority: .userInitiated) {
- markdownToHTML(markdown)
+ markdownToHTML(markdown, codeTheme: theme)
}.value
guard !Task.isCancelled else { return }
renderedHTML = html
}
}
+
+ /// Re-renders when the markdown or the color scheme changes; highlighted
+ /// code colors are baked into the HTML, so both must key the render.
+ private struct MarkdownRenderKey: Equatable {
+ let markdown: String
+ let isDark: Bool
+ }
}
// MARK: - Event Row