UI Updates
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 722 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 358 KiB |
@@ -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,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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||