~ 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

An iOS app reads the inbox, pulls, diffs, repositories and profiles

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:feature/ios-app
.knotless/workflows/ios.ymlnew+13−0
@@ -0,0 +1,13 @@
# The iOS app's models against the XRPC answers the screenshots recorded, so a
# model that misreads an answer fails here rather than on a screen. Xcode needs
# macOS, so the app itself is built by hand (ios/README.md). Only on pulls that
# touch what it reads, and no check deploy.yml waits for: nothing the app does
# holds up a deploy.
image: swift:6.2
on:
pull:
base: [refs/heads/main, refs/heads/feature/*]
paths: [ios/**, lexicons/**, harness/shots/fixtures.json, .knotless/workflows/ios.yml]
steps:
- name: models
run: sh ios/check-models.sh
ios/Knotless/API/Client.swiftnew+58−0
@@ -0,0 +1,58 @@
import Foundation
struct APIError: LocalizedError {
let status: Int
let name: String?
let message: String?
var errorDescription: String? { message ?? "The server answered \(status)." }
var needsSignIn: Bool { status == 401 }
}
private struct Failure: Decodable { var error: String?; var message: String? }
/// Reads knotless's XRPC queries. Asks as an account only for a method it
/// holds a service token for; everything else is public and asked as no one.
actor Client {
static let shared = Client()
private let base = URL(string: "https://knotless.dev/xrpc/")!
private let decoder = JSONDecoder()
func query<T: Decodable & Sendable>(_ method: String, _ params: [String: String?] = [:]) async throws -> T {
let nsid = "dev.knotless." + method
var parts = URLComponents(url: base.appendingPathComponent(nsid), resolvingAgainstBaseURL: false)!
parts.queryItems = params.compactMap { k, v in v.map { URLQueryItem(name: k, value: $0) } }.sorted { $0.name < $1.name }
var request = URLRequest(url: parts.url!)
request.timeoutInterval = 40
if let token = DevAccount.token(for: nsid) {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
guard (200..<300).contains(status) else {
let failure = try? decoder.decode(Failure.self, from: data)
throw APIError(status: status, name: failure?.error, message: failure?.message)
}
return try decoder.decode(T.self, from: data)
}
}
/// An account for trying the app before it has a sign-in: a handle to read
/// as, and service tokens handed over at launch, never kept or built in.
/// `ios/run.sh` mints them with goat.
enum DevAccount {
private static let env = ProcessInfo.processInfo.environment
static var actor: String? { env["KNOTLESS_ACTOR"] }
static var route: String? { env["KNOTLESS_ROUTE"] }
private static let tokens: [String: String] = {
guard let path = env["KNOTLESS_TOKENS"],
let data = FileManager.default.contents(atPath: path),
let map = try? JSONDecoder().decode([String: String].self, from: data)
else { return [:] }
return map
}()
static func token(for nsid: String) -> String? { tokens[nsid] }
}
ios/Knotless/API/Models.swiftnew+295−0
@@ -0,0 +1,295 @@
import Foundation
struct Person: Decodable, Hashable, Identifiable {
let did: String
let handle: String
var avatar: String?
var id: String { did }
}
/// A repository as the feed and notifications name it.
struct RepoRef: Decodable, Hashable {
let did: String
let handle: String
let name: String
var uri: String { "at://\(did)/dev.knotless.repo/\(name)" }
var label: String { "\(handle)/\(name)" }
}
struct Repo: Decodable, Identifiable, Hashable {
let uri: String
let owner: Person
let name: String
var description: String?
var starCount: Int?
var forkCount: Int?
var `private`: Bool?
var id: String { uri }
var ref: RepoRef { RepoRef(did: owner.did, handle: owner.handle, name: name) }
}
struct RepoList: Decodable {
var repos: [Repo] = []
}
struct RepoDetail: Decodable {
let uri: String
let owner: Person
let name: String
var description: String?
let defaultBranch: String
let openPulls: Int
let openIssues: Int
var starCount: Int?
var `private`: Bool?
}
struct ItemLabel: Decodable, Hashable {
let name: String
var color: String?
}
struct Item: Decodable, Identifiable, Hashable {
let uri: String
let kind: String
let title: String
let state: String
let author: Person
let createdAt: String
let comments: Int
var draft: Bool?
var labels: [ItemLabel]?
var id: String { uri }
}
struct ItemList: Decodable {
var items: [Item] = []
var openCount: Int?
var closedCount: Int?
var cursor: String?
}
struct DiffRow: Decodable, Hashable {
let kind: String
let line: Bool
var old: Int?
var new: Int?
let text: String
}
struct DiffFile: Decodable, Identifiable, Hashable {
let old: String
let new: String
let status: String
let added: Int
let removed: Int
var note: String?
var ranges: String?
var rows: [DiffRow] = []
var rowCount: Int?
var id: String { old + "\u{0}" + new }
var path: String { new.isEmpty ? old : new }
enum CodingKeys: String, CodingKey { case old, new, status, added, removed, note, ranges, rows, rowCount }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
old = try c.decode(String.self, forKey: .old)
new = try c.decode(String.self, forKey: .new)
status = try c.decode(String.self, forKey: .status)
added = try c.decode(Int.self, forKey: .added)
removed = try c.decode(Int.self, forKey: .removed)
note = try c.decodeIfPresent(String.self, forKey: .note)
ranges = try c.decodeIfPresent(String.self, forKey: .ranges)
rows = try c.decodeIfPresent([DiffRow].self, forKey: .rows) ?? []
rowCount = try c.decodeIfPresent(Int.self, forKey: .rowCount)
}
}
struct CommitAuthor: Decodable, Hashable {
var name: String?
var actor: Person?
}
struct Commit: Decodable, Identifiable, Hashable {
let sha: String
let message: String
var author: CommitAuthor?
var date: String?
var id: String { sha }
var subject: String { message.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? message }
}
struct Changes: Decodable {
var commits: [Commit]?
var files: [DiffFile]?
}
struct Comment: Decodable, Identifiable, Hashable {
var uri: String?
let author: Person
let createdAt: String
var body: String?
var verdict: String?
var id: String { uri ?? (author.did + createdAt) }
}
struct Check: Decodable, Identifiable, Hashable {
let uri: String
let workflow: String
let status: String
/// Why it was skipped, when it was.
var skipped: String?
var id: String { uri }
}
struct Review: Decodable, Hashable {
let reviewer: Person
var verdict: String?
var outdated: Bool?
}
struct PullHead: Decodable, Hashable {
var branch: String?
}
struct PullThread: Decodable {
let item: Item
var body: String?
var base: String?
var head: PullHead?
var merged: String?
var comments: [Comment]?
var reviews: [Review]?
var checks: [Check]?
var changes: Changes?
}
struct DiffAnswer: Decodable {
var files: [DiffFile] = []
}
struct TreeEntry: Decodable, Identifiable, Hashable {
let name: String
let kind: String
var size: Int?
var id: String { name }
}
struct Tree: Decodable {
var ref: String?
var path: String?
var lastCommit: Commit?
var entries: [TreeEntry]?
var readme: Readme?
var checks: [Check]?
struct Readme: Decodable { var name: String? }
}
struct FileAnswer: Decodable {
var ref: String?
var path: String?
var kind: String?
var size: Int?
var lines: Int?
var text: String?
var ranges: String?
}
struct Org: Decodable, Hashable { let actor: Person }
struct Profile: Decodable {
let did: String
let handle: String
var displayName: String?
var description: String?
var pronouns: String?
var avatar: String?
var followersCount: Int?
var followsCount: Int?
var orgs: [Org]?
}
struct InboxItem: Decodable, Identifiable, Hashable {
let uri: String
let author: Person
let reason: String
var reasonSubject: String?
var repo: RepoRef?
var title: String?
var verdict: String?
var text: String?
var subjectState: String?
let isRead: Bool
let createdAt: String
var id: String { uri + reason }
}
struct NotificationList: Decodable {
var notifications: [InboxItem] = []
var cursor: String?
}
struct FeedEvent: Decodable, Identifiable, Hashable {
let createdAt: String
let actor: Person
let repo: RepoRef
let action: Action
var id: String { createdAt + actor.did + repo.label + action.summary }
/// An open union: a kind this version doesn't know still shows as an event.
struct Action: Decodable, Hashable {
let type: String
var subject: String?
var title: String?
var state: String?
var text: String?
var verdict: String?
var ref: String?
var count: Int?
var forkOf: RepoRef?
enum CodingKeys: String, CodingKey {
case type = "$type", subject, title, state, text, verdict, ref, count, forkOf
}
var kind: String { type.split(separator: "#").last.map(String.init) ?? type }
var branch: String? { ref?.replacingOccurrences(of: "refs/heads/", with: "") }
var summary: String {
switch kind {
case "created": forkOf.map { "forked \($0.label)" } ?? "created this repository"
case "pushed":
if let tag = ref?.replacing("refs/tags/", with: ""), ref?.hasPrefix("refs/tags/") == true {
"pushed tag \(tag)"
} else if (count ?? 0) == 0 {
"pushed to \(branch ?? "a branch")"
} else {
count == 1 ? "pushed 1 commit to \(branch ?? "a branch")" : "pushed \(count ?? 0) commits to \(branch ?? "a branch")"
}
case "deleted": "deleted \(branch ?? "a branch")"
case "opened": "opened"
case "commented":
switch verdict {
case "approve": "approved"
case "changes": "asked for changes on"
default: "commented on"
}
case "changed":
switch state {
case "merged": "merged"
case "closed": "closed"
case "open": "reopened"
default: "changed"
}
case "starred": "starred this repository"
default: "did something new"
}
}
}
}
struct Feed: Decodable {
var events: [FeedEvent] = []
var cursor: String?
}
ios/Knotless/Assets.xcassets/oct-file-diff.imageset/Contents.jsonnew+1−0
@@ -0,0 +1 @@
{"images":[{"filename":"file-diff.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}}
ios/Knotless/Assets.xcassets/oct-file-diff.imageset/file-diff.svgnew+1−0
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1 1.75C1 .784 1.784 0 2.75 0h7.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073ZM8 3.25a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0V7h-1.5a.75.75 0 0 1 0-1.5h1.5V4A.75.75 0 0 1 8 3.25Zm-3 8a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z"/></svg>
\ No newline at end of file
ios/Knotless/Assets.xcassets/oct-git-commit.imageset/Contents.jsonnew+1−0
@@ -0,0 +1 @@
{"images":[{"filename":"git-commit.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}}
ios/Knotless/Assets.xcassets/oct-git-commit.imageset/git-commit.svgnew+1−0
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></svg>
\ No newline at end of file
ios/Knotless/Assets.xcassets/oct-git-merge.imageset/Contents.jsonnew+1−0
@@ -0,0 +1 @@
{"images":[{"filename":"git-merge.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}}
ios/Knotless/Assets.xcassets/oct-git-merge.imageset/git-merge.svgnew+1−0
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z"/></svg>
\ No newline at end of file
ios/Knotless/Assets.xcassets/oct-git-pull-request-closed.imageset/Contents.jsonnew+1−0
@@ -0,0 +1 @@
{"images":[{"filename":"git-pull-request-closed.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}}
ios/Knotless/Assets.xcassets/oct-git-pull-request-closed.imageset/git-pull-request-closed.svgnew+1−0
ios/Knotless/Assets.xcassets/oct-git-pull-request-draft.imageset/Contents.jsonnew+1−0
ios/Knotless/Assets.xcassets/oct-git-pull-request-draft.imageset/git-pull-request-draft.svgnew+1−0
ios/Knotless/Assets.xcassets/oct-git-pull-request.imageset/Contents.jsonnew+1−0
ios/Knotless/Assets.xcassets/oct-git-pull-request.imageset/git-pull-request.svgnew+1−0
ios/Knotless/Assets.xcassets/oct-issue-closed.imageset/Contents.jsonnew+1−0
ios/Knotless/Assets.xcassets/oct-issue-closed.imageset/issue-closed.svgnew+1−0
ios/Knotless/Assets.xcassets/oct-issue-opened.imageset/Contents.jsonnew+1−0
ios/Knotless/Assets.xcassets/oct-issue-opened.imageset/issue-opened.svgnew+1−0
ios/Knotless/Assets.xcassets/Contents.jsonnew+1−0
ios/Knotless/Support/Highlight.swiftnew+67−0
ios/Knotless/Support/Markdown.swiftnew+120−0
ios/Knotless/Support/Theme.swiftnew+85−0
ios/Knotless/Views/CodeViews.swiftnew+72−0
ios/Knotless/Views/ExploreView.swiftnew+89−0
ios/Knotless/Views/FilesView.swiftnew+169−0
ios/Knotless/Views/HomeView.swiftnew+61−0
ios/Knotless/Views/InboxView.swiftnew+175−0
ios/Knotless/Views/ProfileView.swiftnew+67−0
ios/Knotless/Views/RepoView.swiftnew+190−0
ios/Knotless/Views/Shared.swiftnew+181−0
ios/Knotless/Views/ThreadView.swiftnew+203−0
ios/Knotless/App.swiftnew+86−0
ios/KnotlessUITests/FlowTests.swiftnew+83−0
ios/ModelCheck/main.swiftnew+42−0
ios/.gitignorenew+3−0
ios/check-models.shnew+9−0
ios/project.ymlnew+45−0
ios/README.mdnew+39−0
ios/run.shnew+31−0
AGENTS.md+5−0