commit 6305f580caf03f5c32ac3524f0e68fc5e4f87850 Author: Mark Gregory Date: Tue Sep 29 10:04:58 2026 +0100 Initial Commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..da52e6b Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f968883 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# SonarDesk +Native macOS SonarQube dashboard built with SwiftUI. + +## Open +Open `SonarDesk.xcodeproj` in Xcode, select your Development Team under Signing & Capabilities, then Run. + +## First connection +Open SonarDesk > Settings, add your SonarQube Server URL and a SonarQube user token. Tokens are stored in Keychain. + +## Included +- Multiple SonarQube servers +- Keychain token storage +- Server status/version +- Project dashboard +- Quality gate state +- Bugs, vulnerabilities, smells, coverage, duplication, LOC +- Project detail +- Unresolved issue browser/search +- Sandboxed outgoing networking + +## v1.2 dashboard improvements +- Colour-accented interactive dashboard cards. +- Bugs and Vulnerabilities cards navigate directly to filtered global issues. +- Failed Gates card filters the dashboard project list to failing quality gates. +- Projects card restores the complete project list. +- 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. diff --git a/SonarDesk.xcodeproj/project.pbxproj b/SonarDesk.xcodeproj/project.pbxproj new file mode 100644 index 0000000..67c50c3 --- /dev/null +++ b/SonarDesk.xcodeproj/project.pbxproj @@ -0,0 +1,24 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = {}; + objectVersion = 77; + objects = { + A1 = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2600;}; buildConfigurationList = A2; developmentRegion = en; knownRegions = (en, Base); mainGroup = A3; productRefGroup = A4; projectDirPath = ""; projectRoot = ""; targets = (A5,);}; + A3 = {isa = PBXGroup; children = (A6,A4); sourceTree = "";}; + A4 = {isa = PBXGroup; children = (A7,); name = Products; sourceTree = "";}; + A6 = {isa = PBXFileSystemSynchronizedRootGroup; path = SonarDesk; sourceTree = "";}; + A7 = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SonarDesk.app; sourceTree = BUILT_PRODUCTS_DIR;}; + A5 = {isa = PBXNativeTarget; buildConfigurationList = A8; buildPhases = (A9,A10,A11); buildRules = (); dependencies = (); fileSystemSynchronizedGroups = (A6,); name = SonarDesk; packageProductDependencies = (); productName = SonarDesk; productReference = A7; productType = "com.apple.product-type.application";}; + A9 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0;}; + A10 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0;}; + A11 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0;}; + A2 = {isa = XCConfigurationList; buildConfigurations = (A12,A13); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release;}; + 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;}; + }; + rootObject = A1; +} diff --git a/SonarDesk.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/SonarDesk.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/SonarDesk.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate b/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..898e42d Binary files /dev/null and b/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SonarDesk.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist b/SonarDesk.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..b2870ba --- /dev/null +++ b/SonarDesk.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + SonarDesk.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/SonarDesk/App/SonarDeskApp.swift b/SonarDesk/App/SonarDeskApp.swift new file mode 100644 index 0000000..6c1d757 --- /dev/null +++ b/SonarDesk/App/SonarDeskApp.swift @@ -0,0 +1,22 @@ +import SwiftUI + +@main +struct SonarDeskApp: App { + @StateObject private var appState = AppState() + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(appState) + .frame(minWidth: 1040, minHeight: 680) + } + .windowStyle(.titleBar) + .commands { SidebarCommands() } + + Settings { + 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 new file mode 100644 index 0000000..afaa4da --- /dev/null +++ b/SonarDesk/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1 @@ +{"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}} diff --git a/SonarDesk/Assets.xcassets/AppIcon.appiconset/Contents.json b/SonarDesk/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..20a9ca1 --- /dev/null +++ b/SonarDesk/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[],"info":{"author":"xcode","version":1}} diff --git a/SonarDesk/Assets.xcassets/Contents.json b/SonarDesk/Assets.xcassets/Contents.json new file mode 100644 index 0000000..f223229 --- /dev/null +++ b/SonarDesk/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{"info":{"author":"xcode","version":1}} diff --git a/SonarDesk/Models/Models.swift b/SonarDesk/Models/Models.swift new file mode 100644 index 0000000..9f94fde --- /dev/null +++ b/SonarDesk/Models/Models.swift @@ -0,0 +1,63 @@ +import Foundation + +struct SonarServer: Codable, Identifiable, Hashable { + var id = UUID() + var name: String + var baseURL: String +} + +struct Project: Codable, Identifiable, Hashable { + var id: String { key } + let key: String + let name: String + let qualifier: String? + let visibility: String? + let lastAnalysisDate: String? +} + +struct ProjectsResponse: Codable { + let components: [Project] + let paging: Paging? +} + +struct Paging: Codable { let pageIndex: Int?; let pageSize: Int?; let total: Int? } + +struct Measure: Codable, Hashable { let metric: String; let value: String?; let bestValue: Bool? } +struct MeasuresComponent: Codable { let key: String; let name: String; let measures: [Measure] } +struct MeasuresResponse: Codable { let component: MeasuresComponent } + +struct QualityGateResponse: Codable { let projectStatus: ProjectStatus } +struct ProjectStatus: Codable { let status: String; let conditions: [GateCondition]? } +struct GateCondition: Codable, Hashable { + let status: String + let metricKey: String + let comparator: String? + let errorThreshold: String? + let actualValue: String? +} + +struct Issue: Codable, Identifiable, Hashable { + var id: String { key } + let key: String + let rule: String? + let severity: String? + let component: String? + let project: String? + let line: Int? + let message: String? + let type: String? + let status: String? + let creationDate: String? + let effort: String? +} + +struct IssuesResponse: Codable { let total: Int?; let p: Int?; let ps: Int?; let issues: [Issue] } + +struct ServerStatusResponse: Codable { let id: String?; let version: String?; let status: String? } + +struct ProjectSnapshot: Identifiable { + var id: String { project.key } + let project: Project + var measures: [String: String] = [:] + var qualityGate: String = "UNKNOWN" +} diff --git a/SonarDesk/Services/KeychainService.swift b/SonarDesk/Services/KeychainService.swift new file mode 100644 index 0000000..833f229 --- /dev/null +++ b/SonarDesk/Services/KeychainService.swift @@ -0,0 +1,44 @@ +import Foundation +import Security + +enum KeychainService { + static let service = "net.absdev.SonarDesk" + + static func saveToken(_ token: String, serverID: UUID) throws { + let account = serverID.uuidString + let data = Data(token.utf8) + let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account] + SecItemDelete(query as CFDictionary) + var add = query + add[kSecValueData as String] = data + let status = SecItemAdd(add as CFDictionary, nil) + guard status == errSecSuccess else { throw KeychainError.status(status) } + } + + static func token(serverID: UUID) -> String? { + let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: serverID.uuidString, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func deleteToken(serverID: UUID) { + let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: serverID.uuidString] + SecItemDelete(query as CFDictionary) + } +} + +enum KeychainError: LocalizedError { + case status(OSStatus) + var errorDescription: String? { "Keychain error (\(statusCode))" } + private var statusCode: OSStatus { if case .status(let s) = self { return s }; return -1 } +} diff --git a/SonarDesk/Services/SonarQubeClient.swift b/SonarDesk/Services/SonarQubeClient.swift new file mode 100644 index 0000000..3bd9fa8 --- /dev/null +++ b/SonarDesk/Services/SonarQubeClient.swift @@ -0,0 +1,68 @@ +import Foundation + +actor SonarQubeClient { + let server: SonarServer + private let token: String + private let session: URLSession + + init(server: SonarServer, token: String) { + self.server = server + self.token = token + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 60 + self.session = URLSession(configuration: config) + } + + func serverStatus() async throws -> ServerStatusResponse { try await get("api/system/status") } + + func projects(page: Int = 1, pageSize: Int = 100) async throws -> ProjectsResponse { + try await get("api/components/search", query: ["qualifiers":"TRK", "p":"\(page)", "ps":"\(pageSize)"]) + } + + func measures(projectKey: String) async throws -> MeasuresResponse { + let metrics = ["bugs","vulnerabilities","code_smells","security_hotspots","coverage","duplicated_lines_density","ncloc","complexity","reliability_rating","security_rating","sqale_rating"].joined(separator: ",") + return try await get("api/measures/component", query: ["component": projectKey, "metricKeys": metrics]) + } + + func qualityGate(projectKey: String) async throws -> QualityGateResponse { + 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 { + 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 } + return try await get("api/issues/search", query: q) + } + + private func get(_ path: String, query: [String:String] = [:]) async throws -> T { + guard var components = URLComponents(string: server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/" + path) else { throw APIError.invalidURL } + if !query.isEmpty { components.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } } + guard let url = components.url else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard (200...299).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + throw APIError.http(http.statusCode, body) + } + do { return try JSONDecoder().decode(T.self, from: data) } + catch { throw APIError.decoding(error.localizedDescription) } + } +} + +enum APIError: LocalizedError { + case invalidURL, invalidResponse, http(Int, String), decoding(String), notConfigured + var errorDescription: String? { + switch self { + case .invalidURL: return "The server URL is invalid." + case .invalidResponse: return "The server returned an invalid response." + case .http(let code, _): return "SonarQube returned HTTP \(code)." + case .decoding(let text): return "Could not read the SonarQube response: \(text)" + case .notConfigured: return "Configure a SonarQube server in Settings." + } + } +} diff --git a/SonarDesk/SonarDesk.entitlements b/SonarDesk/SonarDesk.entitlements new file mode 100644 index 0000000..65a5b81 --- /dev/null +++ b/SonarDesk/SonarDesk.entitlements @@ -0,0 +1,3 @@ + + +com.apple.security.app-sandboxcom.apple.security.network.client diff --git a/SonarDesk/Stores/AppState.swift b/SonarDesk/Stores/AppState.swift new file mode 100644 index 0000000..c16d881 --- /dev/null +++ b/SonarDesk/Stores/AppState.swift @@ -0,0 +1,108 @@ +import Foundation +import SwiftUI + +@MainActor +final class AppState: ObservableObject { + @Published var servers: [SonarServer] = [] { didSet { saveServers() } } + @Published var selectedServerID: UUID? { didSet { UserDefaults.standard.set(selectedServerID?.uuidString, forKey: "selectedServer") } } + @Published var projects: [ProjectSnapshot] = [] + @Published var issues: [Issue] = [] + @Published var selectedProjectKey: String? + @Published var isLoading = false + @Published var errorMessage: String? + @Published var lastRefresh: Date? + @Published var serverVersion: String? + private let projectOrderKey = "projectOrder" + + var selectedServer: SonarServer? { servers.first { $0.id == selectedServerID } } + + init() { + if let data = UserDefaults.standard.data(forKey: "servers"), let decoded = try? JSONDecoder().decode([SonarServer].self, from: data) { servers = decoded } + if let raw = UserDefaults.standard.string(forKey: "selectedServer"), let id = UUID(uuidString: raw) { selectedServerID = id } + if selectedServerID == nil { selectedServerID = servers.first?.id } + } + + func client() throws -> SonarQubeClient { + guard let server = selectedServer, let token = KeychainService.token(serverID: server.id), !token.isEmpty else { throw APIError.notConfigured } + return SonarQubeClient(server: server, token: token) + } + + func refresh() async { + isLoading = true; errorMessage = nil + defer { isLoading = false } + do { + let client = try client() + async let status = client.serverStatus() + async let response = client.projects() + let (serverStatus, projectResponse) = try await (status, response) + serverVersion = serverStatus.version + var snapshots = projectResponse.components.map { ProjectSnapshot(project: $0) } + await withTaskGroup(of: (Int, [String:String], String).self) { group in + for (index, project) in projectResponse.components.enumerated() { + group.addTask { + async let m = try? client.measures(projectKey: project.key) + async let g = try? client.qualityGate(projectKey: project.key) + let (measures, gate) = await (m, g) + let dict = Dictionary(uniqueKeysWithValues: (measures?.component.measures ?? []).compactMap { x in x.value.map { (x.metric, $0) } }) + return (index, dict, gate?.projectStatus.status ?? "UNKNOWN") + } + } + for await result in group { snapshots[result.0].measures = result.1; snapshots[result.0].qualityGate = result.2 } + } + projects = applySavedProjectOrder(to: snapshots) + lastRefresh = Date() + } catch { errorMessage = error.localizedDescription } + } + + func loadIssues(projectKey: String? = nil) async { + isLoading = true; errorMessage = nil + defer { isLoading = false } + do { issues = try await client().issues(projectKey: projectKey).issues } + catch { errorMessage = error.localizedDescription } + } + + func addServer(name: String, url: String, token: String) throws { + let normalized = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard URL(string: normalized)?.scheme != nil else { throw APIError.invalidURL } + let server = SonarServer(name: name.isEmpty ? "SonarQube" : name, baseURL: normalized) + try KeychainService.saveToken(token, serverID: server.id) + servers.append(server); selectedServerID = server.id + } + + func removeServer(_ server: SonarServer) { + KeychainService.deleteToken(serverID: server.id); servers.removeAll { $0.id == server.id } + if selectedServerID == server.id { selectedServerID = servers.first?.id } + } + + + func moveProjects(from source: IndexSet, to destination: Int) { + projects.move(fromOffsets: source, toOffset: destination) + saveProjectOrder() + } + + func moveProject(_ key: String, direction: Int) { + guard let index = projects.firstIndex(where: { $0.project.key == key }) else { return } + let target = index + direction + guard projects.indices.contains(target) else { return } + projects.swapAt(index, target) + saveProjectOrder() + } + + private func applySavedProjectOrder(to snapshots: [ProjectSnapshot]) -> [ProjectSnapshot] { + let alphabetical = snapshots.sorted { $0.project.name.localizedCaseInsensitiveCompare($1.project.name) == .orderedAscending } + guard let keys = UserDefaults.standard.stringArray(forKey: projectOrderKey), !keys.isEmpty else { return alphabetical } + let positions = Dictionary(uniqueKeysWithValues: keys.enumerated().map { ($0.element, $0.offset) }) + return alphabetical.sorted { lhs, rhs in + let li = positions[lhs.project.key] ?? Int.max + let ri = positions[rhs.project.key] ?? Int.max + if li == ri { return lhs.project.name.localizedCaseInsensitiveCompare(rhs.project.name) == .orderedAscending } + return li < ri + } + } + + private func saveProjectOrder() { + UserDefaults.standard.set(projects.map { $0.project.key }, forKey: projectOrderKey) + } + + private func saveServers() { if let data = try? JSONEncoder().encode(servers) { UserDefaults.standard.set(data, forKey: "servers") } } +} diff --git a/SonarDesk/Views/Components/MetricCard.swift b/SonarDesk/Views/Components/MetricCard.swift new file mode 100644 index 0000000..e34d460 --- /dev/null +++ b/SonarDesk/Views/Components/MetricCard.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct MetricCard: View { + let title: String + let value: String + let systemImage: String + var tint: Color = .accentColor + var action: (() -> Void)? + + var body: some View { + Button { + action?() + } label: { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: systemImage) + .foregroundStyle(tint) + Text(title) + .foregroundStyle(.secondary) + Spacer() + if action != nil { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .font(.subheadline.weight(.medium)) + + Text(value) + .font(.system(size: 30, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .contentTransition(.numericText()) + } + .frame(maxWidth: .infinity, minHeight: 105, alignment: .leading) + .padding(18) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(tint.opacity(0.10)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(tint.opacity(0.22), lineWidth: 1) + } + } + .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(action == nil) + } +} + +struct GateBadge: View { + let status: String + var body: some View { + Label(status == "OK" ? "Passed" : status.capitalized, systemImage: status == "OK" ? "checkmark.circle.fill" : "xmark.circle.fill") + .font(.caption.weight(.semibold)).foregroundStyle(status == "OK" ? .green : .red) + } +} diff --git a/SonarDesk/Views/Dashboard/DashboardView.swift b/SonarDesk/Views/Dashboard/DashboardView.swift new file mode 100644 index 0000000..1f47a60 --- /dev/null +++ b/SonarDesk/Views/Dashboard/DashboardView.swift @@ -0,0 +1,113 @@ +import SwiftUI + +struct DashboardView: View { + @EnvironmentObject var app: AppState + let onNavigate: (Destination) -> Void + + @State private var projectPage = 1 + @State private var pageSize = 10 + @State private var failedOnly = false + + private var total: (String) -> Int { + { key in app.projects.reduce(0) { $0 + (Int($1.measures[key] ?? "0") ?? 0) } } + } + + private var failed: Int { app.projects.filter { $0.qualityGate != "OK" }.count } + private var visibleProjects: [ProjectSnapshot] { failedOnly ? app.projects.filter { $0.qualityGate != "OK" } : app.projects } + private var pageCount: Int { max(1, Int(ceil(Double(visibleProjects.count) / Double(pageSize)))) } + private var pagedProjects: [ProjectSnapshot] { + let safePage = min(max(projectPage, 1), pageCount) + let start = (safePage - 1) * pageSize + guard start < visibleProjects.count else { return [] } + return Array(visibleProjects[start.. pageSize { + Divider().padding(.top, 4) + HStack(spacing: 12) { + Picker("Rows", selection: $pageSize) { + Text("10").tag(10); Text("25").tag(25); Text("50").tag(50) + } + .pickerStyle(.menu).frame(width: 105) + .onChange(of: pageSize) { _, _ in projectPage = 1 } + Spacer() + Text("Page \(projectPage) of \(pageCount)").font(.caption).foregroundStyle(.secondary) + Button { projectPage = max(1, projectPage - 1) } label: { Image(systemName: "chevron.left") } + .disabled(projectPage <= 1) + Button { projectPage = min(pageCount, projectPage + 1) } label: { Image(systemName: "chevron.right") } + .disabled(projectPage >= pageCount) + }.padding(.top, 10) + } + }.padding(.horizontal, 4) + } + }.padding(24) + } + .navigationTitle("Dashboard") + .onChange(of: app.projects.count) { _, _ in projectPage = min(projectPage, pageCount) } + } +} diff --git a/SonarDesk/Views/Issues/IssuesView.swift b/SonarDesk/Views/Issues/IssuesView.swift new file mode 100644 index 0000000..ae4c895 --- /dev/null +++ b/SonarDesk/Views/Issues/IssuesView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +enum IssueFilter: String, CaseIterable, Identifiable, Hashable { + case all = "All" + case bugs = "Bugs" + case vulnerabilities = "Vulnerabilities" + case codeSmells = "Code Smells" + + var id: String { rawValue } + + func matches(_ issue: Issue) -> Bool { + 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" + } + } +} + +struct IssuesView: View { + @EnvironmentObject var app: AppState + let projectKey: String? + let initialFilter: IssueFilter + @State private var search = "" + @State private var selection: Issue.ID? + @State private var filter: IssueFilter + + init(projectKey: String?, initialFilter: IssueFilter = .all) { + self.projectKey = projectKey + self.initialFilter = initialFilter + _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) + } + } + + 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) } + } + .pickerStyle(.menu) + .frame(width: 150) + } + } + .task(id: projectKey) { await app.loadIssues(projectKey: projectKey) } + } + + 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/Projects/ProjectDetailView.swift b/SonarDesk/Views/Projects/ProjectDetailView.swift new file mode 100644 index 0000000..8905367 --- /dev/null +++ b/SonarDesk/Views/Projects/ProjectDetailView.swift @@ -0,0 +1,66 @@ +import SwiftUI + +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 } } + + var body: some View { + if let item { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(item.project.name).font(.largeTitle.bold()) + Text(item.project.key).foregroundStyle(.secondary) + } + 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)) + } + .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) + } + case .issues: + IssuesView(projectKey: projectKey, initialFilter: issueFilter) + .id("\(projectKey)-\(issueFilter.rawValue)") + } + } + } else { + ContentUnavailableView("Project unavailable", systemImage: "shippingbox") + } + } + + private func showIssues(_ filter: IssueFilter) { + issueFilter = filter + section = .issues + } +} + +private enum ProjectSection { case overview, issues } diff --git a/SonarDesk/Views/RootView.swift b/SonarDesk/Views/RootView.swift new file mode 100644 index 0000000..9ed99d3 --- /dev/null +++ b/SonarDesk/Views/RootView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct RootView: View { + @EnvironmentObject var app: AppState + @State private var selection: Destination? = .dashboard + + var body: some 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) + } + Section("Projects") { + ForEach(app.projects) { snapshot in + Label(snapshot.project.name, systemImage: "shippingbox") + .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) + } + } + .onMove { source, destination in + app.moveProjects(from: source, to: destination) + } + } + } + .navigationTitle("SonarDesk") + .safeAreaInset(edge: .bottom) { + if let server = app.selectedServer { + HStack { Circle().fill(.green).frame(width: 7, height: 7); Text(server.name).lineLimit(1); Spacer() } + .font(.caption).foregroundStyle(.secondary).padding(12) + } + } + } detail: { + Group { + switch selection { + case .dashboard: DashboardView { destination in selection = destination } + case .issues: IssuesView(projectKey: nil) + case .issuesFiltered(let filter): IssuesView(projectKey: nil, initialFilter: filter) + case .project(let key): ProjectDetailView(projectKey: key) + case .none: ContentUnavailableView("Select an item", systemImage: "sidebar.left") + } + } + } + .toolbar { + ToolbarItemGroup { + if app.isLoading { ProgressView().controlSize(.small) } + Button { Task { await app.refresh() } } label: { Label("Refresh", systemImage: "arrow.clockwise") }.keyboardShortcut("r") + } + } + .task { if app.projects.isEmpty { await app.refresh() } } + .alert("SonarQube", isPresented: Binding(get: { app.errorMessage != nil }, set: { if !$0 { app.errorMessage = nil } })) { + Button("OK", role: .cancel) { app.errorMessage = nil } + } message: { Text(app.errorMessage ?? "Unknown error") } + } +} + +enum Destination: Hashable { case dashboard, issues, issuesFiltered(IssueFilter), project(String) } diff --git a/SonarDesk/Views/Settings/SettingsView.swift b/SonarDesk/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..7f2ce2a --- /dev/null +++ b/SonarDesk/Views/Settings/SettingsView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var app: AppState + @State private var name = "" + @State private var url = "" + @State private var token = "" + @State private var message: String? + + var body: some View { + TabView { + Form { + Section("Active Server") { + Picker("Server", selection: $app.selectedServerID) { Text("None").tag(UUID?.none); ForEach(app.servers) { Text($0.name).tag(Optional($0.id)) } } + if let version = app.serverVersion { LabeledContent("Version", value: version) } + } + Section("Add SonarQube Server") { + TextField("Name", text: $name, prompt: Text("Pegasus")) + TextField("Server URL", text: $url, prompt: Text("https://sonarqube.example.com")) + SecureField("User token", text: $token) + HStack { Spacer(); Button("Add Server") { add() }.buttonStyle(.borderedProminent).disabled(url.isEmpty || token.isEmpty) } + } + if !app.servers.isEmpty { + Section("Configured Servers") { ForEach(app.servers) { server in HStack { VStack(alignment: .leading) { Text(server.name); Text(server.baseURL).font(.caption).foregroundStyle(.secondary) }; Spacer(); Button(role: .destructive) { app.removeServer(server) } label: { Image(systemName: "trash") }.buttonStyle(.borderless) } } } + } + if let message { Text(message).foregroundStyle(.secondary) } + }.padding(20).tabItem { Label("Servers", systemImage: "server.rack") } + VStack(alignment: .leading, spacing: 12) { Text("SonarDesk").font(.title.bold()); Text("Native SonarQube monitoring for macOS."); Text("Credentials are stored in the macOS Keychain. Server metadata is stored locally in UserDefaults.").foregroundStyle(.secondary); Spacer(); Text("Version 1.0.0").font(.caption).foregroundStyle(.tertiary) }.padding(24).tabItem { Label("About", systemImage: "info.circle") } + } + } + + private func add() { + do { try app.addServer(name: name, url: url, token: token); name = ""; url = ""; token = ""; message = "Server added."; Task { await app.refresh() } } + catch { message = error.localizedDescription } + } +}