From fce03857081f8c1ff0dea585920810770cfe00b9 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 12 Aug 2026 22:28:18 +0000 Subject: [PATCH] Restructure PR detail: remove Commits tab, bottom icon tab bar, toolbar actions, markdown overview; bump to 1.9 - Remove the Commits tab (not useful). - Move the section switcher to a bottom tab bar with icons (Overview / Conversation / Files / Checks) instead of a top segmented control. - Move Review (menu) and Merge into the navigation toolbar, removing the bottom action bar. - Add an Overview tab that renders the PR body as markdown via SwiftUI's built-in AttributedString(markdown:) (no third-party dependency). - Bump to 1.9. --- GiteaPR.xcodeproj/project.pbxproj | 8 +-- GiteaPR/Detail/PRDetailView.swift | 102 ++++++++++++++++++------------ GiteaPR/Detail/Tabs.swift | 56 ++++++++++------ 3 files changed, 100 insertions(+), 66 deletions(-) diff --git a/GiteaPR.xcodeproj/project.pbxproj b/GiteaPR.xcodeproj/project.pbxproj index 65116c1..0b28ea6 100644 --- a/GiteaPR.xcodeproj/project.pbxproj +++ b/GiteaPR.xcodeproj/project.pbxproj @@ -187,7 +187,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR"; @@ -197,7 +197,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8; + MARKETING_VERSION = 1.9; PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -211,7 +211,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR"; @@ -221,7 +221,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8; + MARKETING_VERSION = 1.9; PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/GiteaPR/Detail/PRDetailView.swift b/GiteaPR/Detail/PRDetailView.swift index 78ee385..6c519b4 100644 --- a/GiteaPR/Detail/PRDetailView.swift +++ b/GiteaPR/Detail/PRDetailView.swift @@ -2,8 +2,7 @@ import SwiftUI struct PRDetailView: View { @StateObject private var vm: PRDetailViewModel - @State private var selectedTab: DetailTab = .conversation - @State private var showActionSheet = false + @State private var selectedTab: DetailTab = .overview @State private var showCommentSheet = false @State private var showMergeSheet = false @State private var showCloseConfirm = false @@ -11,11 +10,21 @@ struct PRDetailView: View { @State private var requestChangesMode = false enum DetailTab: String, CaseIterable, Identifiable { + case overview = "Overview" case conversation = "Conversation" case files = "Files" - case commits = "Commits" case checks = "Checks" + var id: String { rawValue } + + var icon: String { + switch self { + case .overview: return "doc.text" + case .conversation: return "bubble.left.and.bubble.right" + case .files: return "doc.plaintext" + case .checks: return "checkmark.circle" + } + } } init(repo: Repository, prNumber: Int) { @@ -47,17 +56,6 @@ struct PRDetailView: View { Button("OK", role: .cancel) {} } message: { Text(vm.errorMessage ?? "") } .overlay { if vm.busyMessage != nil { busyOverlay } } - .confirmationDialog("Actions", isPresented: $showActionSheet, titleVisibility: .visible) { - Button("Comment") { showCommentSheet = true } - Button("Approve") { Task { await vm.submitReview(state: "approved", comment: nil) } } - Button("Request changes") { requestChangesMode = true; showCommentSheet = true } - Button("Merge") { showMergeSheet = true } - if prIsOpen { - Button("Close", role: .destructive) { showCloseConfirm = true } - } else { - Button("Reopen") { Task { await vm.setState("open") } } - } - } .confirmationDialog("Close this PR?", isPresented: $showCloseConfirm, titleVisibility: .visible) { Button("Close", role: .destructive) { Task { await vm.setState("closed") } } Button("Cancel", role: .cancel) {} @@ -89,28 +87,24 @@ struct PRDetailView: View { private func content(_ pr: PullRequest) -> some View { VStack(spacing: 0) { header(pr) - Picker("Tab", selection: $selectedTab) { - ForEach(DetailTab.allCases) { t in Text(t.rawValue).tag(t) } - } - .pickerStyle(.segmented) - .padding(.horizontal) - .padding(.bottom, 8) ScrollView { switch selectedTab { + case .overview: OverviewTab(pr: pr) case .conversation: ConversationTab(vm: vm) case .files: FilesTab(vm: vm) - case .commits: CommitsTab(commits: vm.commits) case .checks: ChecksTab(vm: vm, onRerun: { showRerunConfirm = $0 }) } } - if prIsOpen { - actionBar(pr) - } + bottomTabBar } .toolbar { ToolbarItem(placement: .primaryAction) { reloadButton } + if prIsOpen { + ToolbarItem(placement: .primaryAction) { mergeButton } + ToolbarItem(placement: .primaryAction) { reviewMenu } + } } } @@ -131,36 +125,60 @@ struct PRDetailView: View { Text("Draft").font(.caption2).foregroundColor(.secondary) } } - if let body = pr.body, !body.isEmpty { - Text(body) - .font(.callout) - .frame(maxWidth: .infinity, alignment: .leading) - } } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.secondarySystemBackground)) } - @ViewBuilder - private func actionBar(_ pr: PullRequest) -> some View { + /// Bottom tab bar: icon-only navigation between the PR's sections. + private var bottomTabBar: some View { HStack { - Button { showActionSheet = true } label: { - Label("Review", systemImage: "checkmark.bubble") + ForEach(DetailTab.allCases) { tab in + Button { + selectedTab = tab + } label: { + VStack(spacing: 2) { + Image(systemName: tab.icon) + .font(.system(size: 20)) + Text(tab.rawValue) + .font(.caption2) + } + .foregroundColor(selectedTab == tab ? .accentColor : .secondary) + .frame(maxWidth: .infinity) + } } - .buttonStyle(.borderedProminent) - - Button("Merge") { showMergeSheet = true } - .buttonStyle(.bordered) - .disabled(vm.ciStatus == .failure && - SettingsStore.shared.mergeGateMode == .block) - - Spacer() } - .padding() + .padding(.vertical, 6) .background(.bar) } + private var reviewMenu: some View { + Menu { + Button("Comment") { requestChangesMode = false; showCommentSheet = true } + Button("Approve") { Task { await vm.submitReview(state: "approved", comment: nil) } } + Button("Request changes") { requestChangesMode = true; showCommentSheet = true } + Divider() + if prIsOpen { + Button("Close", role: .destructive) { showCloseConfirm = true } + } else { + Button("Reopen") { Task { await vm.setState("open") } } + } + } label: { + Image(systemName: "checkmark.bubble") + } + } + + private var mergeButton: some View { + Button { + showMergeSheet = true + } label: { + Image(systemName: "arrow.down.circle") + } + .disabled(vm.ciStatus == .failure && + SettingsStore.shared.mergeGateMode == .block) + } + private var prIsOpen: Bool { (vm.pr?.state.lowercased() ?? "") == "open" } diff --git a/GiteaPR/Detail/Tabs.swift b/GiteaPR/Detail/Tabs.swift index 1574d80..f2b2922 100644 --- a/GiteaPR/Detail/Tabs.swift +++ b/GiteaPR/Detail/Tabs.swift @@ -142,28 +142,22 @@ struct FilesTab: View { } } -// MARK: - Commits +// MARK: - Overview + +/// Renders the PR body as markdown. Uses SwiftUI's built-in +/// `AttributedString(markdown:)` so headings, bold, italic, links and inline +/// code render without a third-party dependency. +struct OverviewTab: View { + let pr: PullRequest -struct CommitsTab: View { - let commits: [PRCommit] var body: some View { - VStack(alignment: .leading, spacing: 10) { - ForEach(commits) { commit in - HStack(alignment: .top, spacing: 10) { - Text(commit.sha ?? commit.id) - .font(.caption2.monospaced()) - .foregroundColor(.secondary) - .frame(width: 60, alignment: .leading) - VStack(alignment: .leading, spacing: 2) { - Text(commit.commit?.message ?? "(no message)") - .font(.callout) - .lineLimit(3) - Text(commit.author?.name ?? "") - .font(.caption2) - .foregroundColor(.secondary) - } - } - Divider() + VStack(alignment: .leading, spacing: 12) { + if let body = pr.body, !body.isEmpty { + MarkdownText(body) + } else { + Text("No description.") + .font(.callout) + .foregroundColor(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -171,6 +165,28 @@ struct CommitsTab: View { } } +/// A Text view that renders a markdown string, falling back to plain text if +/// the markdown can't be parsed. +struct MarkdownText: View { + let markdown: String + + init(_ markdown: String) { + self.markdown = markdown + } + + var body: some View { + if let attributed = try? AttributedString(markdown: markdown) { + Text(attributed) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text(markdown) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + // MARK: - Checks struct ChecksTab: View {