docs/superpowers/specs/2026-09-06-ios-parity-design.md
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 bothmr listandmr showstacked_on,stacked—internal/control/mr.go:349-350, each a{number, title}referencecolor—internal/store/labels.go:8, onlabel listrows
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 linkstoyeson iOS. - Reclassify
request a login linkandaccount exportfromnoton/ain the iOS column, each with its reason.API token mintanddelete, transferare already covered by the SSH-only section and need no row change.
One issue:
repo showshould carry watch state, so a client can render a toggle rather than two buttons.store.RepoWatchStatealready 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.