UI Updates

This commit is contained in:
Mark Gregory
2026-09-29 10:08:19 +01:00
parent 6305f580ca
commit 10d57df79c
24 changed files with 216 additions and 91 deletions
Vendored
BIN
View File
Binary file not shown.
+7
View File
@@ -26,3 +26,10 @@ Open SonarDesk > Settings, add your SonarQube Server URL and a SonarQube user to
- Dashboard project rows are clickable and open project details.
- Dashboard project pagination with 10, 25, and 50 rows per page.
- Existing user-defined sidebar project ordering is preserved by pagination.
## v1.3
- Server-side pagination for All Issues, Bugs, Vulnerabilities and Code Smells (25/50/100 rows).
- Dedicated issue destinations in the main sidebar.
- Project metric navigation replaces the old View label/buttons; every metric card has a destination.
- Professional semantic colour system across dashboard/project metrics.
- New SonarDesk application icon and branded launch splash.
+2 -2
View File
@@ -17,8 +17,8 @@
A8 = {isa = XCConfigurationList; buildConfigurations = (A14,A15); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release;};
A12 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0;}; name = Debug;};
A13 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0;}; name = Release;};
A14 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0;}; name = Debug;};
A15 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0;}; name = Release;};
A14 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0;}; name = Debug;};
A15 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0;}; name = Release;};
};
rootObject = A1;
}
+14 -6
View File
@@ -3,20 +3,28 @@ import SwiftUI
@main
struct SonarDeskApp: App {
@StateObject private var appState = AppState()
@State private var showingLaunch = true
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(appState)
.frame(minWidth: 1040, minHeight: 680)
ZStack {
RootView()
.environmentObject(appState)
.opacity(showingLaunch ? 0 : 1)
if showingLaunch { LaunchView().transition(.opacity) }
}
.frame(minWidth: 1040, minHeight: 680)
.task {
if appState.projects.isEmpty && appState.selectedServer != nil { await appState.refresh() }
try? await Task.sleep(for: .milliseconds(900))
withAnimation(.easeOut(duration: 0.28)) { showingLaunch = false }
}
}
.windowStyle(.titleBar)
.commands { SidebarCommands() }
Settings {
SettingsView()
.environmentObject(appState)
.frame(width: 620, height: 430)
SettingsView().environmentObject(appState).frame(width: 620, height: 430)
}
}
}
@@ -1 +1,7 @@
{"colors":[{"idiom":"universal","color":{"color-space":"srgb","components":{"red":"0.086","green":"0.486","blue":"0.902","alpha":"1.000"}}}],"info":{"author":"xcode","version":1}}
{
"colors" : [{
"color" : {"color-space":"srgb","components":{"alpha":"1.000","blue":"0.965","green":"0.520","red":"0.075"}},
"idiom" : "universal"
}],
"info" : {"author":"xcode","version":1}
}
@@ -1 +1,15 @@
{"images":[],"info":{"author":"xcode","version":1}}
{
"images" : [
{"filename":"icon_16.png","idiom":"mac","scale":"1x","size":"16x16"},
{"filename":"icon_16@2x.png","idiom":"mac","scale":"2x","size":"16x16"},
{"filename":"icon_32.png","idiom":"mac","scale":"1x","size":"32x32"},
{"filename":"icon_32@2x.png","idiom":"mac","scale":"2x","size":"32x32"},
{"filename":"icon_128.png","idiom":"mac","scale":"1x","size":"128x128"},
{"filename":"icon_128@2x.png","idiom":"mac","scale":"2x","size":"128x128"},
{"filename":"icon_256.png","idiom":"mac","scale":"1x","size":"256x256"},
{"filename":"icon_256@2x.png","idiom":"mac","scale":"2x","size":"256x256"},
{"filename":"icon_512.png","idiom":"mac","scale":"1x","size":"512x512"},
{"filename":"icon_512@2x.png","idiom":"mac","scale":"2x","size":"512x512"}
],
"info" : {"author":"xcode","version":1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

+2 -2
View File
@@ -29,10 +29,10 @@ actor SonarQubeClient {
try await get("api/qualitygates/project_status", query: ["projectKey": projectKey])
}
func issues(projectKey: String? = nil, search: String = "", page: Int = 1, pageSize: Int = 100) async throws -> IssuesResponse {
func issues(projectKey: String? = nil, type: String? = nil, page: Int = 1, pageSize: Int = 25) async throws -> IssuesResponse {
var q = ["p":"\(page)", "ps":"\(pageSize)", "resolved":"false", "s":"CREATION_DATE", "asc":"false"]
if let projectKey { q["componentKeys"] = projectKey }
if !search.isEmpty { q["search"] = search }
if let type { q["types"] = type }
return try await get("api/issues/search", query: q)
}
+7 -3
View File
@@ -7,6 +7,7 @@ final class AppState: ObservableObject {
@Published var selectedServerID: UUID? { didSet { UserDefaults.standard.set(selectedServerID?.uuidString, forKey: "selectedServer") } }
@Published var projects: [ProjectSnapshot] = []
@Published var issues: [Issue] = []
@Published var issueTotal = 0
@Published var selectedProjectKey: String?
@Published var isLoading = false
@Published var errorMessage: String?
@@ -54,11 +55,14 @@ final class AppState: ObservableObject {
} catch { errorMessage = error.localizedDescription }
}
func loadIssues(projectKey: String? = nil) async {
func loadIssues(projectKey: String? = nil, filter: IssueFilter = .all, page: Int = 1, pageSize: Int = 25) async {
isLoading = true; errorMessage = nil
defer { isLoading = false }
do { issues = try await client().issues(projectKey: projectKey).issues }
catch { errorMessage = error.localizedDescription }
do {
let response = try await client().issues(projectKey: projectKey, type: filter.apiType, page: page, pageSize: pageSize)
issues = response.issues
issueTotal = response.total ?? response.issues.count
} catch { errorMessage = error.localizedDescription }
}
func addServer(name: String, url: String, token: String) throws {
@@ -36,7 +36,7 @@ struct DashboardView: View {
}
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), spacing: 12) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 210), spacing: 12)], spacing: 12) {
MetricCard(title: "Projects", value: "\(app.projects.count)", systemImage: "shippingbox.fill", tint: .blue) {
failedOnly = false; projectPage = 1
}
@@ -49,6 +49,9 @@ struct DashboardView: View {
MetricCard(title: "Vulnerabilities", value: "\(total("vulnerabilities"))", systemImage: "lock.trianglebadge.exclamationmark.fill", tint: .orange) {
onNavigate(.issuesFiltered(.vulnerabilities))
}
MetricCard(title: "Code Smells", value: "\(total("code_smells"))", systemImage: "exclamationmark.bubble.fill", tint: .yellow) {
onNavigate(.issuesFiltered(.codeSmells))
}
}
GroupBox {
+77 -30
View File
@@ -1,19 +1,34 @@
import SwiftUI
enum IssueFilter: String, CaseIterable, Identifiable, Hashable {
case all = "All"
case all = "All Issues"
case bugs = "Bugs"
case vulnerabilities = "Vulnerabilities"
case codeSmells = "Code Smells"
var id: String { rawValue }
func matches(_ issue: Issue) -> Bool {
var apiType: String? {
switch self {
case .all: return true
case .bugs: return issue.type?.uppercased() == "BUG"
case .vulnerabilities: return issue.type?.uppercased() == "VULNERABILITY"
case .codeSmells: return issue.type?.uppercased() == "CODE_SMELL"
case .all: nil
case .bugs: "BUG"
case .vulnerabilities: "VULNERABILITY"
case .codeSmells: "CODE_SMELL"
}
}
var icon: String {
switch self {
case .all: "exclamationmark.triangle"
case .bugs: "ladybug"
case .vulnerabilities: "lock.trianglebadge.exclamationmark"
case .codeSmells: "exclamationmark.bubble"
}
}
var tint: Color {
switch self {
case .all: .blue
case .bugs: .red
case .vulnerabilities: .orange
case .codeSmells: .yellow
}
}
}
@@ -25,6 +40,8 @@ struct IssuesView: View {
@State private var search = ""
@State private var selection: Issue.ID?
@State private var filter: IssueFilter
@State private var page = 1
@State private var pageSize = 25
init(projectKey: String?, initialFilter: IssueFilter = .all) {
self.projectKey = projectKey
@@ -32,37 +49,67 @@ struct IssuesView: View {
_filter = State(initialValue: initialFilter)
}
private var filtered: [Issue] {
app.issues.filter { issue in
guard filter.matches(issue) else { return false }
guard !search.isEmpty else { return true }
return (issue.message ?? "").localizedCaseInsensitiveContains(search)
|| (issue.rule ?? "").localizedCaseInsensitiveContains(search)
|| (issue.component ?? "").localizedCaseInsensitiveContains(search)
private var visibleIssues: [Issue] {
guard !search.isEmpty else { return app.issues }
return app.issues.filter {
($0.message ?? "").localizedCaseInsensitiveContains(search) ||
($0.rule ?? "").localizedCaseInsensitiveContains(search) ||
($0.component ?? "").localizedCaseInsensitiveContains(search)
}
}
private var pageCount: Int { max(1, Int(ceil(Double(app.issueTotal) / Double(pageSize)))) }
var body: some View {
Table(filtered, selection: $selection) {
TableColumn("Severity") { issue in Text(issue.severity ?? "—").font(.caption.weight(.semibold)) }.width(80)
TableColumn("Issue") { issue in VStack(alignment: .leading, spacing: 2) { Text(issue.message ?? "Unknown issue").lineLimit(2); Text(issue.rule ?? "").font(.caption).foregroundStyle(.secondary) } }.width(min: 280, ideal: 500)
TableColumn("File") { issue in Text(shortComponent(issue.component)).lineLimit(1) }.width(min: 160, ideal: 280)
TableColumn("Line") { issue in Text(issue.line.map(String.init) ?? "—") }.width(50)
TableColumn("Type") { issue in Text(issue.type ?? "—") }.width(100)
}
.searchable(text: $search, prompt: "Search issues")
.navigationTitle(projectKey == nil ? "All Issues" : "Issues")
.toolbar {
ToolbarItem {
Picker("Issue Type", selection: $filter) {
ForEach(IssueFilter.allCases) { item in Text(item.rawValue).tag(item) }
VStack(spacing: 0) {
HStack(spacing: 12) {
Label(filter.rawValue, systemImage: filter.icon)
.font(.title2.bold()).foregroundStyle(filter.tint)
if app.issueTotal > 0 { Text(app.issueTotal.formatted()).font(.title3.monospacedDigit()).foregroundStyle(.secondary) }
Spacer()
Picker("Type", selection: $filter) {
ForEach(IssueFilter.allCases) { item in Label(item.rawValue, systemImage: item.icon).tag(item) }
}
.pickerStyle(.menu)
.frame(width: 150)
.pickerStyle(.menu).frame(width: 170)
Picker("Per page", selection: $pageSize) {
Text("25 per page").tag(25); Text("50 per page").tag(50); Text("100 per page").tag(100)
}
.pickerStyle(.menu).frame(width: 125)
}
.padding(.horizontal, 18).padding(.vertical, 12)
.background(filter.tint.opacity(0.055))
Table(visibleIssues, selection: $selection) {
TableColumn("Severity") { issue in Text(issue.severity ?? "—").font(.caption.weight(.semibold)) }.width(80)
TableColumn("Issue") { issue in
VStack(alignment: .leading, spacing: 2) {
Text(issue.message ?? "Unknown issue").lineLimit(2)
Text(issue.rule ?? "").font(.caption).foregroundStyle(.secondary)
}
}.width(min: 280, ideal: 500)
TableColumn("File") { issue in Text(shortComponent(issue.component)).lineLimit(1) }.width(min: 160, ideal: 280)
TableColumn("Line") { issue in Text(issue.line.map(String.init) ?? "—") }.width(50)
TableColumn("Type") { issue in Text(issue.type ?? "—") }.width(110)
}
.searchable(text: $search, prompt: "Search this page")
Divider()
HStack(spacing: 10) {
Text(app.issueTotal == 0 ? "No issues" : "Showing \(((page - 1) * pageSize) + 1)–\(min(page * pageSize, app.issueTotal)) of \(app.issueTotal)")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Button { page = max(1, page - 1) } label: { Label("Previous", systemImage: "chevron.left") }.disabled(page <= 1)
Text("Page \(page) of \(pageCount)").font(.caption.monospacedDigit()).frame(minWidth: 90)
Button { page = min(pageCount, page + 1) } label: { Label("Next", systemImage: "chevron.right") }.disabled(page >= pageCount)
}
.padding(12)
}
.task(id: projectKey) { await app.loadIssues(projectKey: projectKey) }
.navigationTitle(filter.rawValue)
.task(id: requestID) { await load() }
.onChange(of: filter) { _, _ in page = 1 }
.onChange(of: pageSize) { _, _ in page = 1 }
}
private var requestID: String { "\(projectKey ?? "all")|\(filter.rawValue)|\(page)|\(pageSize)" }
private func load() async { await app.loadIssues(projectKey: projectKey, filter: filter, page: page, pageSize: pageSize) }
private func shortComponent(_ value: String?) -> String { guard let value else { return "—" }; return value.split(separator: ":").last.map(String.init) ?? value }
}
+24
View File
@@ -0,0 +1,24 @@
import SwiftUI
import AppKit
struct LaunchView: View {
@State private var glow = false
var body: some View {
ZStack {
LinearGradient(colors: [Color(red: 0.025, green: 0.055, blue: 0.11), Color(red: 0.035, green: 0.10, blue: 0.19), .black], startPoint: .topLeading, endPoint: .bottomTrailing)
.ignoresSafeArea()
Circle().fill(Color.blue.opacity(0.16)).frame(width: 430, height: 430).blur(radius: 80).scaleEffect(glow ? 1.12 : 0.9)
VStack(spacing: 18) {
Image(nsImage: NSApplication.shared.applicationIconImage)
.resizable().interpolation(.high).frame(width: 132, height: 132)
.shadow(color: .blue.opacity(0.45), radius: 28, y: 10)
Text("SonarDesk").font(.system(size: 42, weight: .bold, design: .rounded))
Text("SonarQube Monitor").font(.title3).foregroundStyle(.secondary)
ProgressView().controlSize(.small).tint(.blue).padding(.top, 10)
Text("Connecting to SonarQube…").font(.caption).foregroundStyle(.tertiary)
}
}
.onAppear { withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { glow = true } }
}
}
@@ -4,7 +4,6 @@ struct ProjectDetailView: View {
@EnvironmentObject var app: AppState
let projectKey: String
@State private var section: ProjectSection = .overview
@State private var issueFilter: IssueFilter = .all
private var item: ProjectSnapshot? { app.projects.first { $0.project.key == projectKey } }
@@ -16,51 +15,64 @@ struct ProjectDetailView: View {
Text(item.project.name).font(.largeTitle.bold())
Text(item.project.key).foregroundStyle(.secondary)
}
Spacer()
GateBadge(status: item.qualityGate)
}
.padding(24)
Spacer(); GateBadge(status: item.qualityGate)
}.padding(24)
HStack(spacing: 8) {
Text("View").foregroundStyle(.secondary)
Button("Overview") { section = .overview }
.buttonStyle(.borderedProminent)
.tint(section == .overview ? .accentColor : .gray.opacity(0.35))
Button("Issues") { showIssues(.all) }
.buttonStyle(.borderedProminent)
.tint(section == .issues ? .accentColor : .gray.opacity(0.35))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
navButton(.overview, icon: "square.grid.2x2.fill", tint: .blue)
navButton(.bugs, icon: "ladybug.fill", tint: .red)
navButton(.vulnerabilities, icon: "lock.trianglebadge.exclamationmark.fill", tint: .orange)
navButton(.codeSmells, icon: "exclamationmark.bubble.fill", tint: .yellow)
navButton(.coverage, icon: "chart.bar.fill", tint: .green)
navButton(.duplication, icon: "square.on.square.fill", tint: .purple)
navButton(.lines, icon: "chevron.left.forwardslash.chevron.right", tint: .blue)
}.padding(.horizontal, 24).padding(.bottom, 16)
}
.padding(.bottom, 16)
Divider()
switch section {
case .overview:
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 210), spacing: 14)], spacing: 14) {
MetricCard(title: "Bugs", value: item.measures["bugs"] ?? "—", systemImage: "ladybug", tint: .red) { showIssues(.bugs) }
MetricCard(title: "Vulnerabilities", value: item.measures["vulnerabilities"] ?? "—", systemImage: "lock.trianglebadge.exclamationmark", tint: .orange) { showIssues(.vulnerabilities) }
MetricCard(title: "Code Smells", value: item.measures["code_smells"] ?? "—", systemImage: "exclamationmark.bubble", tint: .yellow) { showIssues(.codeSmells) }
MetricCard(title: "Coverage", value: (item.measures["coverage"].map { $0 + "%" }) ?? "—", systemImage: "chart.bar", tint: .green)
MetricCard(title: "Duplication", value: (item.measures["duplicated_lines_density"].map { $0 + "%" }) ?? "—", systemImage: "square.on.square", tint: .purple)
MetricCard(title: "Lines of Code", value: item.measures["ncloc"] ?? "—", systemImage: "chevron.left.forwardslash.chevron.right", tint: .blue)
}
.padding(24)
MetricCard(title: "Bugs", value: item.measures["bugs"] ?? "—", systemImage: "ladybug.fill", tint: .red) { section = .bugs }
MetricCard(title: "Vulnerabilities", value: item.measures["vulnerabilities"] ?? "—", systemImage: "lock.trianglebadge.exclamationmark.fill", tint: .orange) { section = .vulnerabilities }
MetricCard(title: "Code Smells", value: item.measures["code_smells"] ?? "—", systemImage: "exclamationmark.bubble.fill", tint: .yellow) { section = .codeSmells }
MetricCard(title: "Coverage", value: (item.measures["coverage"].map { $0 + "%" }) ?? "—", systemImage: "chart.bar.fill", tint: .green) { section = .coverage }
MetricCard(title: "Duplication", value: (item.measures["duplicated_lines_density"].map { $0 + "%" }) ?? "—", systemImage: "square.on.square.fill", tint: .purple) { section = .duplication }
MetricCard(title: "Lines of Code", value: item.measures["ncloc"] ?? "—", systemImage: "chevron.left.forwardslash.chevron.right", tint: .blue) { section = .lines }
}.padding(24)
}
case .issues:
IssuesView(projectKey: projectKey, initialFilter: issueFilter)
.id("\(projectKey)-\(issueFilter.rawValue)")
case .bugs: IssuesView(projectKey: projectKey, initialFilter: .bugs).id("\(projectKey)-bugs")
case .vulnerabilities: IssuesView(projectKey: projectKey, initialFilter: .vulnerabilities).id("\(projectKey)-vulnerabilities")
case .codeSmells: IssuesView(projectKey: projectKey, initialFilter: .codeSmells).id("\(projectKey)-smells")
case .coverage: metricDetail("Coverage", value: (item.measures["coverage"].map { $0 + "%" }) ?? "—", icon: "chart.bar.fill", tint: .green, description: "Test coverage reported by SonarQube for this project.")
case .duplication: metricDetail("Duplication", value: (item.measures["duplicated_lines_density"].map { $0 + "%" }) ?? "—", icon: "square.on.square.fill", tint: .purple, description: "Duplicated lines density reported by SonarQube.")
case .lines: metricDetail("Lines of Code", value: item.measures["ncloc"] ?? "—", icon: "chevron.left.forwardslash.chevron.right", tint: .blue, description: "Non-comment lines of code measured by SonarQube.")
}
}
} else {
ContentUnavailableView("Project unavailable", systemImage: "shippingbox")
}
} else { ContentUnavailableView("Project unavailable", systemImage: "shippingbox") }
}
private func showIssues(_ filter: IssueFilter) {
issueFilter = filter
section = .issues
private func navButton(_ target: ProjectSection, icon: String, tint: Color) -> some View {
Button { section = target } label: { Label(target.title, systemImage: icon).padding(.horizontal, 4) }
.buttonStyle(.borderedProminent).tint(section == target ? tint : .gray.opacity(0.35))
}
private func metricDetail(_ title: String, value: String, icon: String, tint: Color, description: String) -> some View {
VStack(spacing: 18) {
Image(systemName: icon).font(.system(size: 48)).foregroundStyle(tint)
Text(value).font(.system(size: 64, weight: .bold, design: .rounded))
Text(title).font(.title2.bold()); Text(description).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, maxHeight: .infinity).background(tint.opacity(0.045))
}
}
private enum ProjectSection { case overview, issues }
private enum ProjectSection: Hashable {
case overview, bugs, vulnerabilities, codeSmells, coverage, duplication, lines
var title: String {
switch self {
case .overview: "Overview"; case .bugs: "Bugs"; case .vulnerabilities: "Vulnerabilities"; case .codeSmells: "Code Smells"; case .coverage: "Coverage"; case .duplication: "Duplication"; case .lines: "Lines of Code"
}
}
}
+12 -12
View File
@@ -8,23 +8,22 @@ struct RootView: View {
NavigationSplitView {
List(selection: $selection) {
Section("Overview") {
Label("Dashboard", systemImage: "gauge.with.dots.needle.67percent").tag(Destination.dashboard)
Label("All Issues", systemImage: "exclamationmark.triangle").tag(Destination.issues)
Label("Dashboard", systemImage: "square.grid.2x2.fill").tag(Destination.dashboard)
Label("All Issues", systemImage: "exclamationmark.triangle.fill").tag(Destination.issues)
Label("Bugs", systemImage: "ladybug.fill").foregroundStyle(selection == .issuesFiltered(.bugs) ? .red : .primary).tag(Destination.issuesFiltered(.bugs))
Label("Vulnerabilities", systemImage: "lock.trianglebadge.exclamationmark.fill").foregroundStyle(selection == .issuesFiltered(.vulnerabilities) ? .orange : .primary).tag(Destination.issuesFiltered(.vulnerabilities))
Label("Code Smells", systemImage: "exclamationmark.bubble.fill").foregroundStyle(selection == .issuesFiltered(.codeSmells) ? .yellow : .primary).tag(Destination.issuesFiltered(.codeSmells))
}
Section("Projects") {
ForEach(app.projects) { snapshot in
Label(snapshot.project.name, systemImage: "shippingbox")
Label(snapshot.project.name, systemImage: "shippingbox.fill")
.tag(Destination.project(snapshot.project.key))
.contextMenu {
Button("Move Up") { app.moveProject(snapshot.project.key, direction: -1) }
.disabled(app.projects.first?.project.key == snapshot.project.key)
Button("Move Down") { app.moveProject(snapshot.project.key, direction: 1) }
.disabled(app.projects.last?.project.key == snapshot.project.key)
Button("Move Up") { app.moveProject(snapshot.project.key, direction: -1) }.disabled(app.projects.first?.project.key == snapshot.project.key)
Button("Move Down") { app.moveProject(snapshot.project.key, direction: 1) }.disabled(app.projects.last?.project.key == snapshot.project.key)
}
}
.onMove { source, destination in
app.moveProjects(from: source, to: destination)
}
.onMove { source, destination in app.moveProjects(from: source, to: destination) }
}
}
.navigationTitle("SonarDesk")
@@ -37,14 +36,15 @@ struct RootView: View {
} detail: {
Group {
switch selection {
case .dashboard: DashboardView { destination in selection = destination }
case .dashboard: DashboardView { selection = $0 }
case .issues: IssuesView(projectKey: nil)
case .issuesFiltered(let filter): IssuesView(projectKey: nil, initialFilter: filter)
case .issuesFiltered(let filter): IssuesView(projectKey: nil, initialFilter: filter).id(filter.rawValue)
case .project(let key): ProjectDetailView(projectKey: key)
case .none: ContentUnavailableView("Select an item", systemImage: "sidebar.left")
}
}
}
.tint(Color(red: 0.08, green: 0.52, blue: 0.96))
.toolbar {
ToolbarItemGroup {
if app.isLoading { ProgressView().controlSize(.small) }