chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Commits in the iOS app open their diff and details
ios/Knotless/API/Models.swift+31−0
| @@ -206,18 +206,49 @@ struct DiffFile: Decodable, Identifiable, Hashable { | ||
struct CommitAuthor: Decodable, Hashable { | ||
var name: String? | ||
var actor: Person? | ||
var label: String { actor?.handle ?? name ?? "" } | ||
} | ||
| ||
struct Commit: Decodable, Identifiable, Hashable { | ||
let sha: String | ||
let message: String | ||
var author: CommitAuthor? | ||
var coauthors: [CommitAuthor]? | ||
var committer: CommitAuthor? | ||
var parents: [Parent]? | ||
var date: String? | ||
var committedAt: String? | ||
var pull: Pull? | ||
var id: String { sha } | ||
var subject: String { message.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? message } | ||
/// What its message says after its subject. | ||
var details: String { | ||
message.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false).dropFirst().first | ||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? "" | ||
} | ||
var when: String? { committedAt ?? date } | ||
var authors: [CommitAuthor] { (author.map { [$0] } ?? []) + (coauthors ?? []) } | ||
/// Who committed it, where that's someone other than those who wrote it. | ||
var otherCommitter: CommitAuthor? { | ||
guard let committer, !authors.contains(where: { $0.label == committer.label }) else { return nil } | ||
return committer | ||
} | ||
| ||
struct Parent: Decodable, Hashable { let sha: String } | ||
struct Pull: Decodable, Hashable { | ||
let uri: String | ||
var title: String? | ||
} | ||
} | ||
| ||
struct CommitAnswer: Decodable { | ||
let commit: Commit | ||
var files: [DiffFile] = [] | ||
} | ||
| ||
struct Changes: Decodable { | ||
/// Where its commits can be read, which for an open pull from a fork is the fork. | ||
var repo: RepoDetail.Basic? | ||
var commits: [Commit]? | ||
var files: [DiffFile]? | ||
} | ||
ios/Knotless/Assets.xcassets/oct-check.imageset/check.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="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg> | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/oct-check.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"check.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/oct-copy.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"copy.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/oct-copy.imageset/copy.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="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg> | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/oct-file-moved.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"file-moved.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/oct-file-moved.imageset/file-moved.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="M2 1.75C2 .784 2.784 0 3.75 0h6.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 16h-3.5a.75.75 0 0 1 0-1.5h3.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-.073H3.75a.25.25 0 0 0-.25.25v6.5a.75.75 0 0 1-1.5 0v-6.5Z"/><path d="m5.427 15.573 3.146-3.146a.25.25 0 0 0 0-.354L5.427 8.927A.25.25 0 0 0 5 9.104V11.5H.75a.75.75 0 0 0 0 1.5H5v2.396c0 .223.27.335.427.177Z"/></svg> | ||
| \ No newline at end of file | ||
ios/Knotless/Views/CommitView.swiftnew+226−0
| @@ -0,0 +1,226 @@ | ||
import SwiftUI | ||
| ||
/// A commit in a list: its subject, the start of the rest, and who made it. | ||
struct CommitRow: View { | ||
let repo: RepoRef | ||
let commit: Commit | ||
| ||
var body: some View { | ||
VStack(alignment: .leading, spacing: 5) { | ||
HStack(alignment: .firstTextBaseline, spacing: 10) { | ||
Text(commit.subject).font(.body.weight(.semibold)).lineLimit(1) | ||
Spacer(minLength: 0) | ||
Text(When.short(commit.when)).font(.subheadline).foregroundStyle(.secondary).fixedSize() | ||
} | ||
if !commit.details.isEmpty { | ||
Text(commit.details.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)) | ||
.font(.footnote.monospaced()) | ||
.foregroundStyle(.secondary) | ||
.lineLimit(1) | ||
} | ||
CommitPeople(commit: commit, whole: false) | ||
} | ||
.background { | ||
// Behind the row, so that the list draws no chevron for it. | ||
NavigationLink(value: Route.commit(repo, sha: commit.sha)) { EmptyView() }.opacity(0) | ||
} | ||
} | ||
} | ||
| ||
/// Their faces, and who wrote it. A list stops there: a queued pull lands as | ||
/// the squash its runner commits, so saying who committed each would name that | ||
/// runner on every row. The commit's own page says, when `whole`. | ||
private struct CommitPeople: View { | ||
let commit: Commit | ||
let whole: Bool | ||
| ||
private func names(_ people: [CommitAuthor]) -> Text { | ||
var out = Text("") | ||
for (i, person) in people.enumerated() { | ||
if i > 0 { | ||
let joiner = i == people.count - 1 ? " and " : ", " | ||
out = Text("\(out)\(Text(joiner).foregroundStyle(.secondary))") | ||
} | ||
out = Text("\(out)\(Text(person.label).fontWeight(.semibold))") | ||
} | ||
return out | ||
} | ||
| ||
var body: some View { | ||
let authors = commit.authors | ||
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) | ||
} | ||
} | ||
if whole, let committer = commit.otherCommitter { | ||
Text("\(names(authors))\(Text(" authored and ").foregroundStyle(.secondary))\(names([committer]))\(Text(" committed").foregroundStyle(.secondary))") | ||
} else { | ||
Text("\(names(authors))\(Text(" committed").foregroundStyle(.secondary))") | ||
} | ||
} | ||
.font(.subheadline) | ||
} | ||
} | ||
| ||
struct CommitView: View { | ||
let repo: RepoRef | ||
let sha: String | ||
| ||
private enum Tab: Hashable { case changes, details } | ||
@State private var tab = Tab.changes | ||
| ||
var body: some View { | ||
Loading(load: { [repo, sha] in | ||
let answer: CommitAnswer = try await Client.shared.query("getCommit", ["repo": repo.uri, "sha": sha]) | ||
return answer | ||
}) { answer in | ||
switch tab { | ||
case .changes: CommitChanges(repo: repo, answer: answer) | ||
case .details: CommitDetails(repo: repo, commit: answer.commit) | ||
} | ||
} | ||
.navigationBarTitleDisplayMode(.inline) | ||
.toolbar { | ||
ToolbarItem(placement: .principal) { | ||
Picker("Show", selection: $tab) { | ||
Text("Changes").tag(Tab.changes) | ||
Text("Details").tag(Tab.details) | ||
} | ||
.pickerStyle(.segmented) | ||
.fixedSize() | ||
} | ||
if let url = URL(string: "https://knotless.dev/\(repo.handle)/\(repo.name)/commit/\(sha)") { | ||
ToolbarItem(placement: .topBarTrailing) { ShareLink(item: url) } | ||
} | ||
} | ||
} | ||
} | ||
| ||
/// What it says, then what it changed, file by file. | ||
private struct CommitChanges: View { | ||
let repo: RepoRef | ||
let answer: CommitAnswer | ||
| ||
private var commit: Commit { answer.commit } | ||
| ||
var body: some View { | ||
ScrollViewReader { reader in | ||
ScrollView { | ||
VStack(alignment: .leading, spacing: 12) { | ||
HStack(alignment: .firstTextBaseline, spacing: 10) { | ||
CommitPeople(commit: commit, whole: true) | ||
Spacer(minLength: 0) | ||
Text(When.short(commit.when)).font(.subheadline).foregroundStyle(.secondary).fixedSize() | ||
} | ||
Text(commit.subject).font(.headline) | ||
if !commit.details.isEmpty { MarkdownView(source: commit.details) } | ||
Text(summary).font(.footnote).foregroundStyle(.secondary) | ||
} | ||
.padding(16) | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { | ||
ForEach(answer.files) { file in | ||
FileSection(repo: repo, file: file).id(file.id) | ||
} | ||
} | ||
.padding(.bottom, 60) | ||
} | ||
.overlay(alignment: .bottomTrailing) { | ||
if answer.files.count > 1 { | ||
Menu { | ||
ForEach(answer.files) { file in | ||
Button(file.path) { withAnimation { reader.scrollTo(file.id, anchor: .top) } } | ||
} | ||
} label: { | ||
SwiftUI.Label { Text("Jump to file") } icon: { Oct(name: "file-moved") }.labelStyle(TightLabel()) | ||
} | ||
.floating() | ||
.padding(16) | ||
} | ||
} | ||
} | ||
} | ||
| ||
private var summary: String { | ||
let files = answer.files.count == 1 ? "1 file changed" : "\(answer.files.count) files changed" | ||
let added = answer.files.reduce(0) { $0 + $1.added } | ||
let removed = answer.files.reduce(0) { $0 + $1.removed } | ||
return "\(files), +\(added) -\(removed)" | ||
} | ||
} | ||
| ||
/// Its name, who made it, what it follows and the pull that brought it in. | ||
private struct CommitDetails: View { | ||
let repo: RepoRef | ||
let commit: Commit | ||
| ||
@State private var copied = false | ||
| ||
var body: some View { | ||
List { | ||
Section("Commit") { | ||
HStack(spacing: 10) { | ||
Text(commit.sha).font(.body.monospaced()).lineLimit(1).truncationMode(.tail) | ||
Spacer(minLength: 0) | ||
Button { | ||
UIPasteboard.general.string = commit.sha | ||
copied = true | ||
} label: { | ||
Oct(name: copied ? "check" : "copy", size: 20).foregroundStyle(copied ? Color.success : Color.accent) | ||
} | ||
.buttonStyle(.borderless) | ||
.accessibilityLabel(copied ? "Copied" : "Copy") | ||
} | ||
} | ||
Section("Contributors") { | ||
ForEach(Array(contributors.enumerated()), id: \.offset) { _, one in | ||
if let handle = one.person.actor?.handle { | ||
NavigationLink(value: Route.profile(handle)) { person(one.person, one.role) } | ||
} else { | ||
person(one.person, one.role) | ||
} | ||
} | ||
} | ||
if let parents = commit.parents, !parents.isEmpty { | ||
Section(parents.count == 1 ? "Parent" : "Parents") { | ||
ForEach(parents, id: \.sha) { parent in | ||
NavigationLink(value: Route.commit(repo, sha: parent.sha)) { | ||
HStack(spacing: 10) { | ||
Oct(name: "git-commit", size: 20).foregroundStyle(.secondary) | ||
Text(parent.sha).font(.body.monospaced()).lineLimit(1).truncationMode(.tail) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
if let pull = commit.pull { | ||
Section("Pull") { | ||
NavigationLink(value: Route.thread(repo, uri: pull.uri)) { | ||
HStack(spacing: 10) { | ||
Oct(name: "git-pull-request", size: 20).foregroundStyle(.secondary) | ||
Text(pull.title ?? "This commit's pull").lineLimit(2) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
| ||
private var contributors: [(person: CommitAuthor, role: String)] { | ||
var out = (commit.author.map { [(person: $0, role: "Author")] } ?? []) | ||
out += (commit.coauthors ?? []).map { (person: $0, role: "Co-author") } | ||
if let committer = commit.otherCommitter { out.append((person: committer, role: "Committer")) } | ||
return out | ||
} | ||
| ||
private func person(_ person: CommitAuthor, _ role: String) -> some View { | ||
HStack(spacing: 12) { | ||
Avatar(url: person.actor?.avatar, name: person.label, size: 32) | ||
Text(person.label).lineLimit(1) | ||
Spacer(minLength: 8) | ||
Text(role).foregroundStyle(.secondary) | ||
} | ||
} | ||
} | ||
ios/Knotless/Views/FilesView.swift+4−20
| @@ -41,9 +41,10 @@ private extension View { | ||
} | ||
} | ||
| ||
private struct FileSection: View { | ||
struct FileSection: View { | ||
let repo: RepoRef | ||
let uri: String | ||
/// The pull whose rows to ask for, if they aren't in `file`. | ||
var uri: String? | ||
let file: DiffFile | ||
| ||
struct Line: Identifiable { | ||
| @@ -96,7 +97,7 @@ private struct FileSection: View { | ||
| ||
private func load() async { | ||
var source = file | ||
if source.rows.isEmpty, source.note == nil { | ||
if source.rows.isEmpty, source.note == nil, let uri { | ||
let answer: DiffAnswer? = try? await Client.shared.query( | ||
"getPullDiff", ["repo": repo.uri, "uri": uri, "paths": file.path]) | ||
guard let found = answer?.files.first else { | ||
| @@ -167,20 +168,3 @@ private struct DiffLine: View { | ||
(line.row.kind == "d" ? line.row.old : line.row.new ?? line.row.old).map(String.init) ?? "" | ||
} | ||
} | ||
| ||
struct CommitRow: View { | ||
let commit: Commit | ||
| ||
var body: some View { | ||
VStack(alignment: .leading, spacing: 4) { | ||
Text(commit.subject).lineLimit(3) | ||
HStack(spacing: 6) { | ||
Text(String(commit.sha.prefix(7))).font(.footnote.monospaced()) | ||
Text(commit.author?.actor?.handle ?? commit.author?.name ?? "") | ||
Text(When.short(commit.date)) | ||
} | ||
.font(.footnote) | ||
.foregroundStyle(.secondary) | ||
} | ||
} | ||
} | ||
ios/Knotless/Views/RepoView.swift+2−2
| @@ -544,9 +544,9 @@ struct HistoryView: View { | ||
var body: some View { | ||
PagedList<Commit, CommitRow>(empty: "No commits yet.", fetch: { [repo, ref] cursor in | ||
let list: CommitList = try await Client.shared.query( | ||
"listCommits", ["repo": repo.uri, "ref": ref, "subjectOnly": "true", "cursor": cursor]) | ||
"listCommits", ["repo": repo.uri, "ref": ref, "limit": "30", "cursor": cursor]) | ||
return .init(items: list.commits, cursor: list.cursor) | ||
}) { CommitRow(commit: $0) } | ||
}) { CommitRow(repo: repo, commit: $0) } | ||
.navigationTitle("Commits") | ||
.navigationBarTitleDisplayMode(.inline) | ||
} | ||