Files
SonarDesk/SonarDesk/Views/Projects/ProjectDetailView.swift
T
2026-10-01 10:48:59 +01:00

303 lines
20 KiB
Swift

import SwiftUI
import AppKit
struct ProjectDetailView: View {
@EnvironmentObject var app: AppState
let projectKey: String
@State private var section: ProjectSection = .overview
@State private var scanCredentials: CreatedProjectCredentials?
@State private var credentialError: String?
@State private var generatingCredentials = false
@State private var exportingAI = false
@State private var exportingPDF = false
@State private var exportError: String?
@State private var showingAliasEditor = false
@State private var aliasText = ""
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: 4) {
HStack(spacing: 8) {
Text(app.projectDisplayName(for: item.project)).font(.system(size: 30, weight: .bold))
Button {
aliasText = app.projectAlias(for: item.project.key) ?? item.project.name
showingAliasEditor = true
} label: { Image(systemName: "pencil") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Edit SonarDesk display name")
}
Text(item.project.key).foregroundStyle(.secondary)
}
Spacer()
Menu {
Button { Task { await exportPDFReport(item) } } label: { Label("PDF Report…", systemImage: "doc.richtext") }
Button { Task { await exportAIReport(item) } } label: { Label("AI Analysis Export…", systemImage: "brain.head.profile") }
} label: {
if exportingPDF || exportingAI { ProgressView().controlSize(.small) } else { Label("Export Report…", systemImage: "square.and.arrow.up") }
}
.menuStyle(.borderlessButton)
.fixedSize()
.disabled(exportingPDF || exportingAI)
GateBadge(status: item.qualityGate)
}.padding(.horizontal, 26).padding(.top, 22).padding(.bottom, 14)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 7) {
projectNav(.overview, "square.grid.2x2.fill", .blue)
projectNav(.bugs, "ladybug.fill", .red)
projectNav(.vulnerabilities, "lock.trianglebadge.exclamationmark.fill", .orange)
projectNav(.codeSmells, "exclamationmark.bubble.fill", .yellow)
projectNav(.securityHotspots, "shield.lefthalf.filled.badge.checkmark", .purple)
projectNav(.coverage, "chart.bar.fill", .green)
projectNav(.duplication, "square.on.square.fill", .purple)
projectNav(.lines, "chevron.left.forwardslash.chevron.right", .blue)
projectNav(.qualityGate, "checkmark.seal.fill", .green)
projectNav(.measures, "chart.bar.xaxis", .cyan)
projectNav(.scanDetails, "key.fill", .indigo)
}.padding(.horizontal, 26).padding(.bottom, 14)
}
Divider()
switch section {
case .overview: overview(item)
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 .securityHotspots: metricDetail("Security Hotspots", item.measures["security_hotspots"] ?? "0", "shield.lefthalf.filled.badge.checkmark", .purple, "Security-sensitive code requiring review in SonarQube.")
case .coverage: metricDetail("Coverage", (item.measures["coverage"] ?? "0") + "%", "chart.bar.fill", .green, "Test coverage reported by SonarQube for this project.")
case .duplication: metricDetail("Duplication", (item.measures["duplicated_lines_density"] ?? "0") + "%", "square.on.square.fill", .purple, "Duplicated lines density reported by SonarQube.")
case .lines: metricDetail("Lines of Code", item.measures["ncloc"] ?? "0", "chevron.left.forwardslash.chevron.right", .blue, "Non-comment lines of code measured by SonarQube.")
case .qualityGate: metricDetail("Quality Gate", item.qualityGate == "OK" ? "Passed" : item.qualityGate.capitalized, item.qualityGate == "OK" ? "checkmark.seal.fill" : "xmark.seal.fill", item.qualityGate == "OK" ? .green : .red, "Current quality-gate result for this project.")
case .measures: measures(item)
case .scanDetails: scanDetails(item)
}
}
.sheet(isPresented: $showingAliasEditor) { aliasEditor }
} else { ContentUnavailableView("Project unavailable", systemImage: "shippingbox") }
}
private var aliasEditor: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Project Display Name").font(.title2.bold())
Text("This alias is stored by SonarDesk only. It does not rename the SonarQube project or change its key.")
.foregroundStyle(.secondary)
TextField("Display name", text: $aliasText).textFieldStyle(.roundedBorder)
HStack {
if app.projectAlias(for: projectKey) != nil {
Button("Reset to SonarQube Name") { app.resetProjectAlias(for: projectKey); showingAliasEditor = false }
}
Spacer()
Button("Cancel") { showingAliasEditor = false }
Button("Save") { app.setProjectAlias(aliasText, for: projectKey); showingAliasEditor = false }
.buttonStyle(.borderedProminent)
.disabled(aliasText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}.padding(24).frame(width: 460)
}
private func overview(_ item: ProjectSnapshot) -> some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 14)], spacing: 14) {
MetricCard(title: "Bugs", value: item.measures["bugs"] ?? "0", systemImage: "ladybug.fill", tint: .red) { section = .bugs }
MetricCard(title: "Vulnerabilities", value: item.measures["vulnerabilities"] ?? "0", systemImage: "lock.trianglebadge.exclamationmark.fill", tint: .orange) { section = .vulnerabilities }
MetricCard(title: "Code Smells", value: item.measures["code_smells"] ?? "0", systemImage: "exclamationmark.bubble.fill", tint: .yellow) { section = .codeSmells }
MetricCard(title: "Security Hotspots", value: item.measures["security_hotspots"] ?? "0", systemImage: "shield.lefthalf.filled.badge.checkmark", tint: .purple) { section = .securityHotspots }
MetricCard(title: "Coverage", value: (item.measures["coverage"] ?? "0") + "%", systemImage: "chart.bar.fill", tint: .green) { section = .coverage }
MetricCard(title: "Duplication", value: (item.measures["duplicated_lines_density"] ?? "0") + "%", systemImage: "square.on.square.fill", tint: .purple) { section = .duplication }
MetricCard(title: "Lines of Code", value: item.measures["ncloc"] ?? "0", systemImage: "chevron.left.forwardslash.chevron.right", tint: .blue) { section = .lines }
MetricCard(title: "Quality Gate", value: item.qualityGate == "OK" ? "Passed" : item.qualityGate.capitalized, systemImage: "checkmark.seal.fill", tint: item.qualityGate == "OK" ? .green : .red) { section = .qualityGate }
MetricCard(title: "Measures", value: "\(item.measures.count)", systemImage: "chart.bar.xaxis", tint: .cyan) { section = .measures }
}.padding(26)
}
}
private func projectNav(_ target: ProjectSection, _ icon: String, _ tint: Color) -> some View {
Button { section = target } label: {
Label(target.title, systemImage: icon).font(.subheadline.weight(.semibold)).foregroundStyle(section == target ? Color.white : tint)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(section == target ? tint : tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 8))
}.buttonStyle(.plain)
}
private func metricDetail(_ title: String, _ value: String, _ icon: String, _ tint: Color, _ description: String) -> some View {
VStack(spacing: 20) { Image(systemName: icon).font(.system(size: 48)).foregroundStyle(tint); Text(value).font(.system(size: 62, weight: .bold, design: .rounded)); Text(title).font(.title2.bold()); Text(description).foregroundStyle(.secondary) }
.frame(maxWidth: .infinity, maxHeight: .infinity).background(LinearGradient(colors: [tint.opacity(0.07), .clear], startPoint: .top, endPoint: .center))
}
private func measures(_ item: ProjectSnapshot) -> some View {
ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) { ForEach(item.measures.keys.sorted(), id: \.self) { key in MetricCard(title: key.replacingOccurrences(of: "_", with: " ").capitalized, value: item.measures[key] ?? "—", systemImage: "chart.bar.xaxis", tint: .cyan) } }.padding(26) }
}
private func scanDetails(_ item: ProjectSnapshot) -> some View {
let credentials = scanCredentials ?? app.storedProjectToken(projectKey: projectKey).map {
CreatedProjectCredentials(projectKey: item.project.key, projectName: item.project.name, tokenName: "Stored token", token: $0, serverURL: app.selectedServer?.baseURL ?? "")
}
return ScrollView {
VStack(alignment: .leading, spacing: 18) {
Text("Scan Details").font(.title2.bold())
Text("Everything required to analyse this project with SonarScanner. Tokens are stored in Keychain and are never written into scan.sh.").foregroundStyle(.secondary)
detailRow("Project name", item.project.name)
detailRow("Project key", item.project.key)
detailRow("Server", app.selectedServer?.baseURL ?? "—")
if let credentials { secretRow(credentials.token) }
else { Text("No analysis token is stored for this project on this Mac. Generate one below.").foregroundStyle(.secondary) }
codeBox("sonar-project.properties", properties(item))
codeBox("scripts/scan.sh", scanScript(item))
HStack {
Button { saveText(scanScript(item), name: "scan.sh", executable: true) } label: { Label("Save scan.sh…", systemImage: "square.and.arrow.down") }.buttonStyle(.borderedProminent)
Button { saveText(properties(item) + "\n", name: "sonar-project.properties", executable: false) } label: { Label("Save sonar-project.properties…", systemImage: "doc.badge.plus") }.buttonStyle(.bordered)
}
VStack(alignment: .leading, spacing: 8) {
Text("How to run the scan").font(.headline)
Text("1. Save scan.sh into your project's scripts directory.\n2. Open Terminal in the project directory.\n3. Run: chmod +x scripts/scan.sh\n4. Set the token: export SONAR_TOKEN=\"YOUR_ANALYSIS_TOKEN\"\n5. Run: ./scripts/scan.sh\n6. Return to SonarDesk and refresh this project.")
.font(.system(.body, design: .monospaced)).textSelection(.enabled)
}.padding(14).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10))
if let credentialError { Text(credentialError).foregroundStyle(.red).textSelection(.enabled) }
if let exportError { Text(exportError).foregroundStyle(.red).textSelection(.enabled) }
Button { Task { await generateCredentials(item) } } label: {
if generatingCredentials { ProgressView().controlSize(.small) } else { Label("Generate New Analysis Token", systemImage: "key.fill") }
}.buttonStyle(.borderedProminent).disabled(generatingCredentials)
}.padding(26).frame(maxWidth: 950, alignment: .leading)
}
}
private func properties(_ item: ProjectSnapshot) -> String {
"sonar.projectKey=\(item.project.key)\nsonar.projectName=\(item.project.name)\nsonar.sources=.\nsonar.host.url=\(app.selectedServer?.baseURL ?? "")"
}
private func scanScript(_ item: ProjectSnapshot) -> String {
let key = bashEscape(item.project.key), name = bashEscape(item.project.name), url = bashEscape(app.selectedServer?.baseURL ?? "")
return """
#!/usr/bin/env bash
set -euo pipefail
PROJECT_KEY="\(key)"
PROJECT_NAME="\(name)"
SONAR_URL="\(url)"
: "${SONAR_TOKEN:?SONAR_TOKEN is not set}"
echo "Scanning: ${PROJECT_NAME}"
echo "Project key: ${PROJECT_KEY}"
echo "SonarQube: ${SONAR_URL}"
echo
sonar-scanner \\
-Dsonar.projectKey="${PROJECT_KEY}" \\
-Dsonar.projectName="${PROJECT_NAME}" \\
-Dsonar.sources=. \\
-Dsonar.host.url="${SONAR_URL}" \\
-Dsonar.token="${SONAR_TOKEN}" \\
-Dsonar.exclusions="vendor/**,node_modules/**,storage/**,bootstrap/cache/**,public/build/**,.scannerwork/**"
"""
}
private func codeBox(_ title: String, _ text: String) -> some View {
VStack(alignment: .leading, spacing: 7) {
HStack { Text(title).font(.headline); Spacer(); Button("Copy") { copy(text) } }
Text(text).font(.system(.body, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
}.padding(14).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10))
}
private func bashEscape(_ value: String) -> String { value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "$", with: "\\$").replacingOccurrences(of: "`", with: "\\`") }
private func copy(_ text: String) { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(text, forType: .string) }
private func saveText(_ text: String, name: String, executable: Bool) {
let panel = NSSavePanel(); panel.nameFieldStringValue = name; panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
do { try text.write(to: url, atomically: true, encoding: .utf8); if executable { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) } }
catch { credentialError = error.localizedDescription }
}
@MainActor private func exportPDFReport(_ item: ProjectSnapshot) async {
exportingPDF = true; exportError = nil
defer { exportingPDF = false }
do {
let issues = try await app.allIssues(projectKey: item.project.key)
try ProjectPDFReportExporter.export(
project: item,
issues: issues,
serverURL: app.selectedServer?.baseURL ?? "Unknown",
serverVersion: app.serverVersion
)
} catch { exportError = error.localizedDescription }
}
@MainActor private func exportAIReport(_ item: ProjectSnapshot) async {
exportingAI = true; exportError = nil
defer { exportingAI = false }
do {
let issues = try await app.allIssues(projectKey: item.project.key)
let report = aiReport(item, issues: issues)
let panel = NSSavePanel(); panel.nameFieldStringValue = "\(item.project.key)-sonarqube-ai-analysis.md"; panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
try report.write(to: url, atomically: true, encoding: .utf8)
} catch { exportError = error.localizedDescription }
}
private func aiReport(_ item: ProjectSnapshot, issues: [Issue]) -> String {
let sorted = issues.sorted { ($0.severity ?? "") > ($1.severity ?? "") }
var out = """
# SonarQube AI Analysis Report
## AI Analysis Instructions
Analyse this SonarQube report as a senior software engineer. Prioritise bugs and reliability problems, security vulnerabilities and hotspots, high-severity code smells, maintainability problems, duplicated code, complexity, and test coverage gaps. For each finding explain the problem and impact, identify the affected file and line where available, recommend a concrete fix, and provide corrected code where enough context is available. Group related findings and avoid changing application behaviour unnecessarily.
## Project
- Name: \(item.project.name)
- Key: \(item.project.key)
- Server: \(app.selectedServer?.baseURL ?? "Unknown")
- Quality Gate: \(item.qualityGate)
- Last analysis: \(item.project.lastAnalysisDate ?? "Unknown")
- Exported: \(ISO8601DateFormatter().string(from: Date()))
## Measures
"""
for key in item.measures.keys.sorted() { out += "\n- \(key): \(item.measures[key] ?? "—")" }
out += "\n\n## Issues (\(issues.count))\n"
if sorted.isEmpty { out += "\nNo unresolved issues were returned by SonarQube.\n" }
for (index, issue) in sorted.enumerated() {
out += """
### \(index + 1). \(issue.message ?? "Issue")
- Type: \(issue.type ?? "Unknown")
- Severity: \(issue.severity ?? "Unknown")
- Rule: \(issue.rule ?? "Unknown")
- Component: \(issue.component ?? "Unknown")
- Line: \(issue.line.map(String.init) ?? "Unknown")
- Status: \(issue.status ?? "Unknown")
- Effort: \(issue.effort ?? "Unknown")
- Created: \(issue.creationDate ?? "Unknown")
- SonarQube issue key: \(issue.key)
"""
}
return out + "\n"
}
private func detailRow(_ label: String, _ value: String) -> some View {
HStack(alignment: .firstTextBaseline) { Text(label).foregroundStyle(.secondary).frame(width: 120, alignment: .leading); Text(value).textSelection(.enabled); Spacer() }
}
private func secretRow(_ token: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Analysis token").font(.headline)
HStack { Text(token).font(.system(.body, design: .monospaced)).textSelection(.enabled); Spacer(); Button("Copy") { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(token, forType: .string) } }
}.padding(14).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10))
}
@MainActor private func generateCredentials(_ item: ProjectSnapshot) async {
generatingCredentials = true; credentialError = nil
defer { generatingCredentials = false }
do { scanCredentials = try await app.generateScanCredentials(projectKey: item.project.key, projectName: item.project.name) }
catch { credentialError = error.localizedDescription }
}
}
private enum ProjectSection: Hashable {
case overview, bugs, vulnerabilities, codeSmells, securityHotspots, coverage, duplication, lines, qualityGate, measures, scanDetails
var title: String { switch self { case .overview:"Overview"; case .bugs:"Bugs"; case .vulnerabilities:"Vulnerabilities"; case .codeSmells:"Code Smells"; case .securityHotspots:"Hotspots"; case .coverage:"Coverage"; case .duplication:"Duplication"; case .lines:"Lines"; case .qualityGate:"Quality Gate"; case .measures:"Measures"; case .scanDetails:"Scan Details" } }
}