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