Commit 0c52dbc5ac
Unsigned
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. | ||
| 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 | /// Every value-routed screen, attachable to any stack root. | 236 | /// Every value-routed screen, attachable to any stack root. |
| 180 | private struct RouteDestinations: ViewModifier { | 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 | @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. | ||
| 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 | @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 | |||
| 8 | struct FormattedBodyView: View { | 8 | struct 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 { | |||
| 338 | private struct MarkdownTableView: View { | 363 | private 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 @@ | |||
| 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 | #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 @@ | |||
| 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 | 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 |