docs/superpowers/specs/2026-09-06-ios-parity-design.md

v1.1.0
gitbay-ios/docs/superpowers/specs/2026-09-06-ios-parity-design.md rendered · source · history · blame · raw

313 lines · 15108 bytes

Closing the iOS parity gaps

Design for clearing every outstanding no in the iOS column of krz/gitbay's Parity wiki page, minus the rows that are no by design.

Instance: gitbay.org · Forge: krz/gitbay @ 6bcd070 · Wiki: krz/gitbay/wiki/Parity

The invariant

The app adds no capability the CLI does not have. Every screen below is a rendering of a control command that already exists in the registry. Where a capability is missing from the registry, the command is added on the server — never special-cased here. Where a command refuses something, the app surfaces the refusal.

Gap inventory

The iOS column carries twenty-four no rows. Nineteen are buildable, four are no by design or should be n/a, and one is already done.

Already shipped — the wiki row is stale

profile about and links. ProfileViewModel.Profile decodes about, about_format and links, and ProfileView.aboutSection renders the about text through ReadmeView under about.org or about.md while the links render as a list (commit 8d2421a). The wiki's note that "the iOS Profile decoder lists its keys explicitly, so it ignores about and links" describes a state that no longer exists.

Not iOS work

Row Why
API token mint SSH-only by design, per the wiki's own rule
delete, transfer SSH-only by design; wants a typed confirmation
request a login link The app authenticates with a pasted bearer token. A mailed one-time web link has nothing to unlock in a native client
account export A JSON bundle has nowhere useful to land on a phone — the same argument the wiki already accepts for archive download

The last two are proposed as n/a reclassifications, not deferrals.

choose body markup is not on this list. It is a genuine gap and MR 13 builds it. The rule the wiki states — a capability lands over SSH first, then on the web in the same merge request — governs SSH to web, not web to iOS, so the web also being no does not order the two surfaces. The wiki says as much: both nos are "the absence of a picker on the web form and in the iOS composer", which is missing UI, not a rule.

Buildable

Row Command MR
labels: list, colour label list, label set, label remove 1
issue filter by label, assignee, author, milestone issue list --label --assignee --author --milestone 2
issue search title and body issue list --search 2
MR search title and body mr list --search 2
search issues and merge requests search <q> [--kind repo|issue|mr] 3
MR draft, ready mr draft, mr ready 4
MR retarget mr retarget <repo> <n> <branch> 4
stacked merge requests stacked_on / stacked on mr list, mr show 4
compare two refs repo diff <repo> <base> <head> 5
notification inbox notifications list, notifications read 6
watch, mute repo watch, repo unwatch 7
build cancel build cancel <repo> <n> 8
dependency checks on/off repo deps enable, repo deps disable 9
dependency status repo deps status 9
release delete release delete <repo> <tag> --yes 10
org create, rename org create, org rename 11
profile set profile set 12
choose body markup (MRs) --format md|org on mr create, mr edit, mr comment 13
choose body markup (issues) --format md|org on issue create, issue edit, issue comment 13

What the wire already carries

Three rows need no server change and no new command, only decoding. The fields exist and are omitempty, which is why they are absent from a sample response rather than unimplemented:

  • draft — internal/control/mr.go:337, on both mr list and mr show
  • stacked_on, stacked — internal/control/mr.go:349-350, each a {number, title} reference
  • color — internal/store/labels.go:8, on label list rows

The decoding tests for these must cover the absent case, not just the present one. A non-draft merge request decodes with the key missing entirely.

The one server gap

Watch state is not readable. repo show emits default_branch, description, mirrors, path, protected_branches, topics, visibility and website — no watch field. store.RepoWatchState (internal/store/inbox.go:114) returns watching, muted or "" but is internal to the server and reachable from no command.

So MR 7 ships Watch and Mute as two stateless actions that report what the command returned, and an issue is filed against krz/gitbay asking for the state on repo show. When it lands, the two actions become one toggle. The parity row closes on the stateless version, since the capability is present.

Components

Filter value types

