Commit d6b1523b2d
Unsigned
Layout: unified · split
gitbay/ContentView.swift +2
| @@ -78,6 +78,8 @@ private struct RouteDestinations: ViewModifier { | |||
| 78 | RepoSettingsView(client: client, repo: repo) | 78 | RepoSettingsView(client: client, repo: repo) |
| 79 | case .grep(let repo): | 79 | case .grep(let repo): |
| 80 | GrepView(client: client, repo: repo) | 80 | GrepView(client: client, repo: repo) |
| 81 | case .blame(let repo, let path, let ref): | ||
| 82 | BlameView(client: client, repo: repo, path: path, ref: ref) | ||
| 81 | case .profile(let name): | 83 | case .profile(let name): |
| 82 | ProfileView(client: client, name: name) | 84 | ProfileView(client: client, name: name) |
| 83 | case .account: | 85 | case .account: |
gitbay/Repos/BlameViewModel.swift added +102
| @@ -0,0 +1,102 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// `repo blame` — one hunk per run of consecutive lines sharing a commit. | ||
| 5 | nonisolated 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 | ||
| 44 | final 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 | |||
| 8 | final class FileViewModel { | 8 | final class FileViewModel { |
| 9 | 9 | ||
| 10 | private(set) var state: LoadState<FileContent> = .loading | 10 | private(set) var state: LoadState<FileContent> = .loading |
| 11 | private(set) var actionError: String? | ||
| 12 | private(set) var working = false | ||
| 11 | 13 | ||
| 12 | private let client: GitbayClient | 14 | private let client: GitbayClient |
| 13 | let repoPath: String | 15 | let repoPath: String |
| @@ -34,4 +36,36 @@ final class FileViewModel { | |||
| 34 | state = .from(error) | 36 | state = .from(error) |
| 35 | } | 37 | } |
| 36 | } | 38 | } |
| 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 | } | ||
| 37 | } | 71 | } |
gitbay/Views/Repos/BlameView.swift added +83
| @@ -0,0 +1,83 @@ | |||
| 1 | import 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. | ||
| 5 | struct 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. | ||
| 58 | private 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 @@ | |||
| 1 | import 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. | ||
| 6 | struct 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 { | |||
| 4 | 4 | ||
| 5 | @Environment(\.colorScheme) private var colorScheme | 5 | @Environment(\.colorScheme) private var colorScheme |
| 6 | @State private var model: FileViewModel | 6 | @State private var model: FileViewModel |
| 7 | @State private var editing = false | ||
| 8 | @State private var draft = "" | ||
| 7 | 9 | ||
| 8 | init(client: GitbayClient, repo: String, path: String, ref: String?) { | 10 | init(client: GitbayClient, repo: String, path: String, ref: String?) { |
| 9 | _model = State(initialValue: FileViewModel( | 11 | _model = State(initialValue: FileViewModel( |
| @@ -22,6 +24,37 @@ struct FileView: View { | |||
| 22 | .overlay { LoadStateOverlay(state: model.state) } | 24 | .overlay { LoadStateOverlay(state: model.state) } |
| 23 | .navigationTitle(model.fileName) | 25 | .navigationTitle(model.fileName) |
| 24 | .navigationBarTitleDisplayMode(.inline) | 26 | .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 | } | ||
| 25 | .task { await model.load() } | 58 | .task { await model.load() } |
| 26 | } | 59 | } |
| 27 | 60 | ||
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -9,6 +9,7 @@ nonisolated enum RepoRoute: Hashable { | |||
| 9 | case log(repo: String) | 9 | case log(repo: String) |
| 10 | case settings(repo: String) | 10 | case settings(repo: String) |
| 11 | case grep(repo: String) | 11 | case grep(repo: String) |
| 12 | case blame(repo: String, path: String, ref: String?) | ||
| 12 | case profile(String) | 13 | case profile(String) |
| 13 | case account | 14 | case account |
| 14 | case addAccount | 15 | case addAccount |
gitbayTests/BlameEditTests.swift added +177
| @@ -0,0 +1,177 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private 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 | |||
| 15 | private 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 | |||
| 20 | private 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 | ||
| 33 | struct 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 | ||
| 102 | struct 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 { | |||
| 163 | XCTFail("\(name) tab did not come to front", file: file, line: line) | 163 | XCTFail("\(name) tab did not come to front", file: file, line: line) |
| 164 | } | 164 | } |
| 165 | 165 | ||
| 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 | |||
| 166 | /// Tap a field and type into it, surviving the focus race: a tap can | 185 | /// Tap a field and type into it, surviving the focus race: a tap can |
| 167 | /// land before the field is ready, and the keystrokes go nowhere. | 186 | /// land before the field is ready, and the keystrokes go nowhere. |
| 168 | func focusAndType(_ element: XCUIElement, _ text: String, | 187 | func focusAndType(_ element: XCUIElement, _ text: String, |
| @@ -548,3 +567,65 @@ extension LiveSmokeUITests { | |||
| 548 | add(attachment) | 567 | add(attachment) |
| 549 | } | 568 | } |
| 550 | } | 569 | } |
| 570 | |||
| 571 | extension 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 | } | ||