Commit 0c52dbc5ac

0c52dbc5ac41f49225ef7a92e310db7aa4be748f

parent: eba8aea07b

Unsigned

cmc <hello@cleberg.net> · 2026-09-30 02:43 UTC

UX review: flow and consistency fixes (!142)

- After creating an issue, the new issue opens.
- MR view: Gates and Checks sit directly under the description.
- The Dashboard tab carries the unread count as a badge; push settings moved below the inbox.
- Build filter sheet presents at `.large`.
- `#N`, `!N`, `owner/name#N` and `owner/name!N` in markdown bodies and comments link in-app, resolved as the web's autolink does (`#` issue, `!` MR). Nothing inside code spans, fenced code or existing links. Org-format bodies are not linked.
- States read in sentence case everywhere (Open, Merged, Closed, Source gone): filters, chips, issue header, search results. MR filter says "Source gone". "Merge requests" in sentence case in titles and the repo tile.
- Repository settings: "merging a merge request becomes the only way to change them".
- SSH key removal asks for the fingerprint's first eight characters after `SHA256:` (the web's confirm value); repository rename asks for `owner/name` (the web's transfer/delete confirm). The button stays disabled until it matches. Live suite updated to type both.
- iPad: Dashboard and Repositories use a `NavigationSplitView` at regular width. The sidebar selection is the root of the detail stack, which keeps the compact path, so notification and universal-link routing work at both widths. Compact is unchanged.

Closes #19

Layout: unified · split

gitbay/Account/AccountViewModel.swift +5
@@ -14,6 +14,11 @@ final class AccountViewModel {
1414 /// A name for the key (v1.21.0); omitted when none is set.
1515 let label: String?
1616 var id: String { fingerprint }
17 /// What removal asks to be typed, as the web does: the first
18 /// eight characters of the fingerprint after `SHA256:`.
19 var confirmation: String {
20 String(fingerprint.replacingOccurrences(of: "SHA256:", with: "").prefix(8))
21 }
1722 }
1823
1924 nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable {
gitbay/ContentView.swift +78 −6
@@ -5,11 +5,14 @@ struct ContentView: View {
55 @Environment(SessionStore.self) private var session
66 @Environment(PushRouter.self) private var router
77 @Environment(\.openURL) private var openURL
8 @Environment(\.horizontalSizeClass) private var sizeClass
89
910 enum Tabs { case dashboard, feed, repositories, explore, profile }
1011
1112 @State private var selectedTab = Tabs.dashboard
1213 @State private var dashboardPath = NavigationPath()
14 @State private var repoPath = NavigationPath()
15 @State private var unread = 0
1316
1417 /// whoami's admin flag, read on the My Profile tab, decides whether
1518 /// the account menu offers Admin.
@@ -28,11 +31,18 @@ struct ContentView: View {
2831 if let client = session.client, let account = session.current {
2932 TabView(selection: $selectedTab) {
3033 Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) {
31 NavigationStack(path: $dashboardPath) {
32 DashboardView(client: client)
33 .navigationDestinations(client: client)
34 if sizeClass == .regular {
35 SplitStack(path: $dashboardPath, client: client, placeholder: "Nothing selected") {
36 DashboardView(client: client, unread: $unread)
37 }
38 } else {
39 NavigationStack(path: $dashboardPath) {
40 DashboardView(client: client, unread: $unread)
41 .navigationDestinations(client: client)
42 }
3443 }
3544 }
45 .badge(unread)
3646 Tab("Feed", systemImage: "bolt", value: Tabs.feed) {
3747 NavigationStack {
3848 FeedView(client: client)
@@ -40,9 +50,15 @@ struct ContentView: View {
4050 }
4151 }
4252 Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) {
43 NavigationStack {
44 RepoListView(client: client)
45 .navigationDestinations(client: client)
53 if sizeClass == .regular {
54 SplitStack(path: $repoPath, client: client, placeholder: "No repository selected") {
55 RepoListView(client: client)
56 }
57 } else {
58 NavigationStack(path: $repoPath) {
59 RepoListView(client: client)
60 .navigationDestinations(client: client)
61 }
4662 }
4763 }
4864 Tab("Explore", systemImage: "safari", value: Tabs.explore) {
@@ -106,6 +122,7 @@ struct ContentView: View {
106122 private func routePendingNotification(_ account: Account) async {
107123 if pathAccount != account.id {
108124 dashboardPath = NavigationPath()
125 repoPath = NavigationPath()
109126 selectedTab = .dashboard
110127 pathAccount = account.id
111128 }
@@ -176,6 +193,46 @@ struct ContentView: View {
176193
177194}
178195
196/// A list as a sidebar at regular width. What it selects is the root of
197/// the detail column's stack; the stack's path is the one the compact
198/// layout's stack uses, so notification and link routing, which append
199/// to it, work at either width.
200private struct SplitStack<Sidebar: View>: View {
201
202 @Binding var path: NavigationPath
203 let client: GitbayClient
204 let placeholder: String
205 @ViewBuilder let sidebar: () -> Sidebar
206
207 @State private var selection: AnyHashable?
208
209 var body: some View {
210 NavigationSplitView {
211 sidebar()
212 .environment(\.sidebarSelection, Binding(
213 get: { selection },
214 set: { route in
215 selection = route
216 path = NavigationPath()
217 }
218 ))
219 } detail: {
220 NavigationStack(path: $path) {
221 Group {
222 if let selection {
223 RouteDestinations(client: client).destination(selection)
224 } else {
225 Text(placeholder)
226 .font(.gbSans(.subheadline))
227 .foregroundStyle(.secondary)
228 }
229 }
230 .navigationDestinations(client: client)
231 }
232 }
233 }
234}
235
179236/// Every value-routed screen, attachable to any stack root.
180237private struct RouteDestinations: ViewModifier {
181238
@@ -206,6 +263,21 @@ private struct RouteDestinations: ViewModifier {
206263 }
207264 }
208265
266 /// A type-erased route's screen, for a sidebar selection.
267 @ViewBuilder
268 func destination(_ route: AnyHashable) -> some View {
269 switch route.base {
270 case let r as RepoRoute: destination(r, client: client)
271 case let r as MRRoute: destination(r, client: client)
272 case let r as IssueRoute: destination(r, client: client)
273 case let r as BuildRoute: destination(r, client: client)
274 case let r as ReleaseRoute: destination(r, client: client)
275 case let r as OrgRoute: destination(r, client: client)
276 case let r as SnippetRoute: destination(r, client: client)
277 default: EmptyView()
278 }
279 }
280
209281 @ViewBuilder
210282 private func destination(_ route: RepoRoute, client: GitbayClient) -> some View {
211283 switch route {
gitbay/Issues/IssueFilter.swift +1 −1
@@ -8,7 +8,7 @@ nonisolated struct IssueFilter: Equatable, Sendable {
88 enum State: String, CaseIterable, Identifiable, Sendable {
99 case open, closed, all
1010 var id: String { rawValue }
11 var label: String { rawValue.capitalized }
11 var label: String { stateLabel(rawValue) }
1212 }
1313
1414 var state: State = .open
gitbay/MRs/MRFilter.swift +8 −6
@@ -10,12 +10,7 @@ nonisolated struct MRFilter: Equatable, Sendable {
1010 case all
1111
1212 var id: String { rawValue }
13 /// Segment text. Five segments share a phone's width, so the
14 /// source-gone state is one word; the rows and chips still say
15 /// "source gone" in full.
16 var label: String {
17 self == .sourceGone ? "Gone" : rawValue.capitalized
18 }
13 var label: String { stateLabel(rawValue) }
1914 }
2015
2116 var state: State = .open
@@ -45,3 +40,10 @@ nonisolated struct MRFilter: Equatable, Sendable {
4540 return state == .all ? "No \(noun)." : "No \(state.label.lowercased()) \(noun)."
4641 }
4742}
43
44/// A wire state (`open`, `source_gone`) as the app shows it: sentence
45/// case, underscores as spaces.
46nonisolated func stateLabel(_ state: String) -> String {
47 let words = state.replacingOccurrences(of: "_", with: " ")
48 return words.prefix(1).uppercased() + words.dropFirst()
49}
gitbay/Views/Account/AccountView.swift +15 −8
@@ -11,6 +11,7 @@ struct AccountView: View {
1111 @State private var verifyCode = ""
1212 @State private var removingEmail: AccountViewModel.EmailAddress?
1313 @State private var removingSSH: AccountViewModel.SSHKey?
14 @State private var sshConfirmText = ""
1415 @State private var labellingSSH: AccountViewModel.SSHKey?
1516 @State private var sshLabelText = ""
1617 @State private var removingPGP: AccountViewModel.PGPKey?
@@ -129,20 +130,25 @@ struct AccountView: View {
129130 } message: { _ in
130131 Text("An empty label clears it.")
131132 }
132 .confirmationDialog(
133 "Remove this SSH key? Anything authenticating with it loses access.",
133 .alert(
134 "Remove this SSH key?",
134135 isPresented: Binding(
135136 get: { removingSSH != nil },
136137 set: { if !$0 { removingSSH = nil } }
137 )
138 ) {
138 ),
139 presenting: removingSSH
140 ) { key in
141 TextField("type \(key.confirmation) to confirm", text: $sshConfirmText)
142 .autocorrectionDisabled()
143 .textInputAutocapitalization(.never)
139144 Button("Remove", role: .destructive) {
140 if let key = removingSSH {
141 Task { await model.removeSSHKey(key) }
142 }
145 Task { await model.removeSSHKey(key) }
143146 removingSSH = nil
144147 }
145 Button("Cancel", role: .cancel) {}
148 .disabled(sshConfirmText != key.confirmation)
149 Button("Cancel", role: .cancel) { removingSSH = nil }
150 } message: { key in
151 Text("Anything authenticating with it loses access. Type \(key.confirmation) to confirm.")
146152 }
147153 .confirmationDialog(
148154 "Remove this PGP key? Commits it signed become unverifiable.",
@@ -209,6 +215,7 @@ struct AccountView: View {
209215 }
210216 .swipeActions {
211217 Button("Remove", role: .destructive) {
218 sshConfirmText = ""
212219 removingSSH = key
213220 }
214221 Button("Label") {
gitbay/Views/Account/NotificationsView.swift +2 −2
@@ -17,8 +17,6 @@ struct NotificationsView: View {
1717
1818 var body: some View {
1919 List {
20 pushSection
21
2220 Picker("Filter", selection: Bindable(model).showAll) {
2321 Text("Unread").tag(false)
2422 Text("All").tag(true)
@@ -43,6 +41,8 @@ struct NotificationsView: View {
4341 }
4442 }
4543 PageFooter(list: model.list)
44
45 pushSection
4646 }
4747 .overlay { LoadStateOverlay(state: model.state) }
4848 .navigationTitle("Notifications")
gitbay/Views/Builds/BuildListView.swift +1
@@ -57,6 +57,7 @@ struct BuildListView: View {
5757 }
5858 .sheet(isPresented: $filtering) {
5959 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 }
60 .presentationDetents([.large])
6061 }
6162 .task {
6263 await model.loadIfNeeded()
gitbay/Views/Dashboard/DashboardView.swift +10 −4
@@ -8,14 +8,17 @@ struct DashboardView: View {
88 @State private var model: DashboardViewModel
99 @State private var createModel: RepoCreateViewModel
1010 @State private var composing = false
11 /// The unread count, for the Dashboard tab's badge.
12 @Binding var unread: Int
1113
12 init(client: GitbayClient) {
14 init(client: GitbayClient, unread: Binding<Int>) {
15 _unread = unread
1316 _model = State(initialValue: DashboardViewModel(client: client))
1417 _createModel = State(initialValue: RepoCreateViewModel(client: client))
1518 }
1619
1720 var body: some View {
18 List {
21 RoutingList {
1922 if let data = model.state.value {
2023 itemSection(
2124 "Waiting on your review", items: data.reviewQueue,
@@ -45,7 +48,7 @@ struct DashboardView: View {
4548 .navigationTitle("Dashboard")
4649 .toolbar {
4750 ToolbarItem(placement: .topBarTrailing) {
48 NavigationLink(value: RepoRoute.notifications) {
51 RouteLink(value: RepoRoute.notifications) {
4952 HStack(spacing: 2) {
5053 Image(systemName: "bell")
5154 if let unread = model.state.value?.unread, unread > 0 {
@@ -78,6 +81,9 @@ struct DashboardView: View {
7881 }
7982 .task { await model.load() }
8083 .refreshable { await model.load() }
84 .onChange(of: model.state.value?.unread) { _, count in
85 if let count { unread = count }
86 }
8187 }
8288
8389 private var bellLabel: String {
@@ -124,7 +130,7 @@ struct DashboardView: View {
124130 Text(item.updatedAt, format: .relative(presentation: .named))
125131 if item.state == "source_gone" {
126132 Text("·")
127 GBChip("source gone", .secondary)
133 GBChip(stateLabel(item.state), .secondary)
128134 }
129135 }
130136 .font(.gbSans(.caption))
gitbay/Views/Discovery/ExploreView.swift +1 −1
@@ -159,7 +159,7 @@ private struct SearchEntryRow: View {
159159 }
160160 HStack(spacing: 6) {
161161 if let state = result.state {
162 GBChip(state, color(for: state))
162 GBChip(stateLabel(state), color(for: state))
163163 }
164164 Spacer()
165165 if let updatedAt = result.updatedAt {
gitbay/Views/Issues/IssueListView.swift +10 −2
@@ -9,8 +9,12 @@ struct IssueListView: View {
99 @State private var draftTitle = ""
1010 @State private var draftBody = ""
1111 @State private var draftFormat = "md"
12 @State private var createdIssue: Int64?
13
14 private let client: GitbayClient
1215
1316 init(client: GitbayClient, repo: String) {
17 self.client = client
1418 _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo))
1519 _createModel = State(initialValue: IssueCreateViewModel(client: client, repoPath: repo))
1620 }
@@ -61,7 +65,7 @@ struct IssueListView: View {
6165 }
6266 .sheet(isPresented: $filtering) {
6367 FilterSheet(
64 title: "Filter Issues",
68 title: "Filter issues",
6569 author: Bindable(model).filter.author,
6670 milestone: Bindable(model).filter.milestone,
6771 milestones: model.milestones,
@@ -92,16 +96,20 @@ struct IssueListView: View {
9296 ) {
9397 createModel.format = draftFormat
9498 Task {
95 if await createModel.create(title: draftTitle, body: draftBody) != nil {
99 if let number = await createModel.create(title: draftTitle, body: draftBody) {
96100 draftTitle = ""
97101 draftBody = ""
98102 draftFormat = "md"
99103 composing = false
104 createdIssue = number
100105 await model.load()
101106 }
102107 }
103108 }
104109 }
110 .navigationDestination(item: $createdIssue) { number in
111 IssueView(client: client, repo: model.repoPath, number: number)
112 }
105113 .task { await model.loadIfNeeded() }
106114 .task { await createModel.loadTemplates() }
107115 .refreshable { await model.load() }
gitbay/Views/Issues/IssueView.swift +4 −3
@@ -31,7 +31,7 @@ struct IssueView: View {
3131
3232 if let body = issue.body, !body.isEmpty {
3333 Section {
34 FormattedBodyView(text: body, format: issue.bodyFormat)
34 FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath)
3535 .padding(.vertical, 4)
3636 }
3737 }
@@ -41,6 +41,7 @@ struct IssueView: View {
4141 }
4242 }
4343 .overlay { LoadStateOverlay(state: model.state) }
44 .referenceNavigation()
4445 .scrollDismissesKeyboard(.interactively)
4546 .navigationTitle("#\(model.number)")
4647 .navigationBarTitleDisplayMode(.inline)
@@ -72,7 +73,7 @@ struct IssueView: View {
7273 Text(issue.title)
7374 .font(.gbSans(.headline))
7475 HStack(spacing: 6) {
75 Label(issue.state, systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill")
76 Label(stateLabel(issue.state), systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill")
7677 .font(.gbSans(.caption).weight(.medium))
7778 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
7879 Text("by \(issue.author)")
@@ -166,7 +167,7 @@ struct IssueView: View {
166167 .font(.gbSans(.caption))
167168 .foregroundStyle(.tertiary)
168169 }
169 FormattedBodyView(text: comment.body, format: comment.bodyFormat)
170 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
170171 .font(.gbSans(.subheadline))
171172 }
172173 .padding(.vertical, 2)
gitbay/Views/MRs/MRListView.swift +3 −3
@@ -32,7 +32,7 @@ struct MRListView: View {
3232 }
3333 .overlay { LoadStateOverlay(state: model.state) }
3434 .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies")
35 .navigationTitle("Merge Requests")
35 .navigationTitle("Merge requests")
3636 .navigationBarTitleDisplayMode(.inline)
3737 .toolbar {
3838 ToolbarItem(placement: .topBarTrailing) {
@@ -58,7 +58,7 @@ struct MRListView: View {
5858 }
5959 .sheet(isPresented: $filtering) {
6060 FilterSheet(
61 title: "Filter Merge Requests",
61 title: "Filter merge requests",
6262 author: Bindable(model).filter.author,
6363 milestone: Bindable(model).filter.milestone,
6464 milestones: model.milestones,
@@ -243,7 +243,7 @@ struct MRStateBadge: View {
243243 let state: String
244244
245245 var body: some View {
246 GBChip(state.replacingOccurrences(of: "_", with: " "), color)
246 GBChip(stateLabel(state), color)
247247 }
248248
249249 /// The web's chip mapping: open=ok, merged=done, closed=bad,
gitbay/Views/MRs/MRView.swift +10 −8
@@ -37,11 +37,18 @@ struct MRView: View {
3737
3838 if let body = mr.body, !body.isEmpty {
3939 Section {
40 FormattedBodyView(text: body, format: mr.bodyFormat)
40 FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath)
4141 .padding(.vertical, 4)
4242 }
4343 }
4444
45 if let gates = mr.gates, mr.isOpen {
46 gatesSection(gates)
47 }
48 if let checks = mr.checks, !checks.isEmpty {
49 checksSection(checks, combined: mr.checksCombined)
50 }
51
4552 milestoneSection(mr)
4653 labelsSection(mr)
4754 stackSection(mr)
@@ -55,12 +62,6 @@ struct MRView: View {
5562 if let commits = mr.commits, !commits.isEmpty {
5663 commitsSection(commits)
5764 }
58 if let checks = mr.checks, !checks.isEmpty {
59 checksSection(checks, combined: mr.checksCombined)
60 }
61 if let gates = mr.gates, mr.isOpen {
62 gatesSection(gates)
63 }
6465 if let reviews = mr.reviews, !reviews.isEmpty {
6566 reviewsSection(reviews)
6667 }
@@ -71,6 +72,7 @@ struct MRView: View {
7172 }
7273 }
7374 .overlay { LoadStateOverlay(state: model.state) }
75 .referenceNavigation()
7476 .scrollDismissesKeyboard(.interactively)
7577 .navigationTitle("!\(model.number)")
7678 .navigationBarTitleDisplayMode(.inline)
@@ -531,7 +533,7 @@ struct MRView: View {
531533 .font(.gbSans(.caption))
532534 .foregroundStyle(.tertiary)
533535 }
534 FormattedBodyView(text: comment.body, format: comment.bodyFormat)
536 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
535537 .font(.gbSans(.subheadline))
536538 }
537539 .padding(.vertical, 2)
gitbay/Views/Repos/FormattedBodyView.swift +4 −1
@@ -8,6 +8,9 @@ import SwiftUI
88struct FormattedBodyView: View {
99 let text: String
1010 var format: String?
11 /// The repository `#N` and `!N` refer to; nil links no references.
12 /// The links open through `referenceNavigation()`.
13 var repo: String?
1114
1215 @Environment(\.colorScheme) private var colorScheme
1316
@@ -22,7 +25,7 @@ struct FormattedBodyView: View {
2225 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)
2326 )
2427 } else {
25 MarkdownView(markdown: text)
28 MarkdownView(markdown: text, referenceRepo: repo)
2629 }
2730 }
2831}
gitbay/Views/Repos/MarkdownView.swift +36 −10
@@ -10,6 +10,8 @@ struct MarkdownView: View {
1010 /// Repository context for resolving relative link targets. Nil leaves them as authored,
1111 /// which is right for a body with no file to be relative to.
1212 var linkOptions: OrgRenderOptions?
13 /// The repository `#N` and `!N` refer to. Nil links no references.
14 var referenceRepo: String?
1315
1416 var body: some View {
1517 VStack(alignment: .leading, spacing: 12) {
@@ -223,7 +225,8 @@ struct MarkdownView: View {
223225 private func blockView(_ block: Block) -> some View {
224226 switch block {
225227 case .heading(let level, let text):
226 Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions)
228 Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions,
229 referenceRepo: referenceRepo)
227230 .font(headingFont(level))
228231 .padding(.top, level <= 2 ? 8 : 4)
229232 case .code(let code):
@@ -238,7 +241,7 @@ struct MarkdownView: View {
238241 RoundedRectangle(cornerRadius: 2)
239242 .fill(.tertiary)
240243 .frame(width: 3)
241 Self.inline(text, linkOptions: linkOptions).foregroundStyle(.secondary)
244 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo).foregroundStyle(.secondary)
242245 }
243246 .fixedSize(horizontal: false, vertical: true)
244247 case .bullet(let items):
@@ -246,7 +249,7 @@ struct MarkdownView: View {
246249 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
247250 HStack(alignment: .firstTextBaseline, spacing: 8) {
248251 Text("•")
249 Self.inline(item, linkOptions: linkOptions)
252 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
250253 .fixedSize(horizontal: false, vertical: true)
251254 }
252255 }
@@ -256,17 +259,17 @@ struct MarkdownView: View {
256259 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
257260 HStack(alignment: .firstTextBaseline, spacing: 8) {
258261 Text("\(index + 1).").monospacedDigit()
259 Self.inline(item, linkOptions: linkOptions)
262 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
260263 .fixedSize(horizontal: false, vertical: true)
261264 }
262265 }
263266 }
264267 case .table(let table):
265 MarkdownTableView(table: table, linkOptions: linkOptions)
268 MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo)
266269 case .rule:
267270 Divider()
268271 case .paragraph(let text):
269 Self.inline(text, linkOptions: linkOptions)
272 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
270273 }
271274 }
272275
@@ -277,15 +280,18 @@ struct MarkdownView: View {
277280 static func inline(
278281 _ text: String,
279282 codeStyle: Font.TextStyle = .subheadline,
280 linkOptions: OrgRenderOptions? = nil
283 linkOptions: OrgRenderOptions? = nil,
284 referenceRepo: String? = nil
281285 ) -> Text {
282 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions))
286 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions,
287 referenceRepo: referenceRepo))
283288 }
284289
285290 static func attributed(
286291 _ text: String,
287292 codeStyle: Font.TextStyle = .subheadline,
288 linkOptions: OrgRenderOptions? = nil
293 linkOptions: OrgRenderOptions? = nil,
294 referenceRepo: String? = nil
289295 ) -> AttributedString {
290296 guard var attributed = try? AttributedString(
291297 markdown: text,
@@ -306,9 +312,28 @@ struct MarkdownView: View {
306312 attributed[range].link = url
307313 }
308314 }
315 if let referenceRepo {
316 linkReferences(in: &attributed, repo: referenceRepo)
317 }
309318 return attributed
310319 }
311320
321 /// Links `#N`, `!N` and `owner/name#N` outside code spans and
322 /// existing links. Fenced code never reaches here.
323 private static func linkReferences(in attributed: inout AttributedString, repo: String) {
324 for reference in bodyReferences(in: String(attributed.characters), repo: repo) {
325 let lower = attributed.characters.index(attributed.startIndex,
326 offsetBy: reference.range.lowerBound)
327 let upper = attributed.characters.index(lower, offsetBy: reference.range.count)
328 let skip = attributed[lower..<upper].runs.contains {
329 $0.link != nil || $0.inlinePresentationIntent?.contains(.code) == true
330 }
331 if !skip, let url = referenceURL(reference.destination) {
332 attributed[lower..<upper].link = url
333 }
334 }
335 }
336
312337 private func headingFont(_ level: Int) -> Font {
313338 switch level {
314339 case 1: .title2.bold()
@@ -338,6 +363,7 @@ struct MarkdownView: View {
338363private struct MarkdownTableView: View {
339364 let table: MarkdownView.Table
340365 var linkOptions: OrgRenderOptions?
366 var referenceRepo: String?
341367
342368 private static let spacing: CGFloat = 14
343369
@@ -372,7 +398,7 @@ private struct MarkdownTableView: View {
372398
373399 private func cell(_ text: String, _ column: Int) -> some View {
374400 let alignment = table.alignments[column]
375 return MarkdownView.inline(text, linkOptions: linkOptions)
401 return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
376402 .multilineTextAlignment(Self.textAlignment(alignment))
377403 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
378404 .fixedSize(horizontal: cellWidth == nil, vertical: true)
gitbay/Views/Repos/RepoListView.swift +2 −2
@@ -14,7 +14,7 @@ struct RepoListView: View {
1414 }
1515
1616 var body: some View {
17 List {
17 RoutingList {
1818 ForEach(model.visibleRepos) { repo in
1919 NavigationLink(value: RepoRoute.repo(repo.path)) {
2020 RepoRow(repo: repo)
@@ -29,7 +29,7 @@ struct RepoListView: View {
2929 .navigationTitle("Repositories")
3030 .toolbar {
3131 ToolbarItem(placement: .topBarTrailing) {
32 NavigationLink(value: RepoRoute.bookmarks) {
32 RouteLink(value: RepoRoute.bookmarks) {
3333 Image(systemName: "bookmark")
3434 }
3535 .accessibilityLabel("Bookmarks")
gitbay/Views/Repos/RepoSettingsView.swift +9 −3
@@ -12,6 +12,7 @@ struct RepoSettingsView: View {
1212 @State private var newTagGlob = ""
1313 @State private var renaming = false
1414 @State private var newName = ""
15 @State private var renameConfirm = ""
1516 /// Set once a rename succeeds: this screen's repository is gone and
1617 /// the renamed one is a value push away.
1718 @State private var renamedTo: String?
@@ -99,6 +100,9 @@ struct RepoSettingsView: View {
99100 TextField("New name", text: $newName)
100101 .autocorrectionDisabled()
101102 .textInputAutocapitalization(.never)
103 TextField("type \(model.repoPath) to confirm", text: $renameConfirm)
104 .autocorrectionDisabled()
105 .textInputAutocapitalization(.never)
102106 Button("Rename") {
103107 Task {
104108 if let path = await model.rename(to: newName) {
@@ -106,10 +110,11 @@ struct RepoSettingsView: View {
106110 }
107111 }
108112 }
109 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
113 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty
114 || renameConfirm != model.repoPath)
110115 Button("Cancel", role: .cancel) {}
111116 } message: {
112 Text("Clone URLs change.")
117 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
113118 }
114119 .confirmationDialog(
115120 "Remove topic \(removingTopic ?? "")?",
@@ -258,7 +263,7 @@ struct RepoSettingsView: View {
258263 } header: {
259264 Text("Protected branches")
260265 } footer: {
261 Text("Protected branches refuse force pushes and deletion. Merge requests only also refuses direct pushes to them; mr merge becomes their only writer.")
266 Text("Protected branches refuse force pushes and deletion. Merge requests only also refuses direct pushes to them; merging a merge request becomes the only way to change them.")
262267 }
263268 }
264269
@@ -448,6 +453,7 @@ struct RepoSettingsView: View {
448453 Section {
449454 Button {
450455 newName = String(model.repoPath.split(separator: "/").last ?? "")
456 renameConfirm = ""
451457 renaming = true
452458 } label: {
453459 Label("Rename repository", systemImage: "pencil")
gitbay/Views/Repos/RepoView.swift +1 −1
@@ -53,7 +53,7 @@ struct RepoView: View {
5353 Grid(horizontalSpacing: 0, verticalSpacing: 0) {
5454 GridRow {
5555 tile("Files", systemImage: "folder", id: "files", to: .files)
56 tile("Merge Requests", systemImage: "arrow.triangle.merge", id: "merge-requests",
56 tile("Merge requests", systemImage: "arrow.triangle.merge", id: "merge-requests",
5757 to: .mergeRequests, count: model.openMRs)
5858 }
5959 GridRow {
gitbay/Views/Shared/BodyReferences.swift added +96
@@ -0,0 +1,96 @@
1import Foundation
2import SwiftUI
3
4/// A `#N`, `!N`, `owner/name#N` or `owner/name!N` in a body, as the web's
5/// autolink package reads them: `#` is an issue, `!` a merge request.
6/// `range` counts characters.
7nonisolated struct BodyReference: Equatable, Sendable {
8 let range: Range<Int>
9 let destination: NotificationDestination
10}
11
12/// Every reference in `text`, bare ones resolved against `repo`. The
13/// patterns are the web's: a bare reference needs the start of the text,
14/// whitespace or an opening bracket before it, and a cross-repository one
15/// wins where the two overlap. Unlike the web, the target is not checked
16/// to exist.
17nonisolated func bodyReferences(in text: String, repo: String) -> [BodyReference] {
18 let cross = /([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)([#!])([0-9]+)/
19 let bare = /(^|[\s(\[{])([#!])([0-9]+)(?![0-9A-Za-z_])/
20
21 func offsets(_ range: Range<String.Index>) -> Range<Int> {
22 let lower = text.distance(from: text.startIndex, to: range.lowerBound)
23 return lower..<(lower + text.distance(from: range.lowerBound, to: range.upperBound))
24 }
25 func destination(_ repo: String, _ kind: Substring, _ number: Substring) -> NotificationDestination? {
26 guard let n = Int64(number) else { return nil }
27 return kind == "#" ? .issue(repo: repo, number: n) : .mr(repo: repo, number: n)
28 }
29
30 var found: [BodyReference] = []
31 for match in text.matches(of: cross) {
32 if let d = destination("\(match.1)/\(match.2)", match.3, match.4) {
33 found.append(BodyReference(range: offsets(match.range), destination: d))
34 }
35 }
36 for match in text.matches(of: bare) {
37 let range = offsets(match.2.startIndex..<match.3.endIndex)
38 guard !found.contains(where: { $0.range.overlaps(range) }),
39 let d = destination(repo, match.2, match.3) else { continue }
40 found.append(BodyReference(range: range, destination: d))
41 }
42 return found.sorted { $0.range.lowerBound < $1.range.lowerBound }
43}
44
45nonisolated private let referenceScheme = "gitbay-ref"
46
47/// The link a reference carries: the web path under an app-only scheme,
48/// so only `referenceNavigation()` acts on it.
49nonisolated func referenceURL(_ destination: NotificationDestination) -> URL? {
50 let path = switch destination {
51 case .issue(let repo, let number): "\(repo)/issues/\(number)"
52 case .mr(let repo, let number): "\(repo)/mrs/\(number)"
53 case .build(let repo, let number): "\(repo)/builds/\(number)"
54 }
55 return URL(string: "\(referenceScheme):\(path)")
56}
57
58nonisolated func referenceDestination(_ url: URL) -> NotificationDestination? {
59 guard url.scheme == referenceScheme else { return nil }
60 return notificationDestination(forPath: String(url.absoluteString.dropFirst(referenceScheme.count + 1)))
61}
62
63extension View {
64 /// Opens reference links from a body in-app. Attach outside the
65 /// `List`: a navigation destination inside a lazy container is ignored.
66 func referenceNavigation() -> some View {
67 modifier(ReferenceNavigation())
68 }
69}
70
71private struct ReferenceNavigation: ViewModifier {
72
73 @Environment(SessionStore.self) private var session
74 @State private var target: NotificationDestination?
75
76 func body(content: Content) -> some View {
77 content
78 .environment(\.openURL, OpenURLAction { url in
79 guard let destination = referenceDestination(url) else { return .systemAction }
80 target = destination
81 return .handled
82 })
83 .navigationDestination(item: $target) { destination in
84 if let client = session.client {
85 switch destination {
86 case .issue(let repo, let number):
87 IssueView(client: client, repo: repo, number: number)
88 case .mr(let repo, let number):
89 MRView(client: client, repo: repo, number: number)
90 case .build(let repo, let number):
91 BuildDetailView(client: client, repo: repo, number: number)
92 }
93 }
94 }
95 }
96}
gitbay/Views/Shared/SidebarRouting.swift added +40
@@ -0,0 +1,40 @@
1import SwiftUI
2
3extension EnvironmentValues {
4 /// Set on a list shown as a `NavigationSplitView` sidebar: what it
5 /// selects opens in the detail column's stack. Nil in a plain stack.
6 @Entry var sidebarSelection: Binding<AnyHashable?>?
7}
8
9/// A `List` whose rows select into `sidebarSelection` when there is one.
10/// A value-based link in a sidebar only reaches the detail column's stack
11/// through the list's selection.
12struct RoutingList<Content: View>: View {
13 @ViewBuilder let content: () -> Content
14 @Environment(\.sidebarSelection) private var selection
15
16 var body: some View {
17 if let selection {
18 List(selection: selection) { content() }
19 } else {
20 List { content() }
21 }
22 }
23}
24
25/// A `NavigationLink` in a stack; in a sidebar, a button that opens the
26/// route in the detail column. For links outside the list, such as
27/// toolbar items, which the list's selection does not cover.
28struct RouteLink<Value: Hashable, Label: View>: View {
29 let value: Value
30 @ViewBuilder let label: () -> Label
31 @Environment(\.sidebarSelection) private var selection
32
33 var body: some View {
34 if let selection {
35 Button { selection.wrappedValue = AnyHashable(value) } label: { label() }
36 } else {
37 NavigationLink(value: value) { label() }
38 }
39 }
40}
gitbayTests/AccountTests.swift +8
@@ -179,6 +179,14 @@ struct AccountViewModelTests {
179179 #expect(try #require(model.state.value).sshKeys.first?.label == "cmc@mac")
180180 }
181181
182 /// The web's typed confirmation for removing a key: the first eight
183 /// characters of the fingerprint after `SHA256:`.
184 @Test func sshKeyConfirmationIsTheFingerprintPrefix() async throws {
185 let (model, _) = try await loadedModel()
186 let key = try #require(model.state.value?.sshKeys.first)
187 #expect(key.confirmation == "15jrWGl3")
188 }
189
182190 @Test func sshKeyAddPassesALabelOnlyWhenGiven() async throws {
183191 let (model, stub) = try await loadedModel()
184192 for _ in 0..<2 {
gitbayTests/BodyReferenceTests.swift added +91
@@ -0,0 +1,91 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5/// `#N`, `!N`, `owner/name#N` and `owner/name!N`, found the way the web's
6/// autolink package finds them: `#` is an issue, `!` a merge request.
7struct BodyReferenceTests {
8
9 private func refs(_ text: String, repo: String = "krz/gitbay") -> [BodyReference] {
10 bodyReferences(in: text, repo: repo)
11 }
12
13 @Test func bareHashIsAnIssueInThisRepo() {
14 #expect(refs("see #12 please") == [
15 BodyReference(range: 4..<7, destination: .issue(repo: "krz/gitbay", number: 12)),
16 ])
17 }
18
19 @Test func bangIsAMergeRequest() {
20 #expect(refs("!3") == [
21 BodyReference(range: 0..<2, destination: .mr(repo: "krz/gitbay", number: 3)),
22 ])
23 }
24
25 @Test func crossRepoReferenceNamesItsRepo() {
26 #expect(refs("fixed in krz/solar#7 and krz/solar!8.") == [
27 BodyReference(range: 9..<20, destination: .issue(repo: "krz/solar", number: 7)),
28 BodyReference(range: 25..<36, destination: .mr(repo: "krz/solar", number: 8)),
29 ])
30 }
31
32 @Test func aBareReferenceNeedsABoundaryBefore() {
33 #expect(refs("a1b2#3").isEmpty)
34 #expect(refs("(#4)").map(\.range) == [1..<3])
35 #expect(refs("[#5]").map(\.range) == [1..<3])
36 #expect(refs("line\n#6").map(\.range) == [5..<7])
37 }
38
39 @Test func digitsMustEndAtAWordBoundary() {
40 #expect(refs("#12abc").isEmpty)
41 #expect(refs("#12, #13").count == 2)
42 }
43
44 @Test func aCrossReferenceIsNotAlsoReadAsABareOne() {
45 #expect(refs("krz/solar#7").count == 1)
46 }
47
48 @Test func offsetsCountCharactersNotBytes() {
49 #expect(refs("é #1").map(\.range) == [2..<4])
50 }
51
52 @Test func theURLRoundTrips() throws {
53 for destination in [NotificationDestination.issue(repo: "krz/gitbay", number: 12),
54 .mr(repo: "krz/solar", number: 3)] {
55 let url = try #require(referenceURL(destination))
56 #expect(referenceDestination(url) == destination)
57 }
58 #expect(referenceDestination(URL(string: "https://gitbay.org/krz/gitbay/issues/1")!) == nil)
59 }
60}
61
62@MainActor
63struct BodyReferenceMarkdownTests {
64
65 private func links(_ markdown: String) -> [String: URL] {
66 let attributed = MarkdownView.attributed(markdown, referenceRepo: "krz/gitbay")
67 var found: [String: URL] = [:]
68 for run in attributed.runs {
69 if let link = run.link {
70 found[String(attributed[run.range].characters)] = link
71 }
72 }
73 return found
74 }
75
76 @Test func referencesBecomeLinks() {
77 let found = links("closes #12, see krz/solar!3")
78 #expect(found["#12"] == referenceURL(.issue(repo: "krz/gitbay", number: 12)))
79 #expect(found["krz/solar!3"] == referenceURL(.mr(repo: "krz/solar", number: 3)))
80 }
81
82 @Test func codeSpansAndExistingLinksAreLeftAlone() {
83 let found = links("`#12` and [#13](https://example.com)")
84 #expect(found.count == 1)
85 #expect(found["#13"] == URL(string: "https://example.com"))
86 }
87
88 @Test func noRepoMeansNoReferenceLinks() {
89 #expect(MarkdownView.attributed("#12").runs.allSatisfy { $0.link == nil })
90 }
91}
gitbayTests/ListFilterTests.swift +11 −1
@@ -107,7 +107,17 @@ struct MRFilterTests {
107107 var f = MRFilter()
108108 f.state = .sourceGone
109109 #expect(f.flags() == ["--state", "source_gone"])
110 #expect(MRFilter.State.sourceGone.label == "Gone")
110 #expect(MRFilter.State.sourceGone.label == "Source gone")
111 }
112
113 /// Filters, chips and headers all say a state the same way.
114 @Test func statesReadInSentenceCase() {
115 #expect(stateLabel("open") == "Open")
116 #expect(stateLabel("merged") == "Merged")
117 #expect(stateLabel("source_gone") == "Source gone")
118 #expect(MRFilter.State.allCases.map(\.label)
119 == ["Open", "Merged", "Closed", "Source gone", "All"])
120 #expect(IssueFilter.State.allCases.map(\.label) == ["Open", "Closed", "All"])
111121 }
112122
113123 @Test func everyStateTheCommandAcceptsIsOffered() {
gitbayUITests/LiveSmokeUITests.swift +27 −2
@@ -412,7 +412,7 @@ final class LiveSmokeUITests: XCTestCase {
412412 XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing")
413413 confirm.tap()
414414
415 XCTAssertTrue(app.staticTexts["closed"].firstMatch
415 XCTAssertTrue(app.staticTexts["Closed"].firstMatch
416416 .waitForExistence(timeout: 15), "MR did not show closed after close")
417417 }
418418
@@ -663,6 +663,7 @@ extension LiveSmokeUITests {
663663 // Each rename pushes the renamed repository; the round-trip ends
664664 // on the scratch repo again, several screens deep.
665665 var firstRename = true
666 var currentPath = Self.scratchRepo
666667 let rename: (String, String) -> Void = { newName, expectedPath in
667668 if firstRename {
668669 firstRename = false // already on the settings screen
@@ -678,7 +679,14 @@ extension LiveSmokeUITests {
678679 field.tap()
679680 // Prefilled with the current name; clear it before typing.
680681 field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12) + newName)
681 self.app.buttons["Rename"].firstMatch.tap()
682 // Rename stays disabled until the current path is typed.
683 let renameAction = self.app.alerts.buttons["Rename"].firstMatch
684 XCTAssertFalse(renameAction.isEnabled, "rename enabled before the path was typed")
685 let confirm = self.app.textFields["type \(currentPath) to confirm"].firstMatch
686 XCTAssertTrue(confirm.exists, "rename confirmation field missing")
687 confirm.tap()
688 confirm.typeText(currentPath)
689 renameAction.tap()
682690 // The settings screen offers the renamed repository at the
683691 // top; the list is scrolled to its end, so go back up first.
684692 let renamedLink = self.app.descendants(matching: .any)
@@ -691,6 +699,7 @@ extension LiveSmokeUITests {
691699 // splits the path into the owner link and the rest.
692700 XCTAssertTrue(self.app.navigationBars.staticTexts[newName].firstMatch
693701 .waitForExistence(timeout: 15), "renamed repo did not open as \(expectedPath)")
702 currentPath = expectedPath
694703 }
695704 rename("ui-smoke-2", Self.scratchRepo + "-2")
696705 rename("ui-smoke", Self.scratchRepo)
@@ -1715,6 +1724,22 @@ extension LiveSmokeUITests {
17151724 XCTAssertTrue(app.staticTexts[original].firstMatch.waitForExistence(timeout: 10),
17161725 "original label not back")
17171726 }
1727
1728 // Removal asks for the fingerprint's first eight characters and
1729 // stays disabled until they are typed. Cancelled: the key stays.
1730 fingerprint.swipeLeft()
1731 let remove = app.buttons["Remove"].firstMatch
1732 XCTAssertTrue(remove.waitForExistence(timeout: 5), "Remove swipe action missing")
1733 remove.tap()
1734 let confirm = app.alerts.textFields.firstMatch
1735 XCTAssertTrue(confirm.waitForExistence(timeout: 5), "remove alert missing")
1736 let removeAction = app.alerts.buttons["Remove"].firstMatch
1737 XCTAssertFalse(removeAction.isEnabled, "remove enabled before confirmation")
1738 confirm.tap()
1739 confirm.typeText(String(fp.dropFirst("SHA256:".count).prefix(8)))
1740 XCTAssertTrue(removeAction.isEnabled, "remove not enabled by the typed confirmation")
1741 app.alerts.buttons["Cancel"].firstMatch.tap()
1742 XCTAssertTrue(fingerprint.waitForExistence(timeout: 5), "key gone after cancel")
17181743 }
17191744
17201745 /// Org labels and milestones from the org screen: a label is created