IssueListViewModel and MRListViewModel are 43 and 44 lines. Each owns a StateFilter enum and rebuilds PagedListModel.argv when it changes; PagedListModel.argv is already mutable and documented as "set by the owning view model when a filter changes".

Replace the bare enum with a struct that renders itself to flags:

struct IssueFilter: Equatable {
    var state: State = .open
    var search: String = ""
    var label: String?
    var assignee: String?
    var author: String?
    var milestone: String?   // a title, or "none"

    /// `--state`, plus one flag per set field. Empty strings are omitted
    /// rather than sent, since `--search ""` is not the same as no search.
    var flags: [String]
}

MRFilter is the same shape minus label and assignee, which mr list does not accept, plus source_gone in its state set. Both view models keep their existing didSet-reload behaviour; only the type widens.

This one change carries four parity rows. It is the highest value per line in the plan.

Label store

Labels render today as GBChip(label, .secondary) (IssueListView.swift:89) — one flat colour for every label, in the list only. label list returns {name, color?, issues} per label.

A repo-scoped store reads it once and feeds three consumers: chips in the issue list, chips in the issue detail, and the label picker in IssueFilter. A label with no stored colour derives one from its name, matching what the web does, so a repository that has never set a colour still reads as coloured rather than uniformly grey. The derivation must produce the same hue the web produces for the same name — the two surfaces show the same label side by side.

The management screen (RepoRoute.labels) lists labels with their use counts and dispatches label set to create or recolour and label remove to delete. label remove takes the label off every issue, so it is destructive and gated.

Splitting DiffView

DiffView.init(client:repo:number:) (DiffView.swift:9) loads mr diff, parses it with UnifiedDiff.parse, and renders it. Compare needs the same renderer over a different source: repo diff returns {base, head, merge_base, patch, truncated}, where patch is the same unified-diff text.

Split it in two: a view over [UnifiedDiff.File] that does the rendering, and two loaders that produce that array — one from mr diff, one from repo diff. DiffFileSection, HunkView and LineView are already parameterised on UnifiedDiff types and do not change.

This is the only existing code the plan modifies rather than extends. The boundary is wrong for a second caller, and duplicating a diff renderer to avoid touching it would be worse.

truncated must be surfaced. A compare across a wide range returns a partial patch, and a renderer that silently shows less than the whole diff is a bug the user cannot see.

Routes

New RepoRoute cases: labels(repo:), compare(repo:base:head:), and notifications. Dependency status renders inside the existing RepoSettingsView rather than taking a route, matching where the web puts it.

Write path

AccountViewModel.perform(argv:stdin:) and RepoSettingsViewModel.perform(argv:) are already near-duplicates of each other, eight lines each, setting working, running the command, and capturing actionError. New view models copy the pattern. A third and fourth copy is cheaper than a shared base class for eight lines, and the existing duplication is not this plan's to fix.

Merge request sequence

Ordering is driven by shared machinery, not by the wiki's section order.

1. Labels. Label store, colour on every chip, management screen. First because MR 2's label filter picks from real labels with real colours.

2. List filters and search. IssueFilter and MRFilter; a search field and filter controls on the issue and merge request lists. Closes three rows.

3. Instance-wide search. The Explore tab searches repositories today through repo search (RepoListViewModel.swift:63). Widen it to search, which covers repositories plus the title and body of every issue and merge request the caller can read, with a kind control for repo | issue | mr. Results carry {kind, repo, number, title, author, state, updated_at} and route to the existing repo, issue and merge request destinations. What the user types is quoted term by term server-side, so an FTS5 operator is a word to match — the app passes the query through unescaped.

4. Draft, ready, retarget, stack. Decode draft, stacked_on and stacked. A draft badge on list rows and the detail header. Draft and Ready actions. Retarget picks a branch from the repo's refs and states that the reviews go stale, because an approval was of the diff against the old branch. A stack section links both directions — what this is stacked on, and what is stacked on it. A squash or rebase merge is refused while anything is stacked on the merge request, so the merge sheet hides those strategies rather than letting the user pick one the server will reject. mr create also takes --draft, so the create form gains the checkbox in the same merge request. Closes three rows.

5. Compare two refs. The DiffView split, then a compare screen reachable from the Refs screen with two ref pickers. Renders merge_base and handles truncated.

6. Notification inbox. notifications list pages on PagedListModel with the same cursors as every other list; --all toggles between unread and everything. Items carry {id, repo, kind, actor, summary, path, created_at, read_at?}; path routes to the thread. notifications read <id>... and --all clear. The unread count sits on the Dashboard, where the web puts it in its rail.

7. Watch and mute. Two actions in repo settings. Stateless, per the server gap above.

8. Build cancel. An action on build detail, offered for queued and running builds, confirmed before firing. Cancelling withdraws a queued build or ends a running one within a couple of seconds; cancelling a duplicate of a commit that already passed puts that result back on the commit.

9. Dependency checks. A toggle in repo settings dispatching repo deps enable and repo deps disable, with the status report under it: enabled, last_check, last_error, a link to the tracking issue issue_number, and the behind rows of {ecosystem, name, current, latest}. Checks are off until a repository's admin turns them on. Closes two rows.

10. Release delete. A destructive action on release detail. release delete requires --yes, so the app confirms before sending it, and the confirmation says the assets go too.

11. Org create and rename. Create from the org list. Rename in org settings, stating that clone URLs change. Note org delete is n/a on iOS, and stays so.

12. Profile set. An edit form on your own profile: description, website, about text with a md | org format picker, and up to five labelled links. The server caps the list at five and '' clears a field. profile set is not SSHOnly — nothing about a bio is a credential, and the JSON API runs it — so the no on every surface is missing UI rather than a rule.

13. Body markup picker. --format md|org on issue create, issue edit, issue comment, mr create, mr edit and mr comment. Nothing in the compose path mentions a format today: ComposeSheet is a title-and-body form shared by the issue and merge request create and edit sheets, and comments compose separately in IssueView and MRView. So the picker lands in two places, not one — an optional format binding on ComposeSheet covers create and edit for both nouns, and each comment field gains its own. The stored format is what every surface renders from, so the picker's default matters: editing an existing body defaults to the format that body was stored with, read from body_format on issue show and mr show, and changing it reinterprets prose that already exists. New bodies default to markdown, as they do now. Diff-line comments have no format column and are always markdown, so mr diff-comment is untouched. Closes two rows.

release create and release edit take --format too, but the wiki has no parity row for release markup, so that is left alone rather than widened silently.

Nineteen rows, thirteen merge requests. Each flips its own parity rows in the same merge request, which is what the wiki page requires.

Work in krz/gitbay

One merge request, landing before the iOS work:

  • Flip profile about and links to yes on iOS.
  • Reclassify request a login link and account export from no to n/a in the iOS column, each with its reason. API token mint and delete, transfer are already covered by the SSH-only section and need no row change.

One issue:

  • repo show should carry watch state, so a client can render a toggle rather than two buttons. store.RepoWatchState already computes it.

Testing

gitbayTests is hermetic and runs offline; network calls go through a stubbed URLProtocol. Every merge request adds view-model tests there. Two kinds carry the weight:

Argv shape. A filter that renders a flag issue list does not accept is a server error the app cannot catch at compile time, and the failure surfaces as an empty list rather than a crash. Each filter combination asserts its exact argv.

Absent-field decoding. For draft, stacked_on, stacked and color, the test that matters is the one where the key is missing, since that is the common case on the wire and the case a fixture written by hand tends to omit.

The live smoke suite in gitbayUITests stays opt-in and is not extended by this plan.

Stopping early

The order is chosen so that stopping is cheap. Merge requests 1 through 6 clear the whole triage, review and respond loop — the app's stated brief — and 13 belongs to it too, but sits last because it touches the composer every other merge request also touches and is cheaper once they have landed. 7 through 12 are repository and account administration, which is the part of the surface a phone is least likely to be the right tool for. If the plan is cut, it is cut after 6, with 13 pulled forward.