Initial Commit

This commit is contained in:
Mark Gregory
2026-09-29 10:04:58 +01:00
commit 6305f580ca
21 changed files with 785 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+28
View File
@@ -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.
+24
View File
@@ -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 = "<group>";};
A4 = {isa = PBXGroup; children = (A7,); name = Products; sourceTree = "<group>";};
A6 = {isa = PBXFileSystemSynchronizedRootGroup; path = SonarDesk; sourceTree = "<group>";};
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;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>SonarDesk.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
+22
View File
@@ -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)
}
}
}
@@ -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}}
@@ -0,0 +1 @@
{"images":[],"info":{"author":"xcode","version":1}}
+1
View File
@@ -0,0 +1 @@
{"info":{"author":"xcode","version":1}}
+63
View File
@@ -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"
}
+44
View File
@@ -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 }
}
+68
View File
@@ -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<T: Decodable>(_ 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."
}
}
}
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>com.apple.security.app-sandbox</key><true/><key>com.apple.security.network.client</key><true/></dict></plist>
+108
View File
@@ -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") } }
}
@@ -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)
}
}
@@ -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..<min(start + pageSize, visibleProjects.count)])
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Dashboard").font(.largeTitle.bold())
Text(app.selectedServer?.baseURL ?? "No server configured").foregroundStyle(.secondary)
}
Spacer()
if let date = app.lastRefresh {
Text("Updated \(date, style: .relative) ago").font(.caption).foregroundStyle(.secondary)
}
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), spacing: 12) {
MetricCard(title: "Projects", value: "\(app.projects.count)", systemImage: "shippingbox.fill", tint: .blue) {
failedOnly = false; projectPage = 1
}
MetricCard(title: "Failed Gates", value: "\(failed)", systemImage: failed == 0 ? "checkmark.seal.fill" : "xmark.seal.fill", tint: failed == 0 ? .green : .red) {
failedOnly = true; projectPage = 1
}
MetricCard(title: "Bugs", value: "\(total("bugs"))", systemImage: "ladybug.fill", tint: .red) {
onNavigate(.issuesFiltered(.bugs))
}
MetricCard(title: "Vulnerabilities", value: "\(total("vulnerabilities"))", systemImage: "lock.trianglebadge.exclamationmark.fill", tint: .orange) {
onNavigate(.issuesFiltered(.vulnerabilities))
}
}
GroupBox {
VStack(spacing: 0) {
HStack {
Text(failedOnly ? "Projects — Failed Quality Gates" : "Projects").font(.headline)
if failedOnly {
Button("Show All") { failedOnly = false; projectPage = 1 }.buttonStyle(.link)
}
Spacer()
Text("\(visibleProjects.count) project\(visibleProjects.count == 1 ? "" : "s")")
.font(.caption).foregroundStyle(.secondary)
}.padding(.bottom, 6)
ForEach(Array(pagedProjects.enumerated()), id: \.element.id) { index, item in
Button { onNavigate(.project(item.project.key)) } label: {
HStack(spacing: 14) {
Image(systemName: "shippingbox.fill").foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text(item.project.name).fontWeight(.medium).foregroundStyle(.primary)
Text(item.project.key).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text("\(item.measures["code_smells"] ?? "0") smells").foregroundStyle(.secondary)
GateBadge(status: item.qualityGate)
Image(systemName: "chevron.right").font(.caption.weight(.semibold)).foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
.padding(.vertical, 10)
}.buttonStyle(.plain)
if index < pagedProjects.count - 1 { Divider() }
}
if visibleProjects.isEmpty {
ContentUnavailableView("No failed quality gates", systemImage: "checkmark.seal.fill", description: Text("All projects currently pass their quality gate."))
.frame(maxWidth: .infinity).padding(.vertical, 24)
}
if visibleProjects.count > 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) }
}
}
+68
View File
@@ -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 }
}
@@ -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 }
+61
View File
@@ -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) }
@@ -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 }
}
}