chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app loads Markdown's pictures, colours its code, and quotes the lines a comment is on
ios/Knotless/API/Client.swift+26−0
| @@ -24,6 +24,8 @@ actor Client { | ||
let config = URLSessionConfiguration.default | ||
config.httpShouldSetCookies = false | ||
config.httpCookieStorage = nil | ||
// Room for accounts' pictures and those in Markdown, across launches. | ||
config.urlCache = URLCache(memoryCapacity: 0, diskCapacity: 128 * 1024 * 1024) | ||
return URLSession(configuration: config) | ||
}() | ||
| ||
| @@ -51,6 +53,22 @@ actor Client { | ||
_ = try await call(write) | ||
} | ||
| ||
/// A picture Markdown names. Those on this site are asked for as the account | ||
/// signed in, as a private repository's must be. | ||
func picture(_ url: URL) async throws -> Data { | ||
#if DEBUG | ||
if let recorded = DevAccount.recording, url.host() == Self.site.host() { | ||
return try recorded.file(url.path) | ||
} | ||
#endif | ||
var request = URLRequest(url: url) | ||
request.timeoutInterval = 40 | ||
if url.host() == Self.site.host() { authorize(&request, nil) } | ||
let (data, response) = try await session.data(for: request) | ||
guard ((response as? HTTPURLResponse)?.statusCode ?? 0) < 300 else { throw URLError(.badServerResponse) } | ||
return data | ||
} | ||
| ||
/// A write whose answer the screen needs, as a fork's says what it made. | ||
func answer<T: Decodable & Sendable>(to write: Write) async throws -> T { | ||
#if DEBUG | ||
| @@ -173,6 +191,14 @@ final class Recording: Sendable { | ||
self.misses = misses | ||
} | ||
| ||
/// A file the recording holds by its path, as a README's picture. | ||
func file(_ path: String) throws -> Data { | ||
guard let found = answers.first(where: { $0.method == "file" && $0.params["path"] == path }) else { | ||
throw APIError(status: 404, name: nil, message: "Nothing recorded at \(path).") | ||
} | ||
return Data(found.body.utf8) | ||
} | ||
| ||
func answer(_ method: String, _ params: [String: String?]) throws -> Data { | ||
let asked = params.compactMapValues { $0 } | ||
func score(_ answer: Answer) -> Int { | ||
ios/Knotless/API/Models.swift+43−0
| @@ -111,6 +111,7 @@ struct Release: Decodable, Identifiable, Hashable { | ||
let version: String | ||
var latest: Bool? | ||
var notes: String? | ||
var html: String? | ||
var files: [File]? | ||
var createdAt: String? | ||
var id: String { uri } | ||
| @@ -206,7 +207,11 @@ struct DiffFile: Decodable, Identifiable, Hashable { | ||
struct CommitAuthor: Decodable, Hashable { | ||
var name: String? | ||
var actor: Person? | ||
/// The coding agent whose address it is. | ||
var agent: String? | ||
var label: String { actor?.handle ?? name ?? "" } | ||
/// The agent's own face, where the app has it. | ||
var face: String? { agent == "claude" ? "agent-claude" : nil } | ||
} | ||
| ||
struct Commit: Decodable, Identifiable, Hashable { | ||
| @@ -258,8 +263,43 @@ struct Comment: Decodable, Identifiable, Hashable { | ||
let author: Person | ||
let createdAt: String | ||
var body: String? | ||
var html: String? | ||
var verdict: String? | ||
/// The lines of a pull's diff it's on, if it's on any. | ||
var line: Lines? | ||
/// The comment starting the thread on those lines that this answers. | ||
var reply: String? | ||
var resolved: Bool? | ||
var id: String { uri ?? (author.did + createdAt) } | ||
| ||
struct Lines: Decodable, Hashable { | ||
let path: String | ||
var side: String? | ||
let line: Int | ||
var startLine: Int? | ||
let outdated: Bool | ||
/// As they were when it was written; absent when that can't be read. | ||
var lines: [Text]? | ||
| ||
struct Text: Decodable, Hashable { | ||
let number: Int | ||
let text: String | ||
} | ||
} | ||
| ||
/// Each thread on lines together: answers straight after what they answer, | ||
/// wherever they fell in time, as the pages show them. | ||
static func threaded(_ comments: [Comment]) -> [Comment] { | ||
let starts = Set(comments.compactMap(\.uri)) | ||
var answers: [String: [Comment]] = [:] | ||
for comment in comments { | ||
if let root = comment.reply, starts.contains(root) { answers[root, default: []].append(comment) } | ||
} | ||
return comments.flatMap { comment -> [Comment] in | ||
if let root = comment.reply, starts.contains(root) { return [] } | ||
return [comment] + (comment.uri.flatMap { answers[$0] } ?? []) | ||
} | ||
} | ||
} | ||
| ||
struct Check: Decodable, Identifiable, Hashable { | ||
| @@ -285,6 +325,7 @@ struct PullHead: Decodable, Hashable { | ||
struct PullThread: Decodable { | ||
let item: Item | ||
var body: String? | ||
var html: String? | ||
var base: String? | ||
var head: PullHead? | ||
var merged: String? | ||
| @@ -365,6 +406,8 @@ struct FileAnswer: Decodable { | ||
var lines: Int? | ||
var text: String? | ||
var ranges: String? | ||
/// A Markdown file, made into HTML. | ||
var html: String? | ||
} | ||
| ||
struct Org: Decodable, Hashable { let actor: Person } | ||
ios/Knotless/Assets.xcassets/agent-claude.imageset/claude.pngnew+0−0
A binary file, not shown.
ios/Knotless/Assets.xcassets/agent-claude.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"claude.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} | ||
| \ No newline at end of file | ||
ios/Knotless/Support/Faces.swiftnew+37−0
| @@ -0,0 +1,37 @@ | ||
import UIKit | ||
| ||
/// Accounts' pictures, decoded and kept while the app runs, so that a screen | ||
/// draws those it has seen in its first frame. Each is asked for once, however | ||
/// many rows show it. The address names the picture's content, so the session's | ||
/// disk cache holds them across launches too. | ||
actor Faces { | ||
static let shared = Faces() | ||
| ||
private static let limit = 64 * 1024 * 1024 | ||
// NSCache is safe across threads, though it isn't marked Sendable. | ||
private nonisolated(unsafe) static let store: NSCache<NSURL, UIImage> = { | ||
let cache = NSCache<NSURL, UIImage>() | ||
cache.totalCostLimit = limit | ||
return cache | ||
}() | ||
| ||
private var loading: [URL: Task<UIImage, Error>] = [:] | ||
| ||
nonisolated static func kept(_ url: URL) -> UIImage? { store.object(forKey: url as NSURL) } | ||
| ||
func load(_ url: URL) async throws -> UIImage { | ||
if let face = Self.kept(url) { return face } | ||
// Not tied to the row asking: another may be waiting on the same one. | ||
let task = loading[url] ?? Task.detached { | ||
let data = try await Client.shared.picture(url) | ||
guard let image = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) } | ||
let face = image.preparingForDisplay() ?? image | ||
let bytes = face.cgImage.map { $0.bytesPerRow * $0.height } ?? data.count | ||
Self.store.setObject(face, forKey: url as NSURL, cost: bytes) | ||
return face | ||
} | ||
loading[url] = task | ||
defer { loading[url] = nil } | ||
return try await task.value | ||
} | ||
} | ||
ios/Knotless/Support/Identicon.swiftnew+24−0
| @@ -0,0 +1,24 @@ | ||
import Foundation | ||
| ||
/// The pattern and hue an account with no picture shows, as `identicon` and | ||
/// `hue` in web/src/lib/format.ts make them, so it's the same on the pages. | ||
enum Identicon { | ||
static func hue(_ seed: String) -> Int { | ||
var h: UInt32 = 7 | ||
for byte in seed.utf8 { h = h &* 31 &+ UInt32(byte) } | ||
return Int(h % 360) | ||
} | ||
| ||
/// The cells of a 5×5 pattern, each its column and row, mirrored left to right. | ||
static func cells(_ seed: String) -> [(x: Int, y: Int)] { | ||
var h: UInt32 = 0x811c9dc5 | ||
for byte in seed.utf8 { h = (h ^ UInt32(byte)) &* 0x01000193 } | ||
var cells: [(x: Int, y: Int)] = [] | ||
for i in 0..<15 where (h >> UInt32(i)) & 1 == 1 { | ||
let (x, y) = (i / 5, i % 5) | ||
cells.append((x, y)) | ||
if x < 2 { cells.append((4 - x, y)) } | ||
} | ||
return cells | ||
} | ||
} | ||
ios/Knotless/Support/Markdown.swift+92−6
| @@ -4,6 +4,10 @@ import SwiftUI | ||
/// Foundation's own parser. What it doesn't know reads as plain text. | ||
struct MarkdownView: View { | ||
let source: String | ||
/// It made into HTML by the server, which alone knows how to colour its code. | ||
var html: String? | ||
/// Where it's written, to find the pictures it names. | ||
var place: MarkdownPlace? | ||
| ||
enum Block: Hashable { | ||
case heading(Int, String) | ||
| @@ -15,38 +19,120 @@ struct MarkdownView: View { | ||
} | ||
| ||
var body: some View { | ||
let colours = html.map(MarkdownFences.ranges) ?? [:] | ||
VStack(alignment: .leading, spacing: 10) { | ||
ForEach(Array(Self.parse(source).enumerated()), id: \.offset) { _, block in | ||
view(block) | ||
view(block, colours) | ||
} | ||
} | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
} | ||
| ||
@ViewBuilder private func view(_ block: Block) -> some View { | ||
@ViewBuilder private func view(_ block: Block, _ colours: [String: String]) -> some View { | ||
switch block { | ||
case .heading(let level, let text): | ||
inline(text).font(level == 1 ? .title2.bold() : level == 2 ? .title3.bold() : .headline).padding(.top, 4) | ||
case .paragraph(let text): | ||
inline(text) | ||
rich(text) | ||
case .bullet(let text, let marker, let depth): | ||
HStack(alignment: .firstTextBaseline, spacing: 8) { | ||
Text(marker).foregroundStyle(.secondary) | ||
inline(text) | ||
rich(text) | ||
} | ||
.padding(.leading, CGFloat(depth) * 16) | ||
case .quote(let text): | ||
inline(text).foregroundStyle(.secondary).padding(.leading, 12) | ||
case .code(let text): | ||
ScrollView(.horizontal, showsIndicators: false) { | ||
Text(text).font(.footnote.monospaced()).textSelection(.enabled).padding(10) | ||
Text(Self.painted(text, colours[text])).font(.footnote.monospaced()).textSelection(.enabled).padding(10) | ||
} | ||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8)) | ||
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 8)) | ||
case .rule: | ||
Divider() | ||
} | ||
} | ||
| ||
private static func painted(_ text: String, _ ranges: String?) -> AttributedString { | ||
var whole = AttributedString() | ||
for (at, line) in Highlight.paint(lines: text.components(separatedBy: "\n"), ranges: ranges).enumerated() { | ||
if at > 0 { whole.append(AttributedString("\n")) } | ||
whole.append(line) | ||
} | ||
return whole | ||
} | ||
| ||
/// Text, with the pictures it names drawn in their turn. Those with nothing | ||
/// between them share a row. | ||
@ViewBuilder private func rich(_ text: String) -> some View { | ||
let pieces = Self.pieces(text) | ||
if pieces.count == 1, case .text(let only) = pieces[0] { | ||
inline(only) | ||
} else { | ||
VStack(alignment: .leading, spacing: 8) { | ||
ForEach(Array(pieces.enumerated()), id: \.offset) { _, piece in | ||
switch piece { | ||
case .text(let words): | ||
inline(words) | ||
case .pictures(let pictures): | ||
FlowRow { | ||
ForEach(Array(pictures.enumerated()), id: \.offset) { _, picture in | ||
self.picture(picture) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
| ||
@ViewBuilder private func picture(_ picture: Picture) -> some View { | ||
let shown = MarkdownImage(urls: place?.pictures(picture.source) ?? URL(string: picture.source).map { [$0] } ?? [], alt: picture.alt) | ||
if let link = picture.link, let url = URL(string: link), url.scheme != nil { | ||
Link(destination: url) { shown } | ||
} else { | ||
shown | ||
} | ||
} | ||
| ||
struct Picture: Hashable { | ||
let alt: String | ||
let source: String | ||
var link: String? | ||
} | ||
| ||
enum Piece: Hashable { | ||
case text(String) | ||
case pictures([Picture]) | ||
} | ||
| ||
/// ``, or that as a link, split out of the words around it. | ||
/// One inside inline code is left as words. | ||
static func pieces(_ text: String) -> [Piece] { | ||
let image = #/\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)\]\(([^)\s]+)(?:\s+"[^"]*")?\)|!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/# | ||
var pieces: [Piece] = [] | ||
var done = text.startIndex | ||
for match in text.matches(of: image) { | ||
let before = text[done..<match.range.lowerBound] | ||
if text[..<match.range.lowerBound].filter({ $0 == "`" }).count % 2 == 1 { continue } | ||
let picture = match.output.2 != nil | ||
? Picture(alt: String(match.output.1 ?? ""), source: String(match.output.2 ?? ""), link: match.output.3.map(String.init)) | ||
: Picture(alt: String(match.output.4 ?? ""), source: String(match.output.5 ?? "")) | ||
let words = before.trimmingCharacters(in: .whitespaces) | ||
if !words.isEmpty { | ||
pieces.append(.text(words)) | ||
} | ||
if case .pictures(let row)? = pieces.last { | ||
pieces[pieces.count - 1] = .pictures(row + [picture]) | ||
} else { | ||
pieces.append(.pictures([picture])) | ||
} | ||
done = match.range.upperBound | ||
} | ||
let rest = text[done...].trimmingCharacters(in: .whitespaces) | ||
if !rest.isEmpty || pieces.isEmpty { pieces.append(.text(rest)) } | ||
return pieces | ||
} | ||
| ||
private func inline(_ text: String) -> Text { | ||
let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) | ||
if var marked = try? AttributedString(markdown: text, options: options) { | ||
ios/Knotless/Support/MarkdownFences.swiftnew+23−0
| @@ -0,0 +1,23 @@ | ||
import Foundation | ||
| ||
/// The colours the server gave a Markdown text's blocks of code, read from the | ||
/// HTML it made of them, by each block's text: a block this app's Markdown reads | ||
/// differently from the server's just goes without. | ||
enum MarkdownFences { | ||
static func ranges(_ html: String) -> [String: String] { | ||
var out: [String: String] = [:] | ||
for found in html.matches(of: #/<pre><code data-ranges="([^"]*)">(.*?)</code></pre>/#.dotMatchesNewlines()) { | ||
var text = String(found.output.2) | ||
if text.hasSuffix("\n") { text.removeLast() } | ||
out[unescaped(text)] = String(found.output.1) | ||
} | ||
return out | ||
} | ||
| ||
private static func unescaped(_ text: String) -> String { | ||
// `&` last, so that an escaped `<` stays one. | ||
text.replacing("<", with: "<").replacing(">", with: ">") | ||
.replacing(""", with: "\"").replacing("'", with: "'").replacing("'", with: "'") | ||
.replacing("&", with: "&") | ||
} | ||
} | ||
ios/Knotless/Support/MarkdownPlace.swiftnew+51−0
| @@ -0,0 +1,51 @@ | ||
import Foundation | ||
| ||
/// Where some Markdown is written, so a picture it names can be found: inside | ||
/// its repository as the pages resolve one, and from the server when it's one | ||
/// a pull, issue or comment attaches. | ||
struct MarkdownPlace: Hashable, Sendable { | ||
let repo: RepoRef | ||
/// The branch a file was read at; none for what's written about the repository. | ||
var ref: String? | ||
/// The pull, issue or comment that says it. | ||
var subject: String? | ||
| ||
static let site = "https://knotless.dev" | ||
| ||
/// Where to look for the picture `dest` names, best first. | ||
func pictures(_ dest: String) -> [URL] { | ||
let scheme = dest.split(separator: ":", maxSplits: 1).first.map(String.init) | ||
if let scheme, dest.contains(":"), !scheme.contains(where: { "/#?".contains($0) }) { | ||
guard ["http", "https"].contains(scheme.lowercased()), let url = URL(string: dest) else { return [] } | ||
return (attached(url).map { [$0] } ?? []) + [url] | ||
} | ||
if dest.hasPrefix("#") { return [] } | ||
let cut = dest.firstIndex { "?#".contains($0) } ?? dest.endIndex | ||
var parts: [Substring] = [] | ||
for part in dest[..<cut].split(separator: "/") { | ||
if part == ".." { | ||
_ = parts.popLast() | ||
} else if part != "." { | ||
parts.append(part) | ||
} | ||
} | ||
let root = [repo.handle, repo.name] + (ref.map { ["raw", $0] } ?? []) | ||
let path = (root + parts.map(String.init)).joined(separator: "/") | ||
return URL(string: "\(Self.site)/\(path)\(dest[cut...])").map { [$0] } ?? [] | ||
} | ||
| ||
/// Where the server shows a blob this subject's author attached, which is | ||
/// how a member reads one of a private repository's. | ||
private func attached(_ url: URL) -> URL? { | ||
guard url.path.hasSuffix("/xrpc/com.atproto.sync.getBlob"), | ||
let subject, | ||
let asked = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, | ||
let did = asked.first(where: { $0.name == "did" })?.value, | ||
let cid = asked.first(where: { $0.name == "cid" })?.value, | ||
subject.hasPrefix("at://\(did)/"), | ||
var parts = URLComponents(string: "\(Self.site)/xrpc/dev.knotless.getAttachment") | ||
else { return nil } | ||
parts.queryItems = [.init(name: "repo", value: repo.uri), .init(name: "uri", value: subject), .init(name: "cid", value: cid)] | ||
return parts.url | ||
} | ||
} | ||
ios/Knotless/Views/CommitView.swift+3−3
| @@ -51,7 +51,7 @@ private struct CommitPeople: View { | ||
HStack(spacing: 8) { | ||
HStack(spacing: -6) { | ||
ForEach(Array(authors.prefix(2).enumerated()), id: \.offset) { _, person in | ||
Avatar(url: person.actor?.avatar, name: person.label, size: 22) | ||
Avatar(url: person.actor?.avatar, name: person.label, did: person.actor?.did, face: person.face, size: 22) | ||
} | ||
} | ||
if whole, let committer = commit.otherCommitter { | ||
| @@ -115,7 +115,7 @@ private struct CommitChanges: View { | ||
Text(When.short(commit.when)).font(.subheadline).foregroundStyle(.secondary).fixedSize() | ||
} | ||
Text(commit.subject).font(.headline) | ||
if !commit.details.isEmpty { MarkdownView(source: commit.details) } | ||
if !commit.details.isEmpty { MarkdownView(source: commit.details, place: MarkdownPlace(repo: repo)) } | ||
Text(summary).font(.footnote).foregroundStyle(.secondary) | ||
} | ||
.padding(16) | ||
| @@ -217,7 +217,7 @@ private struct CommitDetails: View { | ||
| ||
private func person(_ person: CommitAuthor, _ role: String) -> some View { | ||
HStack(spacing: 12) { | ||
Avatar(url: person.actor?.avatar, name: person.label, size: 32) | ||
Avatar(url: person.actor?.avatar, name: person.label, did: person.actor?.did, face: person.face, size: 32) | ||
Text(person.label).lineLimit(1) | ||
Spacer(minLength: 8) | ||
Text(role).foregroundStyle(.secondary) | ||
ios/Knotless/Views/ExploreView.swift+1−1
| @@ -69,7 +69,7 @@ struct EventRow: View { | ||
| ||
var body: some View { | ||
HStack(alignment: .top, spacing: 12) { | ||
Avatar(url: event.actor.avatar, name: event.actor.handle, size: 32) | ||
Avatar(url: event.actor.avatar, name: event.actor.handle, did: event.actor.did, size: 32) | ||
VStack(alignment: .leading, spacing: 3) { | ||
HStack { | ||
Text(event.repo.label).lineLimit(1).foregroundStyle(.secondary) | ||