221 lines
7.7 KiB
Swift
221 lines
7.7 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
// MARK: - Build Record
|
|
|
|
struct BuildRecord: Identifiable, Codable {
|
|
let id: UUID
|
|
let projectName: String
|
|
let scheme: String
|
|
let date: Date
|
|
let duration: TimeInterval
|
|
let action: String
|
|
let configuration: String
|
|
let destination: String
|
|
let outcome: Outcome
|
|
let errorCount: Int
|
|
let warningCount: Int
|
|
let logSnippet: String
|
|
|
|
enum Outcome: String, Codable {
|
|
case success, failure, cancelled
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .success: return "checkmark.circle.fill"
|
|
case .failure: return "xmark.circle.fill"
|
|
case .cancelled: return "stop.circle.fill"
|
|
}
|
|
}
|
|
var color: Color {
|
|
switch self {
|
|
case .success: return .green
|
|
case .failure: return .red
|
|
case .cancelled: return .secondary
|
|
}
|
|
}
|
|
}
|
|
|
|
var formattedDuration: String {
|
|
if duration < 60 { return String(format: "%.1fs", duration) }
|
|
let m = Int(duration / 60); let s = Int(duration) % 60
|
|
return "\(m)m \(s)s"
|
|
}
|
|
|
|
var formattedDate: String {
|
|
let f = RelativeDateTimeFormatter()
|
|
f.unitsStyle = .abbreviated
|
|
return f.localizedString(for: date, relativeTo: Date())
|
|
}
|
|
}
|
|
|
|
// MARK: - Store
|
|
|
|
@MainActor
|
|
final class BuildHistoryStore: ObservableObject {
|
|
@Published var records: [BuildRecord] = []
|
|
private let maxRecords = 50
|
|
private let key = "padxcode.buildHistory"
|
|
|
|
init() { load() }
|
|
|
|
func append(_ record: BuildRecord) {
|
|
records.insert(record, at: 0)
|
|
if records.count > maxRecords { records = Array(records.prefix(maxRecords)) }
|
|
persist()
|
|
}
|
|
|
|
func clear() { records = []; persist() }
|
|
|
|
func delete(id: UUID) { records.removeAll { $0.id == id }; persist() }
|
|
|
|
private func persist() {
|
|
if let d = try? JSONEncoder().encode(records) {
|
|
UserDefaults.standard.set(d, forKey: key)
|
|
}
|
|
}
|
|
|
|
private func load() {
|
|
guard let d = UserDefaults.standard.data(forKey: key),
|
|
let v = try? JSONDecoder().decode([BuildRecord].self, from: d) else { return }
|
|
records = v
|
|
}
|
|
}
|
|
|
|
// MARK: - Views (defined exactly once)
|
|
|
|
struct BuildHistoryView: View {
|
|
@ObservedObject var historyStore: BuildHistoryStore
|
|
@State private var showClearAlert = false
|
|
@State private var selectedRecord: BuildRecord?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if historyStore.records.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Build History",
|
|
systemImage: "clock.badge.xmark",
|
|
description: Text("Successful and failed builds appear here.")
|
|
)
|
|
} else {
|
|
List {
|
|
ForEach(historyStore.records) { record in
|
|
BuildRecordRow(record: record)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { selectedRecord = record }
|
|
.swipeActions {
|
|
Button(role: .destructive) {
|
|
historyStore.delete(id: record.id)
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
}
|
|
.navigationTitle("Build History")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button(role: .destructive) { showClearAlert = true } label: {
|
|
Image(systemName: "trash")
|
|
}
|
|
.disabled(historyStore.records.isEmpty)
|
|
}
|
|
}
|
|
.alert("Clear Build History?", isPresented: $showClearAlert) {
|
|
Button("Clear All", role: .destructive) { historyStore.clear() }
|
|
Button("Cancel", role: .cancel) {}
|
|
}
|
|
.sheet(item: $selectedRecord) { record in
|
|
BuildRecordDetailView(record: record)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BuildRecordRow: View {
|
|
let record: BuildRecord
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: record.outcome.icon)
|
|
.foregroundStyle(record.outcome.color)
|
|
.font(.title3)
|
|
.frame(width: 28)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack {
|
|
Text(record.scheme).font(.subheadline.bold())
|
|
Text("·").foregroundStyle(.tertiary)
|
|
Text(record.configuration).font(.subheadline).foregroundStyle(.secondary)
|
|
}
|
|
HStack(spacing: 6) {
|
|
Text(record.destination).font(.caption).foregroundStyle(.secondary)
|
|
Text("·").foregroundStyle(.tertiary)
|
|
Text(record.formattedDuration).font(.caption).foregroundStyle(.secondary)
|
|
if record.errorCount > 0 {
|
|
Text("·").foregroundStyle(.tertiary)
|
|
Text("\(record.errorCount) error(s)").font(.caption).foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
Spacer()
|
|
Text(record.formattedDate).font(.caption2).foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
struct BuildRecordDetailView: View {
|
|
let record: BuildRecord
|
|
@Environment(\.dismiss) var dismiss
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section("Result") {
|
|
LabeledContent("Outcome") {
|
|
Label(record.outcome.rawValue.capitalized,
|
|
systemImage: record.outcome.icon)
|
|
.foregroundStyle(record.outcome.color)
|
|
}
|
|
LabeledContent("Duration", value: record.formattedDuration)
|
|
LabeledContent("Date", value: record.date.formatted())
|
|
}
|
|
Section("Project") {
|
|
LabeledContent("Project", value: record.projectName)
|
|
LabeledContent("Scheme", value: record.scheme)
|
|
LabeledContent("Configuration", value: record.configuration)
|
|
LabeledContent("Destination", value: record.destination)
|
|
}
|
|
if record.errorCount > 0 || record.warningCount > 0 {
|
|
Section("Diagnostics") {
|
|
LabeledContent("Errors", value: "\(record.errorCount)")
|
|
LabeledContent("Warnings", value: "\(record.warningCount)")
|
|
}
|
|
}
|
|
Section("Log Tail") {
|
|
ScrollView {
|
|
Text(record.logSnippet)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.frame(maxHeight: 300)
|
|
}
|
|
}
|
|
.navigationTitle("Build Detail")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.large])
|
|
}
|
|
}
|