~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The iOS app loads Markdown's pictures, colours its code, and quotes the lines a comment is on

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:ios/images-avatars
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])
}
/// `![alt](source)`, 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 {
// `&amp;` last, so that an escaped `&lt;` stays one.
text.replacing("&lt;", with: "<").replacing("&gt;", with: ">")
.replacing("&quot;", with: "\"").replacing("&#39;", with: "'").replacing("&#x27;", with: "'")
.replacing("&amp;", 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)
ios/Knotless/Views/InboxView.swift+1−1
ios/Knotless/Views/MarkdownImage.swiftnew+129−0
ios/Knotless/Views/ProfileView.swift+3−3
ios/Knotless/Views/RepoView.swift+16−12
ios/Knotless/Views/Shared.swift+59−12
ios/Knotless/Views/ThreadView.swift+71−12
ios/ModelCheck/main.swift+18−0
ios/check-models.sh+1−1
ios/look.mjs+3−1