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 {
14 /// A name for the key (v1.21.0); omitted when none is set. 14 /// A name for the key (v1.21.0); omitted when none is set.
15 let label: String? 15 let label: String?
16 var id: String { fingerprint } 16 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 }
17 } 22 }
18 23
19 nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable { 24 nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable {
gitbay/ContentView.swift +78 −6
@@ -5,11 +5,14 @@ struct ContentView: View {
5 @Environment(SessionStore.self) private var session 5 @Environment(SessionStore.self) private var session
6 @Environment(PushRouter.self) private var router 6 @Environment(PushRouter.self) private var router
7 @Environment(\.openURL) private var openURL 7 @Environment(\.openURL) private var openURL
8 @Environment(\.horizontalSizeClass) private var sizeClass
8 9
9 enum Tabs { case dashboard, feed, repositories, explore, profile } 10 enum Tabs { case dashboard, feed, repositories, explore, profile }
10 11
11 @State private var selectedTab = Tabs.dashboard 12 @State private var selectedTab = Tabs.dashboard
12 @State private var dashboardPath = NavigationPath() 13 @State private var dashboardPath = NavigationPath()
14 @State private var repoPath = NavigationPath()
15 @State private var unread = 0
13 16
14 /// whoami's admin flag, read on the My Profile tab, decides whether 17 /// whoami's admin flag, read on the My Profile tab, decides whether
15 /// the account menu offers Admin. 18 /// the account menu offers Admin.
@@ -28,11 +31,18 @@ struct ContentView: View {
28 if let client = session.client, let account = session.current { 31 if let client = session.client, let account = session.current {
29 TabView(selection: $selectedTab) { 32 TabView(selection: $selectedTab) {
30 Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) { 33 Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) {
31 NavigationStack(path: $dashboardPath) { 34 if sizeClass == .regular {
32 DashboardView(client: client) 35 SplitStack(path: $dashboardPath, client: client, placeholder: "Nothing selected") {
33 .navigationDestinations(client: client) 36 DashboardView(client: client, unread: $unread)
37 }
38 } else {
39 NavigationStack(path: $dashboardPath) {
40 DashboardView(client: client, unread: $unread)
41 .navigationDestinations(client: client)
42 }
34 } 43 }
35 } 44 }
45 .badge(unread)
36 Tab("Feed", systemImage: "bolt", value: Tabs.feed) { 46 Tab("Feed", systemImage: "bolt", value: Tabs.feed) {
37 NavigationStack { 47 NavigationStack {
38 FeedView(client: client) 48 FeedView(client: client)
@@ -40,9 +50,15 @@ struct ContentView: View {
40 } 50 }
41 } 51 }
42 Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) { 52 Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) {
43 NavigationStack { 53 if sizeClass == .regular {
44 RepoListView(client: client) 54 SplitStack(path: $repoPath, client: client, placeholder: "No repository selected") {
45 .navigationDestinations(client: client) 55 RepoListView(client: client)
56 }
57 } else {
58 NavigationStack(path: $repoPath) {
59 RepoListView(client: client)
60 .navigationDestinations(client: client)
61 }
46 } 62 }
47 } 63 }
48 Tab("Explore", systemImage: "safari", value: Tabs.explore) { 64 Tab("Explore", systemImage: "safari", value: Tabs.explore) {
@@ -106,6 +122,7 @@ struct ContentView: View {
106 private func routePendingNotification(_ account: Account) async { 122 private func routePendingNotification(_ account: Account) async {
107 if pathAccount != account.id { 123 if pathAccount != account.id {
108 dashboardPath = NavigationPath() 124 dashboardPath = NavigationPath()
125 repoPath = NavigationPath()
109 selectedTab = .dashboard 126 selectedTab = .dashboard
110 pathAccount = account.id 127 pathAccount = account.id
111 } 128 }
@@ -176,6 +193,46 @@ struct ContentView: View {
176 193
177} 194}
178 195
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
179/// Every value-routed screen, attachable to any stack root. 236/// Every value-routed screen, attachable to any stack root.
180private struct RouteDestinations: ViewModifier { 237private struct RouteDestinations: ViewModifier {
181 238
@@ -206,6 +263,21 @@ private struct RouteDestinations: ViewModifier {
206 } 263 }
207 } 264 }
208 265
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
209 @ViewBuilder 281 @ViewBuilder
210 private func destination(_ route: RepoRoute, client: GitbayClient) -> some View { 282 private func destination(_ route: RepoRoute, client: GitbayClient) -> some View {
211 switch route { 283 switch route {
gitbay/Issues/IssueFilter.swift +1 −1
@@ -8,7 +8,7 @@ nonisolated struct IssueFilter: Equatable, Sendable {
8 enum State: String, CaseIterable, Identifiable, Sendable { 8 enum State: String, CaseIterable, Identifiable, Sendable {
9 case open, closed, all 9 case open, closed, all
10 var id: String { rawValue } 10 var id: String { rawValue }
11 var label: String { rawValue.capitalized } 11 var label: String { stateLabel(rawValue) }
12 } 12 }
13 13
14 var state: State = .open 14 var state: State = .open
gitbay/MRs/MRFilter.swift +8 −6
@@ -10,12 +10,7 @@ nonisolated struct MRFilter: Equatable, Sendable {
10 case all 10 case all
11 11
12 var id: String { rawValue } 12 var id: String { rawValue }
13 /// Segment text. Five segments share a phone's width, so the 13 var label: String { stateLabel(rawValue) }
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 }
19 } 14 }
20 15
21 var state: State = .open 16 var state: State = .open
@@ -45,3 +40,10 @@ nonisolated struct MRFilter: Equatable, Sendable {
45 return state == .all ? "No \(noun)." : "No \(state.label.lowercased()) \(noun)." 40 return state == .all ? "No \(noun)." : "No \(state.label.lowercased()) \(noun)."
46 } 41 }
47} 42}
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 {
11 @State private var verifyCode = "" 11 @State private var verifyCode = ""
12 @State private var removingEmail: AccountViewModel.EmailAddress? 12 @State private var removingEmail: AccountViewModel.EmailAddress?
13 @State private var removingSSH: AccountViewModel.SSHKey? 13 @State private var removingSSH: AccountViewModel.SSHKey?
14 @State private var sshConfirmText = ""
14 @State private var labellingSSH: AccountViewModel.SSHKey? 15 @State private var labellingSSH: AccountViewModel.SSHKey?
15 @State private var sshLabelText = "" 16 @State private var sshLabelText = ""
16 @State private var removingPGP: AccountViewModel.PGPKey? 17 @State private var removingPGP: AccountViewModel.PGPKey?
@@ -129,20 +130,25 @@ struct AccountView: View {
129 } message: { _ in 130 } message: { _ in
130 Text("An empty label clears it.") 131 Text("An empty label clears it.")
131 } 132 }
132 .confirmationDialog( 133 .alert(
133 "Remove this SSH key? Anything authenticating with it loses access.", 134 "Remove this SSH key?",
134 isPresented: Binding( 135 isPresented: Binding(
135 get: { removingSSH != nil }, 136 get: { removingSSH != nil },
136 set: { if !$0 { removingSSH = nil } } 137 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)
139 Button("Remove", role: .destructive) { 144 Button("Remove", role: .destructive) {
140 if let key = removingSSH { 145 Task { await model.removeSSHKey(key) }
141 Task { await model.removeSSHKey(key) }
142 }
143 removingSSH = nil 146 removingSSH = nil
144 } 147 }
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.")
146 } 152 }
147 .confirmationDialog( 153 .confirmationDialog(
148 "Remove this PGP key? Commits it signed become unverifiable.", 154 "Remove this PGP key? Commits it signed become unverifiable.",
@@ -209,6 +215,7 @@ struct AccountView: View {
209 } 215 }
210 .swipeActions { 216 .swipeActions {
211 Button("Remove", role: .destructive) { 217 Button("Remove", role: .destructive) {
218 sshConfirmText = ""
212 removingSSH = key 219 removingSSH = key
213 } 220 }
214 Button("Label") { 221 Button("Label") {
gitbay/Views/Account/NotificationsView.swift +2 −2
@@ -17,8 +17,6 @@ struct NotificationsView: View {
17 17
18 var body: some View { 18 var body: some View {
19 List { 19 List {
20 pushSection
21
22 Picker("Filter", selection: Bindable(model).showAll) { 20 Picker("Filter", selection: Bindable(model).showAll) {
23 Text("Unread").tag(false) 21 Text("Unread").tag(false)
24 Text("All").tag(true) 22 Text("All").tag(true)
@@ -43,6 +41,8 @@ struct NotificationsView: View {
43 } 41 }
44 } 42 }
45 PageFooter(list: model.list) 43 PageFooter(list: model.list)
44
45 pushSection
46 } 46 }
47 .overlay { LoadStateOverlay(state: model.state) } 47 .overlay { LoadStateOverlay(state: model.state) }
48 .navigationTitle("Notifications") 48 .navigationTitle("Notifications")
gitbay/Views/Builds/BuildListView.swift +1
@@ -57,6 +57,7 @@ struct BuildListView: View {
57 } 57 }
58 .sheet(isPresented: $filtering) { 58 .sheet(isPresented: $filtering) {
59 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 } 59 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 }
60 .presentationDetents([.large])
60 } 61 }
61 .task { 62 .task {
62 await model.loadIfNeeded() 63 await model.loadIfNeeded()
gitbay/Views/Dashboard/DashboardView.swift +10 −4
@@ -8,14 +8,17 @@ struct DashboardView: View {
8 @State private var model: DashboardViewModel 8 @State private var model: DashboardViewModel
9 @State private var createModel: RepoCreateViewModel 9 @State private var createModel: RepoCreateViewModel
10 @State private var composing = false 10 @State private var composing = false
11 /// The unread count, for the Dashboard tab's badge.
12 @Binding var unread: Int
11 13
12 init(client: GitbayClient) { 14 init(client: GitbayClient, unread: Binding<Int>) {
15 _unread = unread
13 _model = State(initialValue: DashboardViewModel(client: client)) 16 _model = State(initialValue: DashboardViewModel(client: client))
14 _createModel = State(initialValue: RepoCreateViewModel(client: client)) 17 _createModel = State(initialValue: RepoCreateViewModel(client: client))
15 } 18 }
16 19
17 var body: some View { 20 var body: some View {
18 List { 21 RoutingList {
19 if let data = model.state.value { 22 if let data = model.state.value {
20 itemSection( 23 itemSection(
21 "Waiting on your review", items: data.reviewQueue, 24 "Waiting on your review", items: data.reviewQueue,
@@ -45,7 +48,7 @@ struct DashboardView: View {
45 .navigationTitle("Dashboard") 48 .navigationTitle("Dashboard")
46 .toolbar { 49 .toolbar {
47 ToolbarItem(placement: .topBarTrailing) { 50 ToolbarItem(placement: .topBarTrailing) {
48 NavigationLink(value: RepoRoute.notifications) { 51 RouteLink(value: RepoRoute.notifications) {
49 HStack(spacing: 2) { 52 HStack(spacing: 2) {
50 Image(systemName: "bell") 53 Image(systemName: "bell")
51 if let unread = model.state.value?.unread, unread > 0 { 54 if let unread = model.state.value?.unread, unread > 0 {
@@ -78,6 +81,9 @@ struct DashboardView: View {
78 } 81 }
79 .task { await model.load() } 82 .task { await model.load() }
80 .refreshable { await model.load() } 83 .refreshable { await model.load() }
84 .onChange(of: model.state.value?.unread) { _, count in
85 if let count { unread = count }
86 }
81 } 87 }
82 88
83 private var bellLabel: String { 89 private var bellLabel: String {
@@ -124,7 +130,7 @@ struct DashboardView: View {
124 Text(item.updatedAt, format: .relative(presentation: .named)) 130 Text(item.updatedAt, format: .relative(presentation: .named))
125 if item.state == "source_gone" { 131 if item.state == "source_gone" {
126 Text("·") 132 Text("·")
127 GBChip("source gone", .secondary) 133 GBChip(stateLabel(item.state), .secondary)
128 } 134 }
129 } 135 }
130 .font(.gbSans(.caption)) 136 .font(.gbSans(.caption))
gitbay/Views/Discovery/ExploreView.swift +1 −1
@@ -159,7 +159,7 @@ private struct SearchEntryRow: View {
159 } 159 }
160 HStack(spacing: 6) { 160 HStack(spacing: 6) {
161 if let state = result.state { 161 if let state = result.state {
162 GBChip(state, color(for: state)) 162 GBChip(stateLabel(state), color(for: state))
163 } 163 }
164 Spacer() 164 Spacer()
165 if let updatedAt = result.updatedAt { 165 if let updatedAt = result.updatedAt {
gitbay/Views/Issues/IssueListView.swift +10 −2
@@ -9,8 +9,12 @@ struct IssueListView: View {
9 @State private var draftTitle = "" 9 @State private var draftTitle = ""
10 @State private var draftBody = "" 10 @State private var draftBody = ""
11 @State private var draftFormat = "md" 11 @State private var draftFormat = "md"
12 @State private var createdIssue: Int64?
13
14 private let client: GitbayClient
12 15
13 init(client: GitbayClient, repo: String) { 16 init(client: GitbayClient, repo: String) {
17 self.client = client
14 _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo)) 18 _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo))
15 _createModel = State(initialValue: IssueCreateViewModel(client: client, repoPath: repo)) 19 _createModel = State(initialValue: IssueCreateViewModel(client: client, repoPath: repo))
16 } 20 }
@@ -61,7 +65,7 @@ struct IssueListView: View {
61 } 65 }
62 .sheet(isPresented: $filtering) { 66 .sheet(isPresented: $filtering) {
63 FilterSheet( 67 FilterSheet(
64 title: "Filter Issues", 68 title: "Filter issues",
65 author: Bindable(model).filter.author, 69 author: Bindable(model).filter.author,
66 milestone: Bindable(model).filter.milestone, 70 milestone: Bindable(model).filter.milestone,
67 milestones: model.milestones, 71 milestones: model.milestones,
@@ -92,16 +96,20 @@ struct IssueListView: View {
92 ) { 96 ) {
93 createModel.format = draftFormat 97 createModel.format = draftFormat
94 Task { 98 Task {
95 if await createModel.create(title: draftTitle, body: draftBody) != nil { 99 if let number = await createModel.create(title: draftTitle, body: draftBody) {
96 draftTitle = "" 100 draftTitle = ""
97 draftBody = "" 101 draftBody = ""
98 draftFormat = "md" 102 draftFormat = "md"
99 composing = false 103 composing = false
104 createdIssue = number
100 await model.load() 105 await model.load()
101 } 106 }
102 } 107 }
103 } 108 }
104 } 109 }
110 .navigationDestination(item: $createdIssue) { number in
111 IssueView(client: client, repo: model.repoPath, number: number)
112 }
105 .task { await model.loadIfNeeded() } 113 .task { await model.loadIfNeeded() }
106 .task { await createModel.loadTemplates() } 114 .task { await createModel.loadTemplates() }
107 .refreshable { await model.load() } 115 .refreshable { await model.load() }
gitbay/Views/Issues/IssueView.swift +4 −3
@@ -31,7 +31,7 @@ struct IssueView: View {
31 31
32 if let body = issue.body, !body.isEmpty { 32 if let body = issue.body, !body.isEmpty {
33 Section { 33 Section {
34 FormattedBodyView(text: body, format: issue.bodyFormat) 34 FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath)
35 .padding(.vertical, 4) 35 .padding(.vertical, 4)
36 } 36 }
37 } 37 }
@@ -41,6 +41,7 @@ struct IssueView: View {
41 } 41 }
42 } 42 }
43 .overlay { LoadStateOverlay(state: model.state) } 43 .overlay { LoadStateOverlay(state: model.state) }
44 .referenceNavigation()
44 .scrollDismissesKeyboard(.interactively) 45 .scrollDismissesKeyboard(.interactively)
45 .navigationTitle("#\(model.number)") 46 .navigationTitle("#\(model.number)")
46 .navigationBarTitleDisplayMode(.inline) 47 .navigationBarTitleDisplayMode(.inline)
@@ -72,7 +73,7 @@ struct IssueView: View {
72 Text(issue.title) 73 Text(issue.title)
73 .font(.gbSans(.headline)) 74 .font(.gbSans(.headline))
74 HStack(spacing: 6) { 75 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")
76 .font(.gbSans(.caption).weight(.medium)) 77 .font(.gbSans(.caption).weight(.medium))
77 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad) 78 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
78 Text("by \(issue.author)") 79 Text("by \(issue.author)")
@@ -166,7 +167,7 @@ struct IssueView: View {
166 .font(.gbSans(.caption)) 167 .font(.gbSans(.caption))
167 .foregroundStyle(.tertiary) 168 .foregroundStyle(.tertiary)
168 } 169 }
169 FormattedBodyView(text: comment.body, format: comment.bodyFormat) 170 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
170 .font(.gbSans(.subheadline)) 171 .font(.gbSans(.subheadline))
171 } 172 }
172 .padding(.vertical, 2) 173 .padding(.vertical, 2)
gitbay/Views/MRs/MRListView.swift +3 −3
@@ -32,7 +32,7 @@ struct MRListView: View {
32 } 32 }
33 .overlay { LoadStateOverlay(state: model.state) } 33 .overlay { LoadStateOverlay(state: model.state) }
34 .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies") 34 .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies")
35 .navigationTitle("Merge Requests") 35 .navigationTitle("Merge requests")
36 .navigationBarTitleDisplayMode(.inline) 36 .navigationBarTitleDisplayMode(.inline)
37 .toolbar { 37 .toolbar {
38 ToolbarItem(placement: .topBarTrailing) { 38 ToolbarItem(placement: .topBarTrailing) {
@@ -58,7 +58,7 @@ struct MRListView: View {
58 } 58 }
59 .sheet(isPresented: $filtering) { 59 .sheet(isPresented: $filtering) {
60 FilterSheet( 60 FilterSheet(
61 title: "Filter Merge Requests", 61 title: "Filter merge requests",
62 author: Bindable(model).filter.author, 62 author: Bindable(model).filter.author,
63 milestone: Bindable(model).filter.milestone, 63 milestone: Bindable(model).filter.milestone,
64 milestones: model.milestones, 64 milestones: model.milestones,
@@ -243,7 +243,7 @@ struct MRStateBadge: View {
243 let state: String 243 let state: String
244 244
245 var body: some View { 245 var body: some View {
246 GBChip(state.replacingOccurrences(of: "_", with: " "), color) 246 GBChip(stateLabel(state), color)
247 } 247 }
248 248
249 /// The web's chip mapping: open=ok, merged=done, closed=bad, 249 /// 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 {
37 37
38 if let body = mr.body, !body.isEmpty { 38 if let body = mr.body, !body.isEmpty {
39 Section { 39 Section {
40 FormattedBodyView(text: body, format: mr.bodyFormat) 40 FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath)
41 .padding(.vertical, 4) 41 .padding(.vertical, 4)
42 } 42 }
43 } 43 }
44 44
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
45 milestoneSection(mr) 52 milestoneSection(mr)
46 labelsSection(mr) 53 labelsSection(mr)
47 stackSection(mr) 54 stackSection(mr)
@@ -55,12 +62,6 @@ struct MRView: View {
55 if let commits = mr.commits, !commits.isEmpty { 62 if let commits = mr.commits, !commits.isEmpty {
56 commitsSection(commits) 63 commitsSection(commits)
57 } 64 }
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 }
64 if let reviews = mr.reviews, !reviews.isEmpty { 65 if let reviews = mr.reviews, !reviews.isEmpty {
65 reviewsSection(reviews) 66 reviewsSection(reviews)
66 } 67 }
@@ -71,6 +72,7 @@ struct MRView: View {
71 } 72 }
72 } 73 }
73 .overlay { LoadStateOverlay(state: model.state) } 74 .overlay { LoadStateOverlay(state: model.state) }
75 .referenceNavigation()
74 .scrollDismissesKeyboard(.interactively) 76 .scrollDismissesKeyboard(.interactively)
75 .navigationTitle("!\(model.number)") 77 .navigationTitle("!\(model.number)")
76 .navigationBarTitleDisplayMode(.inline) 78 .navigationBarTitleDisplayMode(.inline)
@@ -531,7 +533,7 @@ struct MRView: View {
531 .font(.gbSans(.caption)) 533 .font(.gbSans(.caption))
532 .foregroundStyle(.tertiary) 534 .foregroundStyle(.tertiary)
533 } 535 }
534 FormattedBodyView(text: comment.body, format: comment.bodyFormat) 536 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
535 .font(.gbSans(.subheadline)) 537 .font(.gbSans(.subheadline))
536 } 538 }
537 .padding(.vertical, 2) 539 .padding(.vertical, 2)
gitbay/Views/Repos/FormattedBodyView.swift +4 −1
@@ -8,6 +8,9 @@ import SwiftUI
8struct FormattedBodyView: View { 8struct FormattedBodyView: View {
9 let text: String 9 let text: String
10 var format: String? 10 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?
11 14
12 @Environment(\.colorScheme) private var colorScheme 15 @Environment(\.colorScheme) private var colorScheme
13 16
@@ -22,7 +25,7 @@ struct FormattedBodyView: View {
22 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone) 25 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)
23 ) 26 )
24 } else { 27 } else {
25 MarkdownView(markdown: text) 28 MarkdownView(markdown: text, referenceRepo: repo)
26 } 29 }
27 } 30 }
28} 31}
gitbay/Views/Repos/MarkdownView.swift +36 −10
@@ -10,6 +10,8 @@ struct MarkdownView: View {
10 /// Repository context for resolving relative link targets. Nil leaves them as authored, 10 /// Repository context for resolving relative link targets. Nil leaves them as authored,
11 /// which is right for a body with no file to be relative to. 11 /// which is right for a body with no file to be relative to.
12 var linkOptions: OrgRenderOptions? 12 var linkOptions: OrgRenderOptions?
13 /// The repository `#N` and `!N` refer to. Nil links no references.
14 var referenceRepo: String?
13 15
14 var body: some View { 16 var body: some View {
15 VStack(alignment: .leading, spacing: 12) { 17 VStack(alignment: .leading, spacing: 12) {
@@ -223,7 +225,8 @@ struct MarkdownView: View {
223 private func blockView(_ block: Block) -> some View { 225 private func blockView(_ block: Block) -> some View {
224 switch block { 226 switch block {
225 case .heading(let level, let text): 227 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)
227 .font(headingFont(level)) 230 .font(headingFont(level))
228 .padding(.top, level <= 2 ? 8 : 4) 231 .padding(.top, level <= 2 ? 8 : 4)
229 case .code(let code): 232 case .code(let code):
@@ -238,7 +241,7 @@ struct MarkdownView: View {
238 RoundedRectangle(cornerRadius: 2) 241 RoundedRectangle(cornerRadius: 2)
239 .fill(.tertiary) 242 .fill(.tertiary)
240 .frame(width: 3) 243 .frame(width: 3)
241 Self.inline(text, linkOptions: linkOptions).foregroundStyle(.secondary) 244 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo).foregroundStyle(.secondary)
242 } 245 }
243 .fixedSize(horizontal: false, vertical: true) 246 .fixedSize(horizontal: false, vertical: true)
244 case .bullet(let items): 247 case .bullet(let items):
@@ -246,7 +249,7 @@ struct MarkdownView: View {
246 ForEach(Array(items.enumerated()), id: \.offset) { _, item in 249 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
247 HStack(alignment: .firstTextBaseline, spacing: 8) { 250 HStack(alignment: .firstTextBaseline, spacing: 8) {
248 Text("•") 251 Text("•")
249 Self.inline(item, linkOptions: linkOptions) 252 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
250 .fixedSize(horizontal: false, vertical: true) 253 .fixedSize(horizontal: false, vertical: true)
251 } 254 }
252 } 255 }
@@ -256,17 +259,17 @@ struct MarkdownView: View {
256 ForEach(Array(items.enumerated()), id: \.offset) { index, item in 259 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
257 HStack(alignment: .firstTextBaseline, spacing: 8) { 260 HStack(alignment: .firstTextBaseline, spacing: 8) {
258 Text("\(index + 1).").monospacedDigit() 261 Text("\(index + 1).").monospacedDigit()
259 Self.inline(item, linkOptions: linkOptions) 262 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
260 .fixedSize(horizontal: false, vertical: true) 263 .fixedSize(horizontal: false, vertical: true)
261 } 264 }
262 } 265 }
263 } 266 }
264 case .table(let table): 267 case .table(let table):
265 MarkdownTableView(table: table, linkOptions: linkOptions) 268 MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo)
266 case .rule: 269 case .rule:
267 Divider() 270 Divider()
268 case .paragraph(let text): 271 case .paragraph(let text):
269 Self.inline(text, linkOptions: linkOptions) 272 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
270 } 273 }
271 } 274 }
272 275
@@ -277,15 +280,18 @@ struct MarkdownView: View {
277 static func inline( 280 static func inline(
278 _ text: String, 281 _ text: String,
279 codeStyle: Font.TextStyle = .subheadline, 282 codeStyle: Font.TextStyle = .subheadline,
280 linkOptions: OrgRenderOptions? = nil 283 linkOptions: OrgRenderOptions? = nil,
284 referenceRepo: String? = nil
281 ) -> Text { 285 ) -> Text {
282 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions)) 286 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions,
287 referenceRepo: referenceRepo))
283 } 288 }
284 289
285 static func attributed( 290 static func attributed(
286 _ text: String, 291 _ text: String,
287 codeStyle: Font.TextStyle = .subheadline, 292 codeStyle: Font.TextStyle = .subheadline,
288 linkOptions: OrgRenderOptions? = nil 293 linkOptions: OrgRenderOptions? = nil,
294 referenceRepo: String? = nil
289 ) -> AttributedString { 295 ) -> AttributedString {
290 guard var attributed = try? AttributedString( 296 guard var attributed = try? AttributedString(
291 markdown: text, 297 markdown: text,
@@ -306,9 +312,28 @@ struct MarkdownView: View {
306 attributed[range].link = url 312 attributed[range].link = url
307 } 313 }
308 } 314 }
315 if let referenceRepo {
316 linkReferences(in: &attributed, repo: referenceRepo)
317 }
309 return attributed 318 return attributed
310 } 319 }
311 320
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
312 private func headingFont(_ level: Int) -> Font { 337 private func headingFont(_ level: Int) -> Font {
313 switch level { 338 switch level {
314 case 1: .title2.bold() 339 case 1: .title2.bold()
@@ -338,6 +363,7 @@ struct MarkdownView: View {
338private struct MarkdownTableView: View { 363private struct MarkdownTableView: View {
339 let table: MarkdownView.Table 364 let table: MarkdownView.Table
340 var linkOptions: OrgRenderOptions? 365 var linkOptions: OrgRenderOptions?
366 var referenceRepo: String?
341 367
342 private static let spacing: CGFloat = 14 368 private static let spacing: CGFloat = 14
343 369
@@ -372,7 +398,7 @@ private struct MarkdownTableView: View {
372 398
373 private func cell(_ text: String, _ column: Int) -> some View { 399 private func cell(_ text: String, _ column: Int) -> some View {
374 let alignment = table.alignments[column] 400 let alignment = table.alignments[column]
375 return MarkdownView.inline(text, linkOptions: linkOptions) 401 return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
376 .multilineTextAlignment(Self.textAlignment(alignment)) 402 .multilineTextAlignment(Self.textAlignment(alignment))
377 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment)) 403 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
378 .fixedSize(horizontal: cellWidth == nil, vertical: true) 404 .fixedSize(horizontal: cellWidth == nil, vertical: true)
gitbay/Views/Repos/RepoListView.swift +2 −2
@@ -14,7 +14,7 @@ struct RepoListView: View {
14 } 14 }
15 15
16 var body: some View { 16 var body: some View {
17 List { 17 RoutingList {
18 ForEach(model.visibleRepos) { repo in 18 ForEach(model.visibleRepos) { repo in
19 NavigationLink(value: RepoRoute.repo(repo.path)) { 19 NavigationLink(value: RepoRoute.repo(repo.path)) {
20 RepoRow(repo: repo) 20 RepoRow(repo: repo)
@@ -29,7 +29,7 @@ struct RepoListView: View {
29 .navigationTitle("Repositories") 29 .navigationTitle("Repositories")
30 .toolbar { 30 .toolbar {
31 ToolbarItem(placement: .topBarTrailing) { 31 ToolbarItem(placement: .topBarTrailing) {
32 NavigationLink(value: RepoRoute.bookmarks) { 32 RouteLink(value: RepoRoute.bookmarks) {
33 Image(systemName: "bookmark") 33 Image(systemName: "bookmark")
34 } 34 }
35 .accessibilityLabel("Bookmarks") 35 .accessibilityLabel("Bookmarks")
gitbay/Views/Repos/RepoSettingsView.swift +9 −3
@@ -12,6 +12,7 @@ struct RepoSettingsView: View {
12 @State private var newTagGlob = "" 12 @State private var newTagGlob = ""
13 @State private var renaming = false 13 @State private var renaming = false
14 @State private var newName = "" 14 @State private var newName = ""
15 @State private var renameConfirm = ""
15 /// Set once a rename succeeds: this screen's repository is gone and 16 /// Set once a rename succeeds: this screen's repository is gone and
16 /// the renamed one is a value push away. 17 /// the renamed one is a value push away.
17 @State private var renamedTo: String? 18 @State private var renamedTo: String?
@@ -99,6 +100,9 @@ struct RepoSettingsView: View {
99 TextField("New name", text: $newName) 100 TextField("New name", text: $newName)
100 .autocorrectionDisabled() 101 .autocorrectionDisabled()
101 .textInputAutocapitalization(.never) 102 .textInputAutocapitalization(.never)
103 TextField("type \(model.repoPath) to confirm", text: $renameConfirm)
104 .autocorrectionDisabled()
105 .textInputAutocapitalization(.never)
102 Button("Rename") { 106 Button("Rename") {
103 Task { 107 Task {
104 if let path = await model.rename(to: newName) { 108 if let path = await model.rename(to: newName) {
@@ -106,10 +110,11 @@ struct RepoSettingsView: View {
106 } 110 }
107 } 111 }
108 } 112 }
109 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty) 113 .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty
114 || renameConfirm != model.repoPath)
110 Button("Cancel", role: .cancel) {} 115 Button("Cancel", role: .cancel) {}
111 } message: { 116 } message: {
112 Text("Clone URLs change.") 117 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
113 } 118 }
114 .confirmationDialog( 119 .confirmationDialog(
115 "Remove topic \(removingTopic ?? "")?", 120 "Remove topic \(removingTopic ?? "")?",
@@ -258,7 +263,7 @@ struct RepoSettingsView: View {
258 } header: { 263 } header: {
259 Text("Protected branches") 264 Text("Protected branches")
260 } footer: { 265 } 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.")
262 } 267 }
263 } 268 }
264 269
@@ -448,6 +453,7 @@ struct RepoSettingsView: View {
448 Section { 453 Section {
449 Button { 454 Button {
450 newName = String(model.repoPath.split(separator: "/").last ?? "") 455 newName = String(model.repoPath.split(separator: "/").last ?? "")
456 renameConfirm = ""
451 renaming = true 457 renaming = true
452 } label: { 458 } label: {
453 Label("Rename repository", systemImage: "pencil") 459 Label("Rename repository", systemImage: "pencil")
gitbay/Views/Repos/RepoView.swift +1 −1
@@ -53,7 +53,7 @@ struct RepoView: View {
53 Grid(horizontalSpacing: 0, verticalSpacing: 0) { 53 Grid(horizontalSpacing: 0, verticalSpacing: 0) {
54 GridRow { 54 GridRow {
55 tile("Files", systemImage: "folder", id: "files", to: .files) 55 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",
57 to: .mergeRequests, count: model.openMRs) 57 to: .mergeRequests, count: model.openMRs)
58 } 58 }
59 GridRow { 59 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 {
179 #expect(try #require(model.state.value).sshKeys.first?.label == "cmc@mac") 179 #expect(try #require(model.state.value).sshKeys.first?.label == "cmc@mac")
180 } 180 }
181 181
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
182 @Test func sshKeyAddPassesALabelOnlyWhenGiven() async throws { 190 @Test func sshKeyAddPassesALabelOnlyWhenGiven() async throws {
183 let (model, stub) = try await loadedModel() 191 let (model, stub) = try await loadedModel()
184 for _ in 0..<2 { 192 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 {
107 var f = MRFilter() 107 var f = MRFilter()
108 f.state = .sourceGone 108 f.state = .sourceGone
109 #expect(f.flags() == ["--state", "source_gone"]) 109 #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"])
111 } 121 }
112 122
113 @Test func everyStateTheCommandAcceptsIsOffered() { 123 @Test func everyStateTheCommandAcceptsIsOffered() {
gitbayUITests/LiveSmokeUITests.swift +27 −2
@@ -412,7 +412,7 @@ final class LiveSmokeUITests: XCTestCase {
412 XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing") 412 XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing")
413 confirm.tap() 413 confirm.tap()
414 414
415 XCTAssertTrue(app.staticTexts["closed"].firstMatch 415 XCTAssertTrue(app.staticTexts["Closed"].firstMatch
416 .waitForExistence(timeout: 15), "MR did not show closed after close") 416 .waitForExistence(timeout: 15), "MR did not show closed after close")
417 } 417 }
418 418
@@ -663,6 +663,7 @@ extension LiveSmokeUITests {
663 // Each rename pushes the renamed repository; the round-trip ends 663 // Each rename pushes the renamed repository; the round-trip ends
664 // on the scratch repo again, several screens deep. 664 // on the scratch repo again, several screens deep.
665 var firstRename = true 665 var firstRename = true
666 var currentPath = Self.scratchRepo
666 let rename: (String, String) -> Void = { newName, expectedPath in 667 let rename: (String, String) -> Void = { newName, expectedPath in
667 if firstRename { 668 if firstRename {
668 firstRename = false // already on the settings screen 669 firstRename = false // already on the settings screen
@@ -678,7 +679,14 @@ extension LiveSmokeUITests {
678 field.tap() 679 field.tap()
679 // Prefilled with the current name; clear it before typing. 680 // Prefilled with the current name; clear it before typing.
680 field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12) + newName) 681 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()
682 // The settings screen offers the renamed repository at the 690 // The settings screen offers the renamed repository at the
683 // top; the list is scrolled to its end, so go back up first. 691 // top; the list is scrolled to its end, so go back up first.
684 let renamedLink = self.app.descendants(matching: .any) 692 let renamedLink = self.app.descendants(matching: .any)
@@ -691,6 +699,7 @@ extension LiveSmokeUITests {
691 // splits the path into the owner link and the rest. 699 // splits the path into the owner link and the rest.
692 XCTAssertTrue(self.app.navigationBars.staticTexts[newName].firstMatch 700 XCTAssertTrue(self.app.navigationBars.staticTexts[newName].firstMatch
693 .waitForExistence(timeout: 15), "renamed repo did not open as \(expectedPath)") 701 .waitForExistence(timeout: 15), "renamed repo did not open as \(expectedPath)")
702 currentPath = expectedPath
694 } 703 }
695 rename("ui-smoke-2", Self.scratchRepo + "-2") 704 rename("ui-smoke-2", Self.scratchRepo + "-2")
696 rename("ui-smoke", Self.scratchRepo) 705 rename("ui-smoke", Self.scratchRepo)
@@ -1715,6 +1724,22 @@ extension LiveSmokeUITests {
1715 XCTAssertTrue(app.staticTexts[original].firstMatch.waitForExistence(timeout: 10), 1724 XCTAssertTrue(app.staticTexts[original].firstMatch.waitForExistence(timeout: 10),
1716 "original label not back") 1725 "original label not back")
1717 } 1726 }
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")
1718 } 1743 }
1719 1744
1720 /// Org labels and milestones from the org screen: a label is created 1745 /// Org labels and milestones from the org screen: a label is created