a native ios client for gitbay

client ios swift

https://gitbay.org

blame and file editing, the last two parity rows !14

merged cmc wants to merge krz/gitbay-ios:blame-and-edit into main

9 files changed, +581 −0

gitbay/ContentView.swift +2
@@ -78,6 +78,8 @@ private struct RouteDestinations: ViewModifier {
7878 RepoSettingsView(client: client, repo: repo)
7979 case .grep(let repo):
8080 GrepView(client: client, repo: repo)
81 case .blame(let repo, let path, let ref):
82 BlameView(client: client, repo: repo, path: path, ref: ref)
8183 case .profile(let name):
8284 ProfileView(client: client, name: name)
8385 case .account:
gitbay/Repos/BlameViewModel.swift added +102
@@ -0,0 +1,102 @@
1import Foundation
2import Observation
3
4/// `repo blame` one hunk per run of consecutive lines sharing a commit.
5nonisolated struct BlameListing: Decodable, Sendable, Hashable {
6 let path: String
7 let ref: String
8 let file: String
9 let from: Int
10 let to: Int
11 let totalLines: Int
12 let hunks: [Hunk]
13
14 enum CodingKeys: String, CodingKey {
15 case path, ref, file, from, to, hunks
16 case totalLines = "total_lines"
17 }
18
19 nonisolated struct Hunk: Decodable, Sendable, Hashable, Identifiable {
20 let sha: String
21 let authorName: String
22 let authorEmail: String
23 let date: Date
24 let summary: String
25 let startLine: Int
26 let lines: [String]
27
28 enum CodingKeys: String, CodingKey {
29 case sha, summary, lines
30 case authorName = "author_name"
31 case authorEmail = "author_email"
32 case date
33 case startLine = "start_line"
34 }
35
36 var id: String { "\(sha):\(startLine)" }
37 var shortSHA: String { String(sha.prefix(10)) }
38 }
39}
40
41/// Line attribution for one file, paged the way the server pages it.
42@Observable
43@MainActor
44final class BlameViewModel {
45
46 private(set) var state: LoadState<BlameListing> = .loading
47 private(set) var isLoadingMore = false
48 /// Hunks accumulated across pages, in file order.
49 private(set) var hunks: [BlameListing.Hunk] = []
50
51 private let client: GitbayClient
52 let repoPath: String
53 let filePath: String
54 let ref: String?
55 private var loadedThrough = 0
56 private var totalLines = 0
57
58 /// The server caps one call at its own page size; asking for more
59 /// just gets clamped, so paging follows what it actually returned.
60 init(client: GitbayClient, repoPath: String, filePath: String, ref: String? = nil) {
61 self.client = client
62 self.repoPath = repoPath
63 self.filePath = filePath
64 self.ref = ref
65 }
66
67 var hasMore: Bool { totalLines > 0 && loadedThrough < totalLines }
68
69 var fileName: String {
70 String(filePath.split(separator: "/").last ?? "")
71 }
72
73 func load() async {
74 state = .loading
75 hunks = []
76 loadedThrough = 0
77 totalLines = 0
78 await fetch(from: 1)
79 }
80
81 func loadMore() async {
82 guard hasMore, !isLoadingMore else { return }
83 isLoadingMore = true
84 defer { isLoadingMore = false }
85 await fetch(from: loadedThrough + 1)
86 }
87
88 private func fetch(from: Int) async {
89 var argv = ["repo", "blame", repoPath, filePath, "--from", String(from)]
90 if let ref { argv.append(contentsOf: ["--ref", ref]) }
91 do {
92 let page = try await client.read(argv, as: BlameListing.self)
93 totalLines = page.totalLines
94 loadedThrough = max(loadedThrough, page.to)
95 hunks.append(contentsOf: page.hunks)
96 state = .loaded(page)
97 } catch {
98 // A later page failing leaves what is on screen alone.
99 if hunks.isEmpty { state = .from(error) }
100 }
101 }
102}
gitbay/Repos/FileViewModel.swift +34
@@ -8,6 +8,8 @@ import Observation
88 final class FileViewModel {
99
1010 private(set) var state: LoadState<FileContent> = .loading
11 private(set) var actionError: String?
12 private(set) var working = false
1113
1214 private let client: GitbayClient
1315 let repoPath: String
@@ -34,4 +36,36 @@ final class FileViewModel {
3436 state = .from(error)
3537 }
3638 }
39
40 /// The branch an edit commits to. `repo cat` echoes the ref it read,
41 /// so editing writes back to whatever is on screen.
42 var editableBranch: String? {
43 guard let file = state.value, !file.binary, !file.isTruncated else { return nil }
44 return file.ref
45 }
46
47 /// `repo commit-file` the server refuses when the repository wants
48 /// signed commits, when the account has no verified email, or when
49 /// the repo is archived, and says which.
50 func commit(content: String, message: String) async -> Bool {
51 guard let branch = editableBranch else { return false }
52 working = true
53 actionError = nil
54 defer { working = false }
55 var argv = ["repo", "commit-file", repoPath, filePath, "--ref", branch, "--file", "-"]
56 let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
57 if !trimmed.isEmpty {
58 argv.append(contentsOf: ["--message", trimmed])
59 }
60 do {
61 try await client.run(argv, stdin: content)
62 await load()
63 return true
64 } catch let error as GitbayError {
65 actionError = error.userFacingMessage
66 } catch {
67 actionError = GitbayError.transport(error).userFacingMessage
68 }
69 return false
70 }
3771 }
gitbay/Views/Repos/BlameView.swift added +83
@@ -0,0 +1,83 @@
1import SwiftUI
2
3/// Who last touched each line, and why. Hunks share a commit, so the
4/// attribution shows once per run rather than once per line.
5struct BlameView: View {
6
7 @State private var model: BlameViewModel
8
9 init(client: GitbayClient, repo: String, path: String, ref: String?) {
10 _model = State(initialValue: BlameViewModel(
11 client: client, repoPath: repo, filePath: path, ref: ref
12 ))
13 }
14
15 var body: some View {
16 ZStack {
17 Color.clear
18 if !model.hunks.isEmpty {
19 ScrollView([.horizontal, .vertical]) {
20 VStack(alignment: .leading, spacing: 0) {
21 ForEach(model.hunks) { hunk in
22 HunkAttribution(hunk: hunk)
23 ForEach(Array(hunk.lines.enumerated()), id: \.offset) { index, line in
24 HStack(alignment: .top, spacing: 0) {
25 Text(String(hunk.startLine + index))
26 .frame(width: 44, alignment: .trailing)
27 .foregroundStyle(.tertiary)
28 Text(line.isEmpty ? " " : line)
29 .padding(.leading, 10)
30 }
31 .font(.gbMono(.caption2))
32 }
33 }
34 if model.hasMore {
35 HStack {
36 Spacer()
37 ProgressView()
38 Spacer()
39 }
40 .padding(.vertical, 12)
41 .task(id: model.hunks.count) { await model.loadMore() }
42 }
43 }
44 .padding(.vertical, 8)
45 }
46 }
47 }
48 .overlay { LoadStateOverlay(state: model.state) }
49 .navigationTitle("Blame")
50 .navigationBarTitleDisplayMode(.inline)
51 .task {
52 if model.hunks.isEmpty { await model.load() }
53 }
54 }
55}
56
57/// The commit a run of lines belongs to.
58private struct HunkAttribution: View {
59 let hunk: BlameListing.Hunk
60
61 var body: some View {
62 HStack(spacing: 8) {
63 Text(hunk.shortSHA)
64 .font(.gbMono(.caption2))
65 .foregroundStyle(Color.gbAccent)
66 Text(hunk.summary)
67 .font(.gbSans(.caption2))
68 .lineLimit(1)
69 Spacer(minLength: 12)
70 Text(hunk.authorName)
71 .font(.gbSans(.caption2))
72 .foregroundStyle(.secondary)
73 .lineLimit(1)
74 Text(hunk.date, format: .dateTime.year().month(.abbreviated).day())
75 .font(.gbSans(.caption2))
76 .foregroundStyle(.tertiary)
77 }
78 .padding(.horizontal, 10)
79 .padding(.vertical, 4)
80 .frame(minWidth: 360, alignment: .leading)
81 .background(Color.gbFillSubtle)
82 }
83}
gitbay/Views/Repos/FileEditSheet.swift added +68
@@ -0,0 +1,68 @@
1import SwiftUI
2
3/// Edit one file and commit it. The commit is unsigned the server
4/// authors it so a repository requiring signatures refuses, and the
5/// refusal is what the sheet shows.
6struct FileEditSheet: View {
7
8 let model: FileViewModel
9 let branch: String
10 @Binding var content: String
11 let onCommitted: () -> Void
12
13 @Environment(\.dismiss) private var dismiss
14 @State private var message = ""
15
16 var body: some View {
17 NavigationStack {
18 Form {
19 Section {
20 TextEditor(text: $content)
21 .font(.gbMono(.caption))
22 .frame(minHeight: 260)
23 .autocorrectionDisabled()
24 .textInputAutocapitalization(.never)
25 .accessibilityIdentifier("file-edit-content")
26 } header: {
27 Text(model.fileName)
28 } footer: {
29 Text("Commits to \(branch) as an unsigned commit.")
30 }
31 Section("Message") {
32 TextField("edit \(model.fileName)", text: $message)
33 .autocorrectionDisabled()
34 .accessibilityIdentifier("file-edit-message")
35 }
36 if let error = model.actionError {
37 Section {
38 Label(error, systemImage: "hand.raised")
39 .foregroundStyle(Color.gbWarn)
40 .font(.gbSans(.subheadline))
41 }
42 }
43 }
44 .navigationTitle("Edit")
45 .navigationBarTitleDisplayMode(.inline)
46 .toolbar {
47 ToolbarItem(placement: .cancellationAction) {
48 Button("Cancel") { dismiss() }
49 }
50 ToolbarItem(placement: .confirmationAction) {
51 if model.working {
52 ProgressView()
53 } else {
54 Button("Commit") {
55 Task {
56 if await model.commit(content: content, message: message) {
57 onCommitted()
58 }
59 }
60 }
61 .accessibilityIdentifier("file-edit-commit")
62 }
63 }
64 }
65 .interactiveDismissDisabled(model.working)
66 }
67 }
68}
gitbay/Views/Repos/FileView.swift +33
@@ -4,6 +4,8 @@ struct FileView: View {
44
55 @Environment(\.colorScheme) private var colorScheme
66 @State private var model: FileViewModel
7 @State private var editing = false
8 @State private var draft = ""
79
810 init(client: GitbayClient, repo: String, path: String, ref: String?) {
911 _model = State(initialValue: FileViewModel(
@@ -22,6 +24,37 @@ struct FileView: View {
2224 .overlay { LoadStateOverlay(state: model.state) }
2325 .navigationTitle(model.fileName)
2426 .navigationBarTitleDisplayMode(.inline)
27 .toolbar {
28 ToolbarItem(placement: .topBarTrailing) {
29 if model.state.value?.binary == false {
30 Menu {
31 NavigationLink(value: RepoRoute.blame(
32 repo: model.repoPath, path: model.filePath, ref: model.ref
33 )) {
34 Label("Blame", systemImage: "person.crop.rectangle.stack")
35 }
36 if let branch = model.editableBranch {
37 Button {
38 draft = model.state.value?.content ?? ""
39 editing = true
40 } label: {
41 Label("Edit on \(branch)", systemImage: "pencil")
42 }
43 }
44 } label: {
45 Image(systemName: "ellipsis.circle")
46 }
47 .accessibilityIdentifier("file-actions-menu")
48 }
49 }
50 }
51 .sheet(isPresented: $editing) {
52 if let branch = model.editableBranch {
53 FileEditSheet(model: model, branch: branch, content: $draft) {
54 editing = false
55 }
56 }
57 }
2558 .task { await model.load() }
2659 }
2760
gitbay/Views/Repos/RepoRoute.swift +1
@@ -9,6 +9,7 @@ nonisolated enum RepoRoute: Hashable {
99 case log(repo: String)
1010 case settings(repo: String)
1111 case grep(repo: String)
12 case blame(repo: String, path: String, ref: String?)
1213 case profile(String)
1314 case account
1415 case addAccount
gitbayTests/BlameEditTests.swift added +177
@@ -0,0 +1,177 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) {
16 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
18}
19
20private func blamePage(from: Int, to: Int, total: Int, startLine: Int, lines: [String]) -> String {
21 let joined = lines.map { "\"\($0)\"" }.joined(separator: ",")
22 return """
23 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"go.mod",\
24 "from":\(from),"to":\(to),"total_lines":\(total),"hunks":[\
25 {"sha":"24c0aad9bf75f925497b0f6de2c8e831e67eaac9","author_name":"Christian Cleberg",\
26 "author_email":"hello@cleberg.net","date":"2026-08-24T00:14:19Z",\
27 "summary":"rename: forge -> gitbay","start_line":\(startLine),"lines":[\(joined)]}\
28 ]},"exit_code":0}
29 """
30}
31
32@MainActor
33struct BlameViewModelTests {
34
35 @Test func loadsTheFirstPageAndDecodesHunks() async throws {
36 let (client, stub) = try makeClient()
37 stub.enqueue(.init(status: 200, json:
38 blamePage(from: 1, to: 2, total: 2, startLine: 1, lines: ["module gitbay.org/gitbay", ""])))
39 let model = BlameViewModel(client: client, repoPath: "krz/gitbay", filePath: "go.mod")
40
41 await model.load()
42
43 #expect(model.hunks.count == 1)
44 #expect(model.hunks[0].shortSHA == "24c0aad9bf")
45 #expect(model.hunks[0].authorName == "Christian Cleberg")
46 #expect(!model.hasMore)
47 let seen = try #require(stub.seen.first)
48 #expect(seen.url.query() ==
49 "argv=repo&argv=blame&argv=krz/gitbay&argv=go.mod&argv=--from&argv=1")
50 }
51
52 @Test func pagesFromWhereTheServerStopped() async throws {
53 let (client, stub) = try makeClient()
54 // The server clamps its own span; paging follows what came back.
55 stub.enqueue(.init(status: 200, json:
56 blamePage(from: 1, to: 1000, total: 1500, startLine: 1, lines: ["a"])))
57 stub.enqueue(.init(status: 200, json:
58 blamePage(from: 1001, to: 1500, total: 1500, startLine: 1001, lines: ["b"])))
59 let model = BlameViewModel(client: client, repoPath: "krz/gitbay", filePath: "big.go")
60
61 await model.load()
62 #expect(model.hasMore)
63
64 await model.loadMore()
65 #expect(model.hunks.count == 2)
66 #expect(!model.hasMore)
67 #expect(stub.seen[1].url.query()?.contains("argv=--from&argv=1001") == true)
68
69 // Nothing further to ask for.
70 await model.loadMore()
71 #expect(stub.seen.count == 2)
72 }
73
74 @Test func refIsForwardedWhenSet() async throws {
75 let (client, stub) = try makeClient()
76 stub.enqueue(.init(status: 200, json:
77 blamePage(from: 1, to: 1, total: 1, startLine: 1, lines: ["x"])))
78 let model = BlameViewModel(
79 client: client, repoPath: "krz/gitbay", filePath: "go.mod", ref: "dev")
80
81 await model.load()
82
83 #expect(stub.seen.first?.url.query()?.hasSuffix("argv=--ref&argv=dev") == true)
84 }
85
86 @Test func aBinaryFileRefusalIsTheScreensState() async throws {
87 let (client, stub) = try makeClient()
88 stub.enqueue(.init(status: 400, json:
89 #"{"protocol_version":1,"error":"logo.png is binary; there is nothing to attribute","exit_code":2}"#))
90 let model = BlameViewModel(client: client, repoPath: "krz/gitbay", filePath: "logo.png")
91
92 await model.load()
93
94 guard case .failed = model.state else {
95 Issue.record("expected .failed, got \(model.state)")
96 return
97 }
98 }
99}
100
101@MainActor
102struct FileEditTests {
103
104 private let textFile = """
105 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"notes.txt",\
106 "size":5,"binary":false,"content":"hello"},"exit_code":0}
107 """
108
109 private func loadedModel(_ json: String? = nil) async throws -> (FileViewModel, StubProtocol.Box) {
110 let (client, stub) = try makeClient()
111 stub.enqueue(.init(status: 200, json: json ?? textFile, match: "argv=cat"))
112 let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "notes.txt")
113 await model.load()
114 return (model, stub)
115 }
116
117 @Test func aTextFileIsEditableOnTheRefItWasReadFrom() async throws {
118 let (model, _) = try await loadedModel()
119 #expect(model.editableBranch == "main")
120 }
121
122 @Test func binaryAndTruncatedFilesAreNotEditable() async throws {
123 let (binary, _) = try await loadedModel("""
124 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"logo.png",\
125 "size":4,"binary":true,"base64":"iVBORw=="},"exit_code":0}
126 """)
127 #expect(binary.editableBranch == nil)
128
129 let (truncated, _) = try await loadedModel("""
130 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"big.log",\
131 "size":10,"binary":false,"truncated":true,"content":"partial..."},"exit_code":0}
132 """)
133 // Committing a truncated read would delete the rest of the file.
134 #expect(truncated.editableBranch == nil)
135 }
136
137 @Test func commitSendsContentOverStdinWithTheBranch() async throws {
138 let (model, stub) = try await loadedModel()
139 stub.enqueue(.init(status: 200, json:
140 #"{"protocol_version":1,"data":{"sha":"abc"},"exit_code":0}"#, match: "cmd"))
141 stub.enqueue(.init(status: 200, json: textFile, match: "argv=cat"))
142
143 let ok = await model.commit(content: "goodbye\n", message: " update notes ")
144
145 #expect(ok)
146 let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
147 #expect(argv == ["repo", "commit-file", "krz/gitbay", "notes.txt",
148 "--ref", "main", "--file", "-", "--message", "update notes"])
149 #expect(stdin == "goodbye\n")
150 }
151
152 @Test func anEmptyMessageLetsTheServerNameTheCommit() async throws {
153 let (model, stub) = try await loadedModel()
154 stub.enqueue(.init(status: 200, json:
155 #"{"protocol_version":1,"data":{"sha":"abc"},"exit_code":0}"#, match: "cmd"))
156 stub.enqueue(.init(status: 200, json: textFile, match: "argv=cat"))
157
158 _ = await model.commit(content: "x", message: " ")
159
160 let (argv, _) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
161 #expect(!argv.contains("--message"))
162 }
163
164 @Test func aSignedCommitPolicyRefusalSurfacesVerbatim() async throws {
165 let (model, stub) = try await loadedModel()
166 stub.enqueue(.init(status: 403, json:
167 #"{"protocol_version":1,"error":"krz/gitbay requires signed commits; this writes an unsigned one push a signed commit instead","exit_code":4}"#,
168 match: "cmd"))
169
170 let ok = await model.commit(content: "x", message: "")
171
172 #expect(!ok)
173 #expect(model.actionError?.contains("requires signed commits") == true)
174 // The file is still on screen.
175 #expect(model.state.value != nil)
176 }
177}
gitbayUITests/LiveSmokeUITests.swift +81
@@ -163,6 +163,25 @@ final class LiveSmokeUITests: XCTestCase {
163163 XCTFail("\(name) tab did not come to front", file: file, line: line)
164164 }
165165
166 /// Empty a text field before typing. Search fields keep their query
167 /// across navigation, and the clear button is not reliably
168 /// addressable, so this deletes character by character.
169 func clearAndType(_ element: XCUIElement, _ text: String,
170 file: StaticString = #filePath, line: UInt = #line) {
171 element.tap()
172 if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
173 element.tap()
174 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
175 "keyboard never appeared", file: file, line: line)
176 }
177 if let existing = element.value as? String,
178 existing != element.placeholderValue, !existing.isEmpty {
179 element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue,
180 count: existing.count))
181 }
182 element.typeText(text)
183 }
184
166185 /// Tap a field and type into it, surviving the focus race: a tap can
167186 /// land before the field is ready, and the keystrokes go nowhere.
168187 func focusAndType(_ element: XCUIElement, _ text: String,
@@ -548,3 +567,65 @@ extension LiveSmokeUITests {
548567 add(attachment)
549568 }
550569 }
570
571extension LiveSmokeUITests {
572
573 /// Blame and file editing the two capabilities that were web-only
574 /// until krz/gitbay#42 and #43 made them commands. Blame is read-only
575 /// on a real repo; the edit writes to a scratch repo the runner
576 /// creates and deletes over SSH.
577 func testBlameAndEditFlows() throws {
578 // --- blame on a real file ---
579 openRepo("krz/gitbay")
580 app.staticTexts["Files"].firstMatch.tap()
581 let goMod = app.staticTexts["go.mod"].firstMatch
582 XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree")
583 goMod.tap()
584 XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "file-actions-menu")
585 .firstMatch.waitForExistence(timeout: 15), "file screen did not open")
586 app.descendants(matching: .any).matching(identifier: "file-actions-menu")
587 .firstMatch.tap()
588 app.buttons["Blame"].firstMatch.tap()
589 // Attribution shows the commit subject beside the lines.
590 XCTAssertTrue(app.staticTexts
591 .containing(NSPredicate(format: "label CONTAINS 'gitbay'")).firstMatch
592 .waitForExistence(timeout: 20), "blame rendered no attribution")
593
594 // --- edit on the scratch repo ---
595 // Relaunch rather than reuse the list: `.searchable` keeps the
596 // previous query, and clearing it from the harness is unreliable.
597 app.terminate()
598 app.launch()
599 openRepo("cmc/ui-smoke-edit")
600 app.staticTexts["Files"].firstMatch.tap()
601 let notes = app.staticTexts["notes.txt"].firstMatch
602 XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree")
603 notes.tap()
604
605 let menu = app.descendants(matching: .any)
606 .matching(identifier: "file-actions-menu").firstMatch
607 XCTAssertTrue(menu.waitForExistence(timeout: 15))
608 menu.tap()
609 let edit = app.buttons
610 .containing(NSPredicate(format: "label BEGINSWITH 'Edit on'")).firstMatch
611 XCTAssertTrue(edit.waitForExistence(timeout: 5), "edit action missing")
612 edit.tap()
613
614 let content = app.descendants(matching: .any)
615 .matching(identifier: "file-edit-content").firstMatch
616 XCTAssertTrue(content.waitForExistence(timeout: 10), "edit sheet did not open")
617 focusAndType(content, " edited from the app")
618
619 let message = app.descendants(matching: .any)
620 .matching(identifier: "file-edit-message").firstMatch
621 focusAndType(message, "edit from ios")
622
623 app.descendants(matching: .any).matching(identifier: "file-edit-commit")
624 .firstMatch.tap()
625 XCTAssertTrue(waitForDisappearance(content, timeout: 20), "commit did not dismiss")
626 // The reloaded file shows the committed text.
627 XCTAssertTrue(app.staticTexts
628 .containing(NSPredicate(format: "label CONTAINS 'edited from the app'")).firstMatch
629 .waitForExistence(timeout: 20), "edit not reflected after commit")
630 }
631}