diff --git a/.DS_Store b/.DS_Store index da52e6b..1954172 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/README.md b/README.md index f968883..2e8ece6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SonarDesk.xcodeproj/project.pbxproj b/SonarDesk.xcodeproj/project.pbxproj index 67c50c3..f6c9abd 100644 --- a/SonarDesk.xcodeproj/project.pbxproj +++ b/SonarDesk.xcodeproj/project.pbxproj @@ -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; } diff --git a/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate b/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate index 898e42d..6945a89 100644 Binary files a/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate and b/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SonarDesk/App/SonarDeskApp.swift b/SonarDesk/App/SonarDeskApp.swift index 6c1d757..69356f4 100644 --- a/SonarDesk/App/SonarDeskApp.swift +++ b/SonarDesk/App/SonarDeskApp.swift @@ -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) } } } diff --git a/SonarDesk/Assets.xcassets/AccentColor.colorset/Contents.json b/SonarDesk/Assets.xcassets/AccentColor.colorset/Contents.json index afaa4da..1101ac4 100644 --- a/SonarDesk/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/SonarDesk/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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} +} diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/Contents.json b/SonarDesk/Assets.xcassets/AppIcon.appiconset/Contents.json index 20a9ca1..bcb3b99 100644 --- a/SonarDesk/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/SonarDesk/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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} +} diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_128.png b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 0000000..14fa653 Binary files /dev/null and b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png new file mode 100644 index 0000000..6823b52 Binary files /dev/null and b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png differ diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_16.png b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 0000000..3faf42e Binary files /dev/null and b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png new file mode 100644 index 0000000..3aa2375 Binary files /dev/null and b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png differ diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_256.png b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 0000000..6823b52 Binary files /dev/null and b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png new file mode 100644 index 0000000..2f2d498 Binary files /dev/null and b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png differ diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_32.png b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 0000000..3aa2375 Binary files /dev/null and b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png new file mode 100644 index 0000000..232da65 Binary files /dev/null and b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png differ diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_512.png b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 0000000..2f2d498 Binary files /dev/null and b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png new file mode 100644 index 0000000..3614192 Binary files /dev/null and b/SonarDesk/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png differ diff --git a/SonarDesk/Services/SonarQubeClient.swift b/SonarDesk/Services/SonarQubeClient.swift index 3bd9fa8..da0ba75 100644 --- a/SonarDesk/Services/SonarQubeClient.swift +++ b/SonarDesk/Services/SonarQubeClient.swift @@ -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) } diff --git a/SonarDesk/Stores/AppState.swift b/SonarDesk/Stores/AppState.swift index c16d881..f940377 100644 --- a/SonarDesk/Stores/AppState.swift +++ b/SonarDesk/Stores/AppState.swift @@ -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 { diff --git a/SonarDesk/Views/Dashboard/DashboardView.swift b/SonarDesk/Views/Dashboard/DashboardView.swift index 1f47a60..3355143 100644 --- a/SonarDesk/Views/Dashboard/DashboardView.swift +++ b/SonarDesk/Views/Dashboard/DashboardView.swift @@ -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 { diff --git a/SonarDesk/Views/Issues/IssuesView.swift b/SonarDesk/Views/Issues/IssuesView.swift index ae4c895..dcb5d2c 100644 --- a/SonarDesk/Views/Issues/IssuesView.swift +++ b/SonarDesk/Views/Issues/IssuesView.swift @@ -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 } } diff --git a/SonarDesk/Views/Launch/LaunchView.swift b/SonarDesk/Views/Launch/LaunchView.swift new file mode 100644 index 0000000..e109b95 --- /dev/null +++ b/SonarDesk/Views/Launch/LaunchView.swift @@ -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 } } + } +} diff --git a/SonarDesk/Views/Projects/ProjectDetailView.swift b/SonarDesk/Views/Projects/ProjectDetailView.swift index 8905367..dbb9e5b 100644 --- a/SonarDesk/Views/Projects/ProjectDetailView.swift +++ b/SonarDesk/Views/Projects/ProjectDetailView.swift @@ -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" + } + } +} diff --git a/SonarDesk/Views/RootView.swift b/SonarDesk/Views/RootView.swift index 9ed99d3..b89e211 100644 --- a/SonarDesk/Views/RootView.swift +++ b/SonarDesk/Views/RootView.swift @@ -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) }