303 lines
20 KiB
Swift
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" } }
|
|
}
|