Commit 4d6ea462de
Unsigned
Layout: unified · split
docs/superpowers/plans/2026-09-06-mr10-profile-set.md added +237
| @@ -0,0 +1,237 @@ | |||
| 1 | # MR 10: Edit your profile Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** Close the `profile set` parity row — edit your description, website, about text and links from the app. | ||
| 6 | |||
| 7 | **Architecture:** One write command behind an edit sheet on your own profile screen. The complication is entirely in `profile set`'s flag semantics, which are unlike anything else this app sends. | ||
| 8 | |||
| 9 | **Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md` | ||
| 10 | |||
| 11 | ## The command, and why it needs care | ||
| 12 | |||
| 13 | ``` | ||
| 14 | profile set [--description <d>] [--website <url>] [--about <text>|--file -] | ||
| 15 | [--about-format md|org] [--link <label|url>]... ('' clears) | ||
| 16 | ``` | ||
| 17 | |||
| 18 | Five things, all verified against `internal/control/profile.go`: | ||
| 19 | |||
| 20 | 1. **`--link` replaces the whole set, it does not append.** `applyProfile` does `p.Links = *e.Links` (`:159-161`). So the editor sends **every link it wants to keep** on every save — dropping one from the form and saving is how you delete it. | ||
| 21 | 2. **`--link ""` clears all links** (`:81-84`). | ||
| 22 | 3. **`''` clears any field.** An empty `--description` is "remove my description", which is different from omitting the flag entirely. | ||
| 23 | 4. **At least one flag is required.** With none, the command fails: *"nothing to set: pass --description, --website, --about and/or --link"* (`:356-358`). A save with no changes must not be sent. | ||
| 24 | 5. **A link is `label|url`**, split on the first `|`. Without a separator the whole value is the URL (`:106-113`). Labels are trimmed and truncated at 32 characters; a link with no URL is an error. **At most 5 links** (`maxProfileLinks = 5`, `:31,100-102`). | ||
| 25 | |||
| 26 | **About text goes over stdin.** It is long-form and contains newlines — the live profile on gitbay.org is org-mode with tables and links. Use `--file -` with the text in `stdin`, the same discipline the compose sheet already uses for issue and merge request bodies. Do not put it in argv. | ||
| 27 | |||
| 28 | **`--about-format md|org`** is stored alongside the text, so changing it reinterprets prose that already exists. The editor must default to the format the profile was **stored** with, read from `about_format` on `profile show`. | ||
| 29 | |||
| 30 | ## Global Constraints | ||
| 31 | |||
| 32 | - Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s. | ||
| 33 | - Swift Testing only — never XCTest. | ||
| 34 | - `gitbayTests` is hermetic and offline; network goes through `StubProtocol`. | ||
| 35 | - New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit. | ||
| 36 | - The label model is `IssueLabel`; the notification model is `InboxNotification`. | ||
| 37 | - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer. | ||
| 38 | - Never commit to `main`. | ||
| 39 | |||
| 40 | **Counting tests — `xcresulttool`, not grep:** | ||
| 41 | |||
| 42 | ```bash | ||
| 43 | RES=$(ls -td ~/Library/Developer/Xcode/DerivedData/gitbay-*/Logs/Test/*.xcresult | head -1) | ||
| 44 | xcrun xcresulttool get test-results summary --path "$RES" | ||
| 45 | ``` | ||
| 46 | |||
| 47 | Baseline: **331 total, 330 passed, 1 skipped, 0 failed.** | ||
| 48 | |||
| 49 | --- | ||
| 50 | |||
| 51 | ## File Structure | ||
| 52 | |||
| 53 | | File | Responsibility | | ||
| 54 | |------|----------------| | ||
| 55 | | `gitbay/Discovery/ProfileEdit.swift` (create) | `ProfileEdit` — the form's value type and its argv rendering | | ||
| 56 | | `gitbay/Discovery/ProfileViewModel.swift` (modify) | `saveProfile(_:) async -> Bool` | | ||
| 57 | | `gitbayTests/ProfileEditTests.swift` (create) | Every test in this plan | | ||
| 58 | | `gitbay/Views/Discovery/ProfileEditSheet.swift` (create) | The edit form | | ||
| 59 | | `gitbay/Views/Discovery/ProfileView.swift` (modify) | Edit button on your own profile | | ||
| 60 | |||
| 61 | --- | ||
| 62 | |||
| 63 | ### Task 1: The value type and its argv | ||
| 64 | |||
| 65 | **Files:** Create `gitbay/Discovery/ProfileEdit.swift`; test in `gitbayTests/ProfileEditTests.swift` | ||
| 66 | |||
| 67 | This task is pure argv construction — the same shape as MR 2's `IssueFilter`, and the same reason: a flag this command does not accept is a usage error the app cannot catch at compile time. | ||
| 68 | |||
| 69 | **Interfaces produced:** | ||
| 70 | - `nonisolated struct ProfileEdit: Equatable, Sendable` — `description: String`, `website: String`, `about: String`, `aboutFormat: String`, `links: [Link]`; nested `Link` with `label: String`, `url: String` | ||
| 71 | - `init(from profile: ProfileViewModel.Profile)` — seeds the form from what the server reported | ||
| 72 | - `var isEmpty: Bool` — true when nothing would be sent | ||
| 73 | - `func flags() -> [String]` — everything except the about text | ||
| 74 | - `var aboutStdin: String?` — the about text, when it is being set | ||
| 75 | |||
| 76 | Rules, each pinned by a test: | ||
| 77 | 1. **`--link` sends the entire set**, one flag per link, `label|url`. A link with an empty label sends just the URL (no leading `|`). | ||
| 78 | 2. **An empty link list sends `--link ""`** — that is how the set is cleared. It must not simply omit the flag, which would leave the existing links untouched. | ||
| 79 | 3. **A field cleared to empty sends `--flag ""`**, not omission. Empty means "remove this"; omitted means "leave it alone". Since the form is always seeded from the current profile, every field it holds is being set. | ||
| 80 | 4. **The about text never appears in argv.** It goes to stdin via `--file -`. | ||
| 81 | 5. **`--about-format` is sent whenever the about text is**, using the stored format unless the user changed it. | ||
| 82 | 6. **More than 5 links is refused locally** before sending — the server caps at 5 and the form should not let it get there. | ||
| 83 | 7. **A link with no URL is refused locally.** | ||
| 84 | |||
| 85 | - [ ] **Step 1: Write the failing tests** | ||
| 86 | |||
| 87 | ```swift | ||
| 88 | import Foundation | ||
| 89 | import Testing | ||
| 90 | @testable import gitbay | ||
| 91 | |||
| 92 | struct ProfileEditFlagTests { | ||
| 93 | |||
| 94 | private func edit( | ||
| 95 | description: String = "", website: String = "", | ||
| 96 | about: String = "", aboutFormat: String = "md", | ||
| 97 | links: [ProfileEdit.Link] = [] | ||
| 98 | ) -> ProfileEdit { | ||
| 99 | ProfileEdit(description: description, website: website, | ||
| 100 | about: about, aboutFormat: aboutFormat, links: links) | ||
| 101 | } | ||
| 102 | |||
| 103 | @Test func everyFieldSendsItsFlag() { | ||
| 104 | let e = edit(description: "hi", website: "https://x.test", | ||
| 105 | links: [.init(label: "blog", url: "https://b.test")]) | ||
| 106 | let flags = e.flags() | ||
| 107 | #expect(flags.contains("--description")) | ||
| 108 | #expect(flags.contains("hi")) | ||
| 109 | #expect(flags.contains("--website")) | ||
| 110 | #expect(flags.contains("https://x.test")) | ||
| 111 | #expect(flags.contains("blog|https://b.test")) | ||
| 112 | } | ||
| 113 | |||
| 114 | /// An emptied field CLEARS it. Omitting the flag would leave the old | ||
| 115 | /// value in place, which is a different outcome. | ||
| 116 | @Test func anEmptiedFieldSendsAnEmptyValueNotNothing() { | ||
| 117 | let flags = edit(description: "", website: "https://x.test").flags() | ||
| 118 | let i = try! #require(flags.firstIndex(of: "--description")) | ||
| 119 | #expect(flags[i + 1] == "") | ||
| 120 | } | ||
| 121 | |||
| 122 | /// `--link ""` is how the whole set is cleared. Omitting it would | ||
| 123 | /// leave the existing links untouched. | ||
| 124 | @Test func noLinksSendsAnEmptyLinkFlag() { | ||
| 125 | let flags = edit(description: "hi").flags() | ||
| 126 | let i = try! #require(flags.firstIndex(of: "--link")) | ||
| 127 | #expect(flags[i + 1] == "") | ||
| 128 | } | ||
| 129 | |||
| 130 | /// The set replaces wholesale, so every kept link is sent every time. | ||
| 131 | @Test func everyLinkIsSentSoTheSetReplaces() { | ||
| 132 | let flags = edit(links: [ | ||
| 133 | .init(label: "a", url: "https://a.test"), | ||
| 134 | .init(label: "b", url: "https://b.test"), | ||
| 135 | ]).flags() | ||
| 136 | #expect(flags.filter { $0 == "--link" }.count == 2) | ||
| 137 | #expect(flags.contains("a|https://a.test")) | ||
| 138 | #expect(flags.contains("b|https://b.test")) | ||
| 139 | } | ||
| 140 | |||
| 141 | /// Without a separator the whole value is the URL, so a label-less | ||
| 142 | /// link must not send a leading pipe. | ||
| 143 | @Test func aLabellessLinkSendsJustTheUrl() { | ||
| 144 | let flags = edit(links: [.init(label: "", url: "https://a.test")]).flags() | ||
| 145 | #expect(flags.contains("https://a.test")) | ||
| 146 | #expect(flags.contains("|https://a.test") == false) | ||
| 147 | } | ||
| 148 | |||
| 149 | /// Long-form text with newlines belongs in stdin, not argv. | ||
| 150 | @Test func theAboutTextGoesToStdinNotArgv() { | ||
| 151 | let e = edit(about: "line one\n\nline two", aboutFormat: "org") | ||
| 152 | #expect(e.flags().contains { $0.contains("line one") } == false) | ||
| 153 | #expect(e.flags().contains("--file")) | ||
| 154 | #expect(e.flags().contains("-")) | ||
| 155 | #expect(e.aboutStdin == "line one\n\nline two") | ||
| 156 | } | ||
| 157 | |||
| 158 | @Test func theAboutFormatAccompaniesTheText() { | ||
| 159 | let flags = edit(about: "x", aboutFormat: "org").flags() | ||
| 160 | let i = try! #require(flags.firstIndex(of: "--about-format")) | ||
| 161 | #expect(flags[i + 1] == "org") | ||
| 162 | } | ||
| 163 | |||
| 164 | @Test func moreThanFiveLinksIsInvalid() { | ||
| 165 | let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") } | ||
| 166 | #expect(edit(links: six).validationError != nil) | ||
| 167 | let five = (1...5).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") } | ||
| 168 | #expect(edit(links: five).validationError == nil) | ||
| 169 | } | ||
| 170 | |||
| 171 | @Test func aLinkWithoutAUrlIsInvalid() { | ||
| 172 | #expect(edit(links: [.init(label: "blog", url: " ")]).validationError != nil) | ||
| 173 | } | ||
| 174 | } | ||
| 175 | ``` | ||
| 176 | |||
| 177 | Add `var validationError: String?` to the interface list — the tests above use it. | ||
| 178 | |||
| 179 | - [ ] **Step 2: Run to verify failure.** | ||
| 180 | - [ ] **Step 3: Implement.** | ||
| 181 | - [ ] **Step 4: Run the tests.** | ||
| 182 | - [ ] **Step 5: Commit** — `git commit -m "Profile edit value type and its flags"` | ||
| 183 | |||
| 184 | --- | ||
| 185 | |||
| 186 | ### Task 2: Saving, and the edit sheet | ||
| 187 | |||
| 188 | **Files:** Modify `gitbay/Discovery/ProfileViewModel.swift`; create `gitbay/Views/Discovery/ProfileEditSheet.swift`; modify `gitbay/Views/Discovery/ProfileView.swift` | ||
| 189 | |||
| 190 | **Interfaces produced:** `ProfileViewModel.saveProfile(_ edit: ProfileEdit) async -> Bool` | ||
| 191 | |||
| 192 | `saveProfile` sends `["profile", "set"] + edit.flags()` with `edit.aboutStdin` as stdin, then reloads so the screen shows what the server stored. It must refuse to send an edit whose `validationError` is non-nil, and refuse to send when nothing would change. | ||
| 193 | |||
| 194 | **The sheet:** description and website as single-line fields; about as a `TextEditor` with a `md`/`org` picker; links as an editable list capped at five, each with a label and a URL. | ||
| 195 | |||
| 196 | **The format picker defaults to the profile's stored `about_format`.** Changing it reinterprets prose that already exists — the same rule that governs issue and merge request bodies. Do not default to markdown when the stored value is org; the live profile on gitbay.org is org, with tables and links that would render as flat text under the wrong renderer. | ||
| 197 | |||
| 198 | **The edit button appears only on your own profile** — `profile set` always writes the caller's own. The screen already computes this: MR 8 added an org-create button gated on `!profile.isOrg && session.current?.username == profile.name`. Reuse that condition rather than inventing a second one. | ||
| 199 | |||
| 200 | Add tests for `saveProfile` in `gitbayTests/ProfileEditTests.swift`: | ||
| 201 | - The full argv reaches the command, with the about text in stdin and not in argv. | ||
| 202 | - A save reloads, so the screen shows what the server stored rather than what was typed. | ||
| 203 | - A refusal surfaces and returns false. | ||
| 204 | - An invalid edit (six links) sends nothing at all. | ||
| 205 | |||
| 206 | **Provision stubs for every request each flow makes** — `saveProfile` writes *and* reloads. Three tests in this repo have already shipped under-provisioned, silently passing against bugs. | ||
| 207 | |||
| 208 | - [ ] **Step 1: `saveProfile` and its tests** | ||
| 209 | - [ ] **Step 2: The edit sheet** | ||
| 210 | - [ ] **Step 3: The edit button on your own profile** | ||
| 211 | - [ ] **Step 4: Build and run the full suite.** | ||
| 212 | - [ ] **Step 5: Commit** — `git commit -m "Edit your profile from the app"` | ||
| 213 | |||
| 214 | --- | ||
| 215 | |||
| 216 | ### Task 3: Flip the parity row and open the merge request | ||
| 217 | |||
| 218 | - [ ] **Step 1:** Set `profile set` to `yes` for iOS in the Accounts table. Touch no other row. | ||
| 219 | |||
| 220 | The wiki note for that row currently says no surface has ever offered a form, and that the `no` is missing UI rather than a rule — **update that prose too**, since it stops being true. The web still has no form, so say that iOS has one and the web does not. | ||
| 221 | |||
| 222 | Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff`. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch. **Read the merge request number back from `mr create`'s JSON.** | ||
| 223 | |||
| 224 | - [ ] **Step 2:** Run the full suite via `xcresulttool`; record the real numbers. | ||
| 225 | - [ ] **Step 3:** Open the merge request. | ||
| 226 | |||
| 227 | --- | ||
| 228 | |||
| 229 | ## Notes for whoever executes this | ||
| 230 | |||
| 231 | **`--link` replaces the set.** Every link you want to keep goes on every save, and `--link ""` clears them. This is the one flag semantic in the whole app that works this way, and getting it wrong silently loses or duplicates a user's links. | ||
| 232 | |||
| 233 | **Empty is not omitted.** `--description ""` removes the description; omitting `--description` leaves it. The form always sends what it holds. | ||
| 234 | |||
| 235 | **About text goes in stdin.** It is long-form with newlines. `--file -`, never argv. | ||
| 236 | |||
| 237 | **Default the format to what was stored.** The live profile is org-mode; defaulting to markdown would reinterpret prose that already exists. | ||
gitbay/Discovery/ProfileEdit.swift added +77
| @@ -0,0 +1,77 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// `profile set` — unlike every other filter or edit in the app, `--link` | ||
| 4 | /// replaces the whole set rather than appending, so every link the form | ||
| 5 | /// wants to keep is sent on every save. The form is always seeded from | ||
| 6 | /// the current profile, so every other field is also sent every time, | ||
| 7 | /// including empty ones: `--description ""` clears, omitting the flag | ||
| 8 | /// would leave the old value alone. | ||
| 9 | nonisolated struct ProfileEdit: Equatable, Sendable { | ||
| 10 | |||
| 11 | nonisolated struct Link: Equatable, Sendable, Identifiable { | ||
| 12 | var id = UUID() | ||
| 13 | var label: String | ||
| 14 | var url: String | ||
| 15 | } | ||
| 16 | |||
| 17 | var description: String | ||
| 18 | var website: String | ||
| 19 | var about: String | ||
| 20 | var aboutFormat: String | ||
| 21 | var links: [Link] | ||
| 22 | |||
| 23 | init( | ||
| 24 | description: String = "", website: String = "", | ||
| 25 | about: String = "", aboutFormat: String = "md", | ||
| 26 | links: [Link] = [] | ||
| 27 | ) { | ||
| 28 | self.description = description | ||
| 29 | self.website = website | ||
| 30 | self.about = about | ||
| 31 | self.aboutFormat = aboutFormat | ||
| 32 | self.links = links | ||
| 33 | } | ||
| 34 | |||
| 35 | init(from profile: ProfileViewModel.Profile) { | ||
| 36 | description = profile.description ?? "" | ||
| 37 | website = profile.website ?? "" | ||
| 38 | about = profile.about ?? "" | ||
| 39 | aboutFormat = profile.aboutFormat ?? "md" | ||
| 40 | links = (profile.links ?? []).map { Link(label: $0.label ?? "", url: $0.url) } | ||
| 41 | } | ||
| 42 | |||
| 43 | /// At most 5 links and every link needs a URL — the server enforces | ||
| 44 | /// both, but the form should not let a save reach it that way. A | ||
| 45 | /// label may not contain `|` either: the server splits a link on its | ||
| 46 | /// first pipe, so a label carrying one would silently corrupt the URL | ||
| 47 | /// rather than be rejected. | ||
| 48 | var validationError: String? { | ||
| 49 | if links.count > 5 { return "At most 5 links." } | ||
| 50 | if links.contains(where: { $0.url.trimmingCharacters(in: .whitespaces).isEmpty }) { | ||
| 51 | return "A link needs a URL." | ||
| 52 | } | ||
| 53 | if links.contains(where: { $0.label.contains("|") }) { | ||
| 54 | return "A link label can't contain \"|\" — that's what separates it from the URL." | ||
| 55 | } | ||
| 56 | return nil | ||
| 57 | } | ||
| 58 | |||
| 59 | /// Everything except the about text, which travels over stdin. | ||
| 60 | func flags() -> [String] { | ||
| 61 | var flags = ["--description", description, "--website", website] | ||
| 62 | if links.isEmpty { | ||
| 63 | flags += ["--link", ""] | ||
| 64 | } else { | ||
| 65 | for link in links { | ||
| 66 | let label = link.label.trimmingCharacters(in: .whitespaces) | ||
| 67 | flags += ["--link", label.isEmpty ? link.url : "\(label)|\(link.url)"] | ||
| 68 | } | ||
| 69 | } | ||
| 70 | flags += ["--about-format", aboutFormat, "--file", "-"] | ||
| 71 | return flags | ||
| 72 | } | ||
| 73 | |||
| 74 | /// The about text, sent over stdin — never in argv, since it is | ||
| 75 | /// long-form and may hold newlines. | ||
| 76 | var aboutStdin: String { about } | ||
| 77 | } | ||
gitbay/Discovery/ProfileViewModel.swift +11 −2
| @@ -123,12 +123,21 @@ final class ProfileViewModel { | |||
| 123 | return actionError == nil | 123 | return actionError == nil |
| 124 | } | 124 | } |
| 125 | 125 | ||
| 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 { | ||
| 127 | working = true | 136 | working = true |
| 128 | actionError = nil | 137 | actionError = nil |
| 129 | defer { working = false } | 138 | defer { working = false } |
| 130 | do { | 139 | do { |
| 131 | try await client.run(argv) | 140 | try await client.run(argv, stdin: stdin) |
| 132 | await load() | 141 | await load() |
| 133 | } catch let error as GitbayError { | 142 | } catch let error as GitbayError { |
| 134 | actionError = error.userFacingMessage | 143 | actionError = error.userFacingMessage |
gitbay/Views/Discovery/ProfileEditSheet.swift added +118
| @@ -0,0 +1,118 @@ | |||
| 1 | import 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. | ||
| 8 | struct 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 | private var localError: String? { edit.validationError } | ||
| 23 | |||
| 24 | var body: some View { | ||
| 25 | NavigationStack { | ||
| 26 | Form { | ||
| 27 | Section("Description") { | ||
| 28 | TextField("Description", text: $edit.description) | ||
| 29 | .accessibilityIdentifier("profile-edit-description") | ||
| 30 | } | ||
| 31 | Section("Website") { | ||
| 32 | TextField("https://example.com", text: $edit.website) | ||
| 33 | .keyboardType(.URL) | ||
| 34 | .autocorrectionDisabled() | ||
| 35 | .textInputAutocapitalization(.never) | ||
| 36 | .accessibilityIdentifier("profile-edit-website") | ||
| 37 | } | ||
| 38 | Section { | ||
| 39 | Picker("Format", selection: $edit.aboutFormat) { | ||
| 40 | Text("Markdown").tag("md") | ||
| 41 | Text("Org").tag("org") | ||
| 42 | } | ||
| 43 | .pickerStyle(.segmented) | ||
| 44 | .accessibilityIdentifier("profile-edit-format") | ||
| 45 | TextEditor(text: $edit.about) | ||
| 46 | .font(.gbMono(.caption)) | ||
| 47 | .frame(minHeight: 160) | ||
| 48 | .autocorrectionDisabled() | ||
| 49 | .accessibilityIdentifier("profile-edit-about") | ||
| 50 | } header: { | ||
| 51 | Text("About") | ||
| 52 | } footer: { | ||
| 53 | Text("Changing the format reinterprets text that is already written.") | ||
| 54 | } | ||
| 55 | linksSection | ||
| 56 | if let error = localError ?? model.actionError { | ||
| 57 | Section { | ||
| 58 | GBNotice(error, .gbWarn) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | .navigationTitle("Edit Profile") | ||
| 63 | .navigationBarTitleDisplayMode(.inline) | ||
| 64 | .toolbar { | ||
| 65 | ToolbarItem(placement: .cancellationAction) { | ||
| 66 | Button("Cancel") { dismiss() } | ||
| 67 | } | ||
| 68 | ToolbarItem(placement: .confirmationAction) { | ||
| 69 | if model.working { | ||
| 70 | ProgressView() | ||
| 71 | } else { | ||
| 72 | Button("Save") { save() } | ||
| 73 | .disabled(localError != nil) | ||
| 74 | .accessibilityIdentifier("profile-edit-save") | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | .interactiveDismissDisabled(model.working) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | private var linksSection: some View { | ||
| 83 | Section { | ||
| 84 | ForEach($edit.links) { $link in | ||
| 85 | let index = edit.links.firstIndex { $0.id == link.id } ?? 0 | ||
| 86 | VStack(alignment: .leading, spacing: 4) { | ||
| 87 | TextField("Label", text: $link.label) | ||
| 88 | .autocorrectionDisabled() | ||
| 89 | .accessibilityIdentifier("profile-edit-link-label-\(index)") | ||
| 90 | TextField("https://example.com", text: $link.url) | ||
| 91 | .keyboardType(.URL) | ||
| 92 | .autocorrectionDisabled() | ||
| 93 | .textInputAutocapitalization(.never) | ||
| 94 | .accessibilityIdentifier("profile-edit-link-url-\(index)") | ||
| 95 | } | ||
| 96 | } | ||
| 97 | .onDelete { edit.links.remove(atOffsets: $0) } | ||
| 98 | if edit.links.count < 5 { | ||
| 99 | Button { | ||
| 100 | edit.links.append(.init(label: "", url: "")) | ||
| 101 | } label: { | ||
| 102 | Label("Add Link", systemImage: "plus.circle") | ||
| 103 | } | ||
| 104 | .accessibilityIdentifier("profile-edit-add-link") | ||
| 105 | } | ||
| 106 | } header: { | ||
| 107 | Text("Links") | ||
| 108 | } footer: { | ||
| 109 | Text("Up to 5 links.") | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | private func save() { | ||
| 114 | Task { | ||
| 115 | if await model.saveProfile(edit) { dismiss() } | ||
| 116 | } | ||
| 117 | } | ||
| 118 | } | ||
gitbay/Views/Discovery/ProfileView.swift +15
| @@ -8,6 +8,7 @@ struct ProfileView: View { | |||
| 8 | @State private var model: ProfileViewModel | 8 | @State private var model: ProfileViewModel |
| 9 | @State private var creatingOrg = false | 9 | @State private var creatingOrg = false |
| 10 | @State private var newOrgName = "" | 10 | @State private var newOrgName = "" |
| 11 | @State private var editingProfile = false | ||
| 11 | 12 | ||
| 12 | /// Someone else's profile is titled with their name. Your own is | 13 | /// Someone else's profile is titled with their name. Your own is |
| 13 | /// reached from a tab and titled to match it. | 14 | /// reached from a tab and titled to match it. |
| @@ -46,6 +47,15 @@ struct ProfileView: View { | |||
| 46 | .toolbar { | 47 | .toolbar { |
| 47 | if let profile = model.state.value, | 48 | if let profile = model.state.value, |
| 48 | !profile.isOrg, session.current?.username == profile.name { | 49 | !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 | } | ||
| 49 | ToolbarItem(placement: .topBarTrailing) { | 59 | ToolbarItem(placement: .topBarTrailing) { |
| 50 | Button { | 60 | Button { |
| 51 | newOrgName = "" | 61 | newOrgName = "" |
| @@ -60,6 +70,11 @@ struct ProfileView: View { | |||
| 60 | } | 70 | } |
| 61 | .task { await model.load() } | 71 | .task { await model.load() } |
| 62 | .refreshable { await model.load() } | 72 | .refreshable { await model.load() } |
| 73 | .sheet(isPresented: $editingProfile) { | ||
| 74 | if let profile = model.state.value { | ||
| 75 | ProfileEditSheet(model: model, profile: profile) | ||
| 76 | } | ||
| 77 | } | ||
| 63 | .alert("New Organization", isPresented: $creatingOrg) { | 78 | .alert("New Organization", isPresented: $creatingOrg) { |
| 64 | TextField("Name", text: $newOrgName) | 79 | TextField("Name", text: $newOrgName) |
| 65 | .autocorrectionDisabled() | 80 | .autocorrectionDisabled() |
gitbayTests/ProfileEditTests.swift added +233
| @@ -0,0 +1,233 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | struct ProfileEditFlagTests { | ||
| 6 | |||
| 7 | private func edit( | ||
| 8 | description: String = "", website: String = "", | ||
| 9 | about: String = "", aboutFormat: String = "md", | ||
| 10 | links: [ProfileEdit.Link] = [] | ||
| 11 | ) -> ProfileEdit { | ||
| 12 | ProfileEdit(description: description, website: website, | ||
| 13 | about: about, aboutFormat: aboutFormat, links: links) | ||
| 14 | } | ||
| 15 | |||
| 16 | @Test func everyFieldSendsItsFlag() { | ||
| 17 | let e = edit(description: "hi", website: "https://x.test", | ||
| 18 | links: [.init(label: "blog", url: "https://b.test")]) | ||
| 19 | let flags = e.flags() | ||
| 20 | #expect(flags.contains("--description")) | ||
| 21 | #expect(flags.contains("hi")) | ||
| 22 | #expect(flags.contains("--website")) | ||
| 23 | #expect(flags.contains("https://x.test")) | ||
| 24 | #expect(flags.contains("blog|https://b.test")) | ||
| 25 | } | ||
| 26 | |||
| 27 | /// An emptied field CLEARS it. Omitting the flag would leave the old | ||
| 28 | /// value in place, which is a different outcome. | ||
| 29 | @Test func anEmptiedFieldSendsAnEmptyValueNotNothing() { | ||
| 30 | let flags = edit(description: "", website: "https://x.test").flags() | ||
| 31 | let i = try! #require(flags.firstIndex(of: "--description")) | ||
| 32 | #expect(flags[i + 1] == "") | ||
| 33 | } | ||
| 34 | |||
| 35 | /// `--link ""` is how the whole set is cleared. Omitting it would | ||
| 36 | /// leave the existing links untouched. | ||
| 37 | @Test func noLinksSendsAnEmptyLinkFlag() { | ||
| 38 | let flags = edit(description: "hi").flags() | ||
| 39 | let i = try! #require(flags.firstIndex(of: "--link")) | ||
| 40 | #expect(flags[i + 1] == "") | ||
| 41 | } | ||
| 42 | |||
| 43 | /// The set replaces wholesale, so every kept link is sent every time. | ||
| 44 | @Test func everyLinkIsSentSoTheSetReplaces() { | ||
| 45 | let flags = edit(links: [ | ||
| 46 | .init(label: "a", url: "https://a.test"), | ||
| 47 | .init(label: "b", url: "https://b.test"), | ||
| 48 | ]).flags() | ||
| 49 | #expect(flags.filter { $0 == "--link" }.count == 2) | ||
| 50 | #expect(flags.contains("a|https://a.test")) | ||
| 51 | #expect(flags.contains("b|https://b.test")) | ||
| 52 | } | ||
| 53 | |||
| 54 | /// Without a separator the whole value is the URL, so a label-less | ||
| 55 | /// link must not send a leading pipe. | ||
| 56 | @Test func aLabellessLinkSendsJustTheUrl() { | ||
| 57 | let flags = edit(links: [.init(label: "", url: "https://a.test")]).flags() | ||
| 58 | #expect(flags.contains("https://a.test")) | ||
| 59 | #expect(flags.contains("|https://a.test") == false) | ||
| 60 | } | ||
| 61 | |||
| 62 | /// Long-form text with newlines belongs in stdin, not argv. | ||
| 63 | @Test func theAboutTextGoesToStdinNotArgv() { | ||
| 64 | let e = edit(about: "line one\n\nline two", aboutFormat: "org") | ||
| 65 | #expect(e.flags().contains { $0.contains("line one") } == false) | ||
| 66 | #expect(e.flags().contains("--file")) | ||
| 67 | #expect(e.flags().contains("-")) | ||
| 68 | #expect(e.aboutStdin == "line one\n\nline two") | ||
| 69 | } | ||
| 70 | |||
| 71 | @Test func theAboutFormatAccompaniesTheText() { | ||
| 72 | let flags = edit(about: "x", aboutFormat: "org").flags() | ||
| 73 | let i = try! #require(flags.firstIndex(of: "--about-format")) | ||
| 74 | #expect(flags[i + 1] == "org") | ||
| 75 | } | ||
| 76 | |||
| 77 | @Test func moreThanFiveLinksIsInvalid() { | ||
| 78 | let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") } | ||
| 79 | #expect(edit(links: six).validationError != nil) | ||
| 80 | let five = (1...5).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") } | ||
| 81 | #expect(edit(links: five).validationError == nil) | ||
| 82 | } | ||
| 83 | |||
| 84 | @Test func aLinkWithoutAUrlIsInvalid() { | ||
| 85 | #expect(edit(links: [.init(label: "blog", url: " ")]).validationError != nil) | ||
| 86 | } | ||
| 87 | |||
| 88 | /// The server splits a link on its first `|`, so a label carrying one | ||
| 89 | /// would silently corrupt the URL rather than be rejected — caught | ||
| 90 | /// here, not just in the view. | ||
| 91 | @Test func aLabelContainingAPipeIsInvalid() { | ||
| 92 | #expect(edit(links: [.init(label: "a|b", url: "https://x.test")]).validationError != nil) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | struct ProfileEditFromProfileTests { | ||
| 97 | |||
| 98 | private let orgProfileJSON = """ | ||
| 99 | {"name":"cmc","kind":"user",\ | ||
| 100 | "description":"about me","website":"https://cleberg.net",\ | ||
| 101 | "about":"* heading\\n\\n| a | b |","about_format":"org",\ | ||
| 102 | "links":[{"label":null,"url":"https://a.test"},{"label":"blog","url":"https://b.test"}],\ | ||
| 103 | "repos":[],"activity_total":0} | ||
| 104 | """ | ||
| 105 | |||
| 106 | /// The real profile on gitbay.org is org, with tables and links that | ||
| 107 | /// render as flat text under markdown — the stored format must round | ||
| 108 | /// trip, not default away from what the server holds. | ||
| 109 | @Test func seedsFromAnOrgProfile() throws { | ||
| 110 | let data = try #require(orgProfileJSON.data(using: .utf8)) | ||
| 111 | let profile = try JSONDecoder().decode(ProfileViewModel.Profile.self, from: data) | ||
| 112 | |||
| 113 | let edit = ProfileEdit(from: profile) | ||
| 114 | |||
| 115 | #expect(edit.aboutFormat == "org") | ||
| 116 | #expect(edit.description == "about me") | ||
| 117 | #expect(edit.website == "https://cleberg.net") | ||
| 118 | #expect(edit.about == "* heading\n\n| a | b |") | ||
| 119 | #expect(edit.links.map(\.url) == ["https://a.test", "https://b.test"]) | ||
| 120 | #expect(edit.links.map(\.label) == ["", "blog"]) | ||
| 121 | } | ||
| 122 | |||
| 123 | /// Absent `about_format`, seeding falls back to markdown. | ||
| 124 | @Test func seedingFallsBackToMarkdownWhenFormatIsAbsent() throws { | ||
| 125 | let json = """ | ||
| 126 | {"name":"cmc","kind":"user",\ | ||
| 127 | "description":"hi","repos":[],"activity_total":0} | ||
| 128 | """ | ||
| 129 | let data = try #require(json.data(using: .utf8)) | ||
| 130 | let profile = try JSONDecoder().decode(ProfileViewModel.Profile.self, from: data) | ||
| 131 | |||
| 132 | let edit = ProfileEdit(from: profile) | ||
| 133 | |||
| 134 | #expect(edit.aboutFormat == "md") | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 139 | let box = StubProtocol.box() | ||
| 140 | let client = GitbayClient( | ||
| 141 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 142 | token: "test-token", | ||
| 143 | session: box.session() | ||
| 144 | ) | ||
| 145 | return (client, box) | ||
| 146 | } | ||
| 147 | |||
| 148 | private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) { | ||
| 149 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 150 | return (try #require(body["argv"] as? [String]), body["stdin"] as? String) | ||
| 151 | } | ||
| 152 | |||
| 153 | private let okJSON = #"{"protocol_version":1,"exit_code":0}"# | ||
| 154 | |||
| 155 | private let profileJSON = """ | ||
| 156 | {"protocol_version":1,"data":{"name":"cmc","kind":"user",\ | ||
| 157 | "description":"server value","website":"https://cleberg.net","repos":[],\ | ||
| 158 | "activity_total":0},"exit_code":0} | ||
| 159 | """ | ||
| 160 | |||
| 161 | @MainActor | ||
| 162 | struct ProfileSaveTests { | ||
| 163 | |||
| 164 | /// The write, then the reload `profile set` triggers — one POST, one | ||
| 165 | /// GET. Both need a stub or the flow ends up looking like a refusal | ||
| 166 | /// even when the write itself succeeded. | ||
| 167 | @Test func savesTheFullArgvWithAboutOnlyInStdin() async throws { | ||
| 168 | let (client, stub) = try makeClient() | ||
| 169 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 170 | stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show")) | ||
| 171 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 172 | let edit = ProfileEdit( | ||
| 173 | description: "hi", website: "https://cleberg.net", | ||
| 174 | about: "long form\n\nprose", aboutFormat: "org", | ||
| 175 | links: [.init(label: "blog", url: "https://b.test")] | ||
| 176 | ) | ||
| 177 | |||
| 178 | let saved = await model.saveProfile(edit) | ||
| 179 | |||
| 180 | #expect(saved) | ||
| 181 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 182 | let (argv, stdin) = try argvOf(write) | ||
| 183 | #expect(argv == [ | ||
| 184 | "profile", "set", | ||
| 185 | "--description", "hi", "--website", "https://cleberg.net", | ||
| 186 | "--link", "blog|https://b.test", | ||
| 187 | "--about-format", "org", "--file", "-", | ||
| 188 | ]) | ||
| 189 | #expect(stdin == "long form\n\nprose") | ||
| 190 | #expect(argv.contains { $0.contains("long form") } == false) | ||
| 191 | } | ||
| 192 | |||
| 193 | /// The screen shows what the server stored, not what was typed — the | ||
| 194 | /// reload's payload, not the edit, is what the caller sees next. | ||
| 195 | @Test func aSuccessfulSaveReloadsFromTheServer() async throws { | ||
| 196 | let (client, stub) = try makeClient() | ||
| 197 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 198 | stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show")) | ||
| 199 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 200 | |||
| 201 | let saved = await model.saveProfile(ProfileEdit(description: "typed value", website: "https://cleberg.net")) | ||
| 202 | |||
| 203 | #expect(saved) | ||
| 204 | let profile = try #require(model.state.value) | ||
| 205 | #expect(profile.description == "server value") | ||
| 206 | } | ||
| 207 | |||
| 208 | @Test func aRefusedSaveReturnsFalseAndSurfaces() async throws { | ||
| 209 | let (client, stub) = try makeClient() | ||
| 210 | stub.enqueue(.init(status: 200, json: """ | ||
| 211 | {"protocol_version":1,"error":"setting a profile needs write access","exit_code":4} | ||
| 212 | """)) | ||
| 213 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 214 | |||
| 215 | let saved = await model.saveProfile(ProfileEdit(description: "hi")) | ||
| 216 | |||
| 217 | #expect(saved == false) | ||
| 218 | #expect(model.actionError?.isEmpty == false) | ||
| 219 | } | ||
| 220 | |||
| 221 | /// Six links fail `ProfileEdit.validationError`; the save must refuse | ||
| 222 | /// before any request goes out, not after the server rejects it. | ||
| 223 | @Test func anInvalidEditSendsNothingAtAll() async throws { | ||
| 224 | let (client, stub) = try makeClient() | ||
| 225 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 226 | let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") } | ||
| 227 | |||
| 228 | let saved = await model.saveProfile(ProfileEdit(links: six)) | ||
| 229 | |||
| 230 | #expect(saved == false) | ||
| 231 | #expect(stub.seen.isEmpty) | ||
| 232 | } | ||
| 233 | } | ||