Commit 26e787b104

26e787b104d5fa03034556a4844e3216401a5ff8

parent: b475734cc0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-07 01:29 UTC

Edit your profile from the app

Layout: unified · split

gitbay/Discovery/ProfileViewModel.swift +11 −2
@@ -123,12 +123,21 @@ final class ProfileViewModel {
123123 return actionError == nil
124124 }
125125
126 private func perform(_ argv: [String]) async {
126 /// `profile set` — always the caller's own profile. The about text
127 /// travels over stdin, never argv, since it is long-form and may
128 /// carry newlines.
129 func saveProfile(_ edit: ProfileEdit) async -> Bool {
130 guard edit.validationError == nil else { return false }
131 await perform(["profile", "set"] + edit.flags(), stdin: edit.aboutStdin)
132 return actionError == nil
133 }
134
135 private func perform(_ argv: [String], stdin: String? = nil) async {
127136 working = true
128137 actionError = nil
129138 defer { working = false }
130139 do {
131 try await client.run(argv)
140 try await client.run(argv, stdin: stdin)
132141 await load()
133142 } catch let error as GitbayError {
134143 actionError = error.userFacingMessage
gitbay/Views/Discovery/ProfileEditSheet.swift added +127
@@ -0,0 +1,127 @@
1import SwiftUI
2
3/// `profile set` — always the caller's own. Description and website are
4/// single lines; about is long-form prose in whichever format the
5/// profile already carries, since switching formats reinterprets text
6/// that was already written; links are capped at five, matching the
7/// server.
8struct ProfileEditSheet: View {
9
10 let model: ProfileViewModel
11 let profile: ProfileViewModel.Profile
12
13 @Environment(\.dismiss) private var dismiss
14 @State private var edit: ProfileEdit
15
16 init(model: ProfileViewModel, profile: ProfileViewModel.Profile) {
17 self.model = model
18 self.profile = profile
19 _edit = State(initialValue: ProfileEdit(from: profile))
20 }
21
22 /// The server splits a link on the first `|`, so a label carrying one
23 /// would silently push part of itself into the URL. Caught here,
24 /// ahead of `ProfileEdit.validationError`, so the message is specific
25 /// rather than a generic refusal.
26 private var pipeError: String? {
27 edit.links.contains(where: { $0.label.contains("|") })
28 ? "A link label can't contain \"|\" — that's what separates it from the URL."
29 : nil
30 }
31
32 private var localError: String? { pipeError ?? edit.validationError }
33
34 var body: some View {
35 NavigationStack {
36 Form {
37 Section("Description") {
38 TextField("Description", text: $edit.description)
39 .accessibilityIdentifier("profile-edit-description")
40 }
41 Section("Website") {
42 TextField("https://example.com", text: $edit.website)
43 .keyboardType(.URL)
44 .autocorrectionDisabled()
45 .textInputAutocapitalization(.never)
46 .accessibilityIdentifier("profile-edit-website")
47 }
48 Section {
49 Picker("Format", selection: $edit.aboutFormat) {
50 Text("Markdown").tag("md")
51 Text("Org").tag("org")
52 }
53 .pickerStyle(.segmented)
54 .accessibilityIdentifier("profile-edit-format")
55 TextEditor(text: $edit.about)
56 .font(.gbMono(.caption))
57 .frame(minHeight: 160)
58 .autocorrectionDisabled()
59 .accessibilityIdentifier("profile-edit-about")
60 } header: {
61 Text("About")
62 } footer: {
63 Text("Changing the format reinterprets text that is already written.")
64 }
65 linksSection
66 if let error = localError ?? model.actionError {
67 Section {
68 GBNotice(error, .gbWarn)
69 }
70 }
71 }
72 .navigationTitle("Edit Profile")
73 .navigationBarTitleDisplayMode(.inline)
74 .toolbar {
75 ToolbarItem(placement: .cancellationAction) {
76 Button("Cancel") { dismiss() }
77 }
78 ToolbarItem(placement: .confirmationAction) {
79 if model.working {
80 ProgressView()
81 } else {
82 Button("Save") { save() }
83 .disabled(localError != nil)
84 .accessibilityIdentifier("profile-edit-save")
85 }
86 }
87 }
88 .interactiveDismissDisabled(model.working)
89 }
90 }
91
92 private var linksSection: some View {
93 Section {
94 ForEach(Array(edit.links.enumerated()), id: \.offset) { index, _ in
95 VStack(alignment: .leading, spacing: 4) {
96 TextField("Label", text: $edit.links[index].label)
97 .autocorrectionDisabled()
98 .accessibilityIdentifier("profile-edit-link-label-\(index)")
99 TextField("https://example.com", text: $edit.links[index].url)
100 .keyboardType(.URL)
101 .autocorrectionDisabled()
102 .textInputAutocapitalization(.never)
103 .accessibilityIdentifier("profile-edit-link-url-\(index)")
104 }
105 }
106 .onDelete { edit.links.remove(atOffsets: $0) }
107 if edit.links.count < 5 {
108 Button {
109 edit.links.append(.init(label: "", url: ""))
110 } label: {
111 Label("Add Link", systemImage: "plus.circle")
112 }
113 .accessibilityIdentifier("profile-edit-add-link")
114 }
115 } header: {
116 Text("Links")
117 } footer: {
118 Text("Up to 5 links.")
119 }
120 }
121
122 private func save() {
123 Task {
124 if await model.saveProfile(edit) { dismiss() }
125 }
126 }
127}
gitbay/Views/Discovery/ProfileView.swift +15
@@ -8,6 +8,7 @@ struct ProfileView: View {
88 @State private var model: ProfileViewModel
99 @State private var creatingOrg = false
1010 @State private var newOrgName = ""
11 @State private var editingProfile = false
1112
1213 /// Someone else's profile is titled with their name. Your own is
1314 /// reached from a tab and titled to match it.
@@ -46,6 +47,15 @@ struct ProfileView: View {
4647 .toolbar {
4748 if let profile = model.state.value,
4849 !profile.isOrg, session.current?.username == profile.name {
50 ToolbarItem(placement: .topBarTrailing) {
51 Button {
52 editingProfile = true
53 } label: {
54 Image(systemName: "pencil")
55 }
56 .disabled(model.working)
57 .accessibilityIdentifier("profile-edit-button")
58 }
4959 ToolbarItem(placement: .topBarTrailing) {
5060 Button {
5161 newOrgName = ""
@@ -60,6 +70,11 @@ struct ProfileView: View {
6070 }
6171 .task { await model.load() }
6272 .refreshable { await model.load() }
73 .sheet(isPresented: $editingProfile) {
74 if let profile = model.state.value {
75 ProfileEditSheet(model: model, profile: profile)
76 }
77 }
6378 .alert("New Organization", isPresented: $creatingOrg) {
6479 TextField("Name", text: $newOrgName)
6580 .autocorrectionDisabled()
gitbayTests/ProfileEditTests.swift +97
@@ -85,3 +85,100 @@ struct ProfileEditFlagTests {
8585 #expect(edit(links: [.init(label: "blog", url: " ")]).validationError != nil)
8686 }
8787}
88
89private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
90 let box = StubProtocol.box()
91 let client = GitbayClient(
92 instance: try GitbayInstance(url: "https://gitbay.org"),
93 token: "test-token",
94 session: box.session()
95 )
96 return (client, box)
97}
98
99private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) {
100 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
101 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
102}
103
104private let okJSON = #"{"protocol_version":1,"exit_code":0}"#
105
106private let profileJSON = """
107 {"protocol_version":1,"data":{"name":"cmc","kind":"user",\
108 "description":"hi","website":"https://cleberg.net","repos":[],\
109 "activity_total":0},"exit_code":0}
110 """
111
112@MainActor
113struct ProfileSaveTests {
114
115 /// The write, then the reload `profile set` triggers — one POST, one
116 /// GET. Both need a stub or the flow ends up looking like a refusal
117 /// even when the write itself succeeded.
118 @Test func savesTheFullArgvWithAboutOnlyInStdin() async throws {
119 let (client, stub) = try makeClient()
120 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
121 stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show"))
122 let model = ProfileViewModel(client: client, name: "cmc")
123 let edit = ProfileEdit(
124 description: "hi", website: "https://cleberg.net",
125 about: "long form\n\nprose", aboutFormat: "org",
126 links: [.init(label: "blog", url: "https://b.test")]
127 )
128
129 let saved = await model.saveProfile(edit)
130
131 #expect(saved)
132 let write = try #require(stub.seen.first { $0.method == "POST" })
133 let (argv, stdin) = try argvOf(write)
134 #expect(argv == [
135 "profile", "set",
136 "--description", "hi", "--website", "https://cleberg.net",
137 "--link", "blog|https://b.test",
138 "--about-format", "org", "--file", "-",
139 ])
140 #expect(stdin == "long form\n\nprose")
141 #expect(argv.contains { $0.contains("long form") } == false)
142 }
143
144 /// The screen shows what the server stored, not what was typed — the
145 /// reload's payload, not the edit, is what the caller sees next.
146 @Test func aSuccessfulSaveReloadsFromTheServer() async throws {
147 let (client, stub) = try makeClient()
148 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
149 stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show"))
150 let model = ProfileViewModel(client: client, name: "cmc")
151
152 let saved = await model.saveProfile(ProfileEdit(description: "hi", website: "https://cleberg.net"))
153
154 #expect(saved)
155 let profile = try #require(model.state.value)
156 #expect(profile.description == "hi")
157 }
158
159 @Test func aRefusedSaveReturnsFalseAndSurfaces() async throws {
160 let (client, stub) = try makeClient()
161 stub.enqueue(.init(status: 200, json: """
162 {"protocol_version":1,"error":"setting a profile needs write access","exit_code":4}
163 """))
164 let model = ProfileViewModel(client: client, name: "cmc")
165
166 let saved = await model.saveProfile(ProfileEdit(description: "hi"))
167
168 #expect(saved == false)
169 #expect(model.actionError?.isEmpty == false)
170 }
171
172 /// Six links fail `ProfileEdit.validationError`; the save must refuse
173 /// before any request goes out, not after the server rejects it.
174 @Test func anInvalidEditSendsNothingAtAll() async throws {
175 let (client, stub) = try makeClient()
176 let model = ProfileViewModel(client: client, name: "cmc")
177 let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") }
178
179 let saved = await model.saveProfile(ProfileEdit(links: six))
180
181 #expect(saved == false)
182 #expect(stub.seen.isEmpty)
183 }
184}