krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.8.2: DomainDig/WatchlistView.swift · raw

  1import SwiftUI
  2
  3struct WatchlistView: View {
  4    @Environment(\.appDensity) private var appDensity
  5    @Bindable var viewModel: DomainViewModel
  6    @Environment(\.dismiss) private var dismiss
  7    @State private var purchaseService = PurchaseService.shared
  8    @State private var showWorkflowAddSheet = false
  9    @State private var showAddDomainSheet = false
 10    @State private var newTrackedDomain = ""
 11    @State private var addDomainError: String?
 12    @FocusState private var isAddDomainFieldFocused: Bool
 13    @State private var showSavedViewsSheet = false
 14    @State private var showSaveViewPrompt = false
 15    @State private var newSavedViewName = ""
 16
 17    private var pinnedDomains: [TrackedDomain] {
 18        viewModel.filteredTrackedDomains.filter(\.isPinned)
 19    }
 20
 21    private var otherDomains: [TrackedDomain] {
 22        viewModel.filteredTrackedDomains.filter { !$0.isPinned }
 23    }
 24
 25    var body: some View {
 26        let _ = purchaseService.currentTier
 27
 28        List {
 29            if !viewModel.allWatchlistTags.isEmpty {
 30                Section {
 31                    TagFilterChipRowView(tags: viewModel.allWatchlistTags, selection: $viewModel.watchlistTagFilter)
 32                }
 33                .listRowBackground(Color.clear)
 34                .listRowInsets(EdgeInsets())
 35            }
 36
 37            if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
 38                Section("Refresh Progress") {
 39                    VStack(alignment: .leading, spacing: 8) {
 40                        ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
 41                            .tint(.cyan)
 42                        HStack {
 43                            Text(viewModel.batchProgressLabel)
 44                                .font(appDensity.font(.caption))
 45                                .foregroundStyle(.secondary)
 46                            Spacer()
 47                            if viewModel.batchLookupRunning {
 48                                Button("Cancel") {
 49                                    viewModel.cancelBatchLookup()
 50                                }
 51                                .buttonStyle(.bordered)
 52                                .font(appDensity.font(.caption2))
 53                            }
 54                        }
 55
 56                        ForEach(viewModel.batchResults.prefix(5)) { result in
 57                            BatchResultRowView(result: result)
 58                        }
 59                    }
 60                    .padding(.vertical, 4)
 61                }
 62                .listRowBackground(Color(.systemGray6).opacity(0.5))
 63            }
 64
 65            if viewModel.filteredTrackedDomains.isEmpty {
 66                Section {
 67                    EmptyStateCardView(
 68                        title: "No Tracked Domains",
 69                        message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.",
 70                        suggestion: "Run an inspection and use the Track action on a domain you care about.",
 71                        systemImage: "eye",
 72                        showsCardBackground: false
 73                    )
 74                }
 75                .listRowBackground(Color(.systemGray6).opacity(0.5))
 76            } else {
 77                if let limitMessage = FeatureAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
 78                    Section {
 79                        Text(limitMessage)
 80                            .font(appDensity.font(.caption))
 81                            .foregroundStyle(.secondary)
 82                    }
 83                    .listRowBackground(Color(.systemGray6).opacity(0.5))
 84                }
 85
 86                if !pinnedDomains.isEmpty {
 87                    trackedSection(title: "Pinned", domains: pinnedDomains)
 88                }
 89
 90                if !otherDomains.isEmpty {
 91                    trackedSection(title: pinnedDomains.isEmpty ? "Tracked Domains" : "Others", domains: otherDomains)
 92                }
 93            }
 94        }
 95        .animation(.easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id))
 96        .scrollContentBackground(.hidden)
 97        .background(Color.black)
 98        .navigationTitle("Watchlist")
 99        .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains")
100        .toolbar {
101            ToolbarItemGroup(placement: .topBarTrailing) {
102                Button {
103                    addDomainError = nil
104                    newTrackedDomain = ""
105                    showAddDomainSheet = true
106                } label: {
107                    Image(systemName: "plus")
108                }
109
110                if !viewModel.filteredTrackedDomains.isEmpty {
111                    Menu {
112                        Picker("Filter", selection: $viewModel.watchlistFilter) {
113                            ForEach(WatchlistFilterOption.allCases) { option in
114                                Text(option.title).tag(option)
115                            }
116                        }
117
118                        Picker("Sort", selection: $viewModel.watchlistSortOption) {
119                            ForEach(WatchlistSortOption.allCases) { option in
120                                Text(option.title).tag(option)
121                            }
122                        }
123
124                        Button("Save Current View…") {
125                            newSavedViewName = ""
126                            showSaveViewPrompt = true
127                        }
128
129                        if !viewModel.watchlistSavedViews.isEmpty {
130                            Button("Saved Views") {
131                                showSavedViewsSheet = true
132                            }
133                        }
134
135                        Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
136                            AppHaptics.refresh()
137                            viewModel.refreshAllTrackedDomains()
138                        }
139                        .disabled(viewModel.batchLookupRunning)
140
141                        Button("Add to Workflow") {
142                            showWorkflowAddSheet = true
143                        }
144
145                        if viewModel.trackedDomains.count >= 2 {
146                            NavigationLink("Compare Domains") {
147                                DomainCompareView(viewModel: viewModel)
148                            }
149                        }
150
151                        Button("Export TXT") {
152                            shareTrackedDomains(format: .text)
153                        }
154
155                        if FeatureAccessService.hasAccess(to: .advancedExports) {
156                            Button("Export CSV") {
157                                shareTrackedDomains(format: .csv)
158                            }
159
160                            Button("Export JSON") {
161                                shareTrackedDomains(format: .json)
162                            }
163
164                            Button("Export Markdown") {
165                                shareTrackedDomains(format: .markdown)
166                            }
167
168                            Button("Export PDF") {
169                                shareTrackedDomains(format: .pdf)
170                            }
171                        } else {
172                            Button("CSV Export • Available in Pro") {}
173                                .disabled(true)
174                            Button("JSON Export • Available in Pro") {}
175                                .disabled(true)
176                            Button("Markdown Export • Available in Pro") {}
177                                .disabled(true)
178                            Button("PDF Export • Available in Pro") {}
179                                .disabled(true)
180                        }
181                    } label: {
182                        Image(systemName: "line.3.horizontal.decrease.circle")
183                    }
184
185                    EditButton()
186                }
187            }
188        }
189        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
190            dismiss()
191        }
192        .sheet(item: batchSummaryBinding) { summary in
193            BatchSweepSummaryView(viewModel: viewModel, summary: summary)
194        }
195        .sheet(isPresented: $showAddDomainSheet) {
196            NavigationStack {
197                Form {
198                    Section("Domain") {
199                        TextField("example.com", text: $newTrackedDomain)
200                            .textInputAutocapitalization(.never)
201                            .autocorrectionDisabled()
202                            .keyboardType(.URL)
203                            .textContentType(.URL)
204                            .focused($isAddDomainFieldFocused)
205                            .onSubmit(addTrackedDomain)
206                    }
207
208                    Section {
209                        Text("Adds the domain directly to your watchlist so monitoring can run without a prior inspection.")
210                            .font(appDensity.font(.caption))
211                            .foregroundStyle(.secondary)
212                    }
213
214                    if let addDomainError {
215                        Section {
216                            Text(addDomainError)
217                                .font(appDensity.font(.caption))
218                                .foregroundStyle(.red)
219                        }
220                    }
221                }
222                .navigationTitle("Add Domain")
223                .toolbar {
224                    ToolbarItem(placement: .cancellationAction) {
225                        Button("Cancel") {
226                            showAddDomainSheet = false
227                        }
228                    }
229
230                    ToolbarItem(placement: .confirmationAction) {
231                        Button("Add", action: addTrackedDomain)
232                    }
233                }
234                .onAppear {
235                    DispatchQueue.main.async {
236                        isAddDomainFieldFocused = true
237                    }
238                }
239            }
240        }
241        .sheet(isPresented: $showWorkflowAddSheet) {
242            WorkflowBulkAddSheet(
243                viewModel: viewModel,
244                title: "Add Watchlist Domains",
245                availableDomains: viewModel.filteredTrackedDomains.map(\.domain)
246            )
247        }
248        .alert("Save Current View", isPresented: $showSaveViewPrompt) {
249            TextField("View name", text: $newSavedViewName)
250            Button("Save") {
251                viewModel.saveCurrentWatchlistView(name: newSavedViewName)
252            }
253            Button("Cancel", role: .cancel) {}
254        } message: {
255            Text("Saves the current tag, filter, and sort as a reusable preset.")
256        }
257        .sheet(isPresented: $showSavedViewsSheet) {
258            NavigationStack {
259                List {
260                    ForEach(viewModel.watchlistSavedViews) { view in
261                        Button {
262                            viewModel.applyWatchlistSavedView(view)
263                            showSavedViewsSheet = false
264                        } label: {
265                            VStack(alignment: .leading, spacing: 2) {
266                                Text(view.name)
267                                    .foregroundStyle(.primary)
268                                Text([view.tag, view.filter.title, view.sort.title].compactMap { $0 }.joined(separator: ""))
269                                    .font(.caption)
270                                    .foregroundStyle(.secondary)
271                            }
272                        }
273                    }
274                    .onDelete { offsets in
275                        viewModel.deleteWatchlistSavedViews(at: offsets)
276                    }
277                }
278                .navigationTitle("Saved Views")
279                .toolbar {
280                    ToolbarItem(placement: .cancellationAction) {
281                        Button("Done") {
282                            showSavedViewsSheet = false
283                        }
284                    }
285                    ToolbarItem(placement: .topBarTrailing) {
286                        EditButton()
287                    }
288                }
289            }
290        }
291        .preferredColorScheme(.dark)
292    }
293
294    @ViewBuilder
295    private func trackedSection(title: String, domains: [TrackedDomain]) -> some View {
296        Section(title) {
297            ForEach(domains) { trackedDomain in
298                trackedDomainRow(trackedDomain)
299            }
300        }
301    }
302
303    private func trackedDomainRow(_ trackedDomain: TrackedDomain) -> some View {
304        NavigationLink {
305            TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
306        } label: {
307            WatchlistRowView(
308                trackedDomain: trackedDomain,
309                isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
310            )
311        }
312        .buttonStyle(.plain)
313        .swipeActions(edge: .leading, allowsFullSwipe: false) {
314            Button {
315                AppHaptics.refresh()
316                viewModel.refreshTrackedDomain(trackedDomain)
317            } label: {
318                Label("Refresh", systemImage: "arrow.clockwise")
319            }
320            .tint(.cyan)
321
322            Button {
323                viewModel.togglePinned(for: trackedDomain)
324            } label: {
325                Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
326            }
327            .tint(.yellow)
328        }
329        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
330            Button {
331                AppHaptics.refresh()
332                viewModel.refreshTrackedDomain(trackedDomain)
333            } label: {
334                Label("Refresh", systemImage: "arrow.clockwise")
335            }
336            .tint(.cyan)
337
338            if viewModel.canDelete(trackedDomain) {
339                Button(role: .destructive) {
340                    viewModel.deleteTrackedDomain(trackedDomain)
341                } label: {
342                    Label("Delete", systemImage: "trash")
343                }
344            }
345        }
346        .contextMenu {
347            Button {
348                AppHaptics.refresh()
349                viewModel.refreshTrackedDomain(trackedDomain)
350            } label: {
351                Label("Refresh", systemImage: "arrow.clockwise")
352            }
353
354            Button {
355                dismiss()
356                viewModel.rerunInspection(for: trackedDomain)
357            } label: {
358                Label("Open Inspection", systemImage: "magnifyingglass")
359            }
360
361            Button {
362                viewModel.togglePinned(for: trackedDomain)
363            } label: {
364                Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
365            }
366            .disabled(!viewModel.canEdit(trackedDomain))
367
368            Button {
369                // The system sharing UI manages participants and permissions.
370            } label: {
371                Label(trackedDomain.collaboration?.isShared == true ? "Shared" : "Private", systemImage: "person.2")
372            }
373            .disabled(true)
374
375            if viewModel.canDelete(trackedDomain) {
376                Button(role: .destructive) {
377                    viewModel.deleteTrackedDomain(trackedDomain)
378                } label: {
379                    Label("Delete", systemImage: "trash")
380                }
381            }
382        }
383        .listRowBackground(Color(.systemGray6).opacity(0.5))
384    }
385
386    private var batchSummaryBinding: Binding<BatchSweepSummary?> {
387        Binding(
388            get: { viewModel.latestBatchSweepSummary },
389            set: { viewModel.latestBatchSweepSummary = $0 }
390        )
391    }
392
393    private func deleteFilteredTrackedDomains(at offsets: IndexSet) {
394        let domains = offsets.map { viewModel.filteredTrackedDomains[$0] }
395        domains.forEach(viewModel.deleteTrackedDomain)
396    }
397
398    private func shareTrackedDomains(format: DomainExportFormat) {
399        let formatter = DateFormatter()
400        formatter.dateFormat = "yyyyMMdd_HHmmss"
401        let timestamp = formatter.string(from: Date())
402        let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)"
403        guard let data = viewModel.exportTrackedDomainsData(domains: viewModel.filteredTrackedDomains, format: format) else {
404            return
405        }
406
407        ExportPresenter.share(filename: filename, data: data)
408    }
409
410    private func addTrackedDomain() {
411        let draft = newTrackedDomain.trimmingCharacters(in: .whitespacesAndNewlines)
412        guard !draft.isEmpty else {
413            addDomainError = "Enter a domain to add."
414            return
415        }
416
417        if viewModel.trackDomain(domain: draft, availabilityStatus: nil) {
418            AppHaptics.track()
419            isAddDomainFieldFocused = false
420            addDomainError = nil
421            newTrackedDomain = ""
422            showAddDomainSheet = false
423        } else if viewModel.upgradePrompt == nil {
424            addDomainError = "Enter a valid domain like example.com."
425        }
426    }
427}
428
429struct WatchlistRowView: View {
430    @Environment(\.appDensity) private var appDensity
431    let trackedDomain: TrackedDomain
432    let isRefreshing: Bool
433
434    var body: some View {
435        VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
436            HStack(alignment: .firstTextBaseline, spacing: 8) {
437                if trackedDomain.isPinned {
438                    Image(systemName: "pin.fill")
439                        .font(.caption2)
440                        .foregroundStyle(.yellow)
441                }
442                Text(trackedDomain.domain)
443                    .font(appDensity.font(.callout))
444                    .foregroundStyle(.primary)
445                    .lineLimit(2)
446                    .multilineTextAlignment(.leading)
447                Spacer(minLength: 8)
448                statusBadge
449            }
450
451            Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
452                .font(appDensity.font(.caption2))
453                .foregroundStyle(.secondary)
454
455            if let collaboration = trackedDomain.collaboration, collaboration.isShared {
456                Text("\(collaboration.ownership.title)\(collaboration.permission.title)")
457                    .font(appDensity.font(.caption2))
458                    .foregroundStyle(.secondary)
459            }
460
461            HStack(spacing: 8) {
462                Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off")
463                if let lastMonitoredAt = trackedDomain.lastMonitoredAt {
464                    Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))")
465                }
466                if let lastAlertAt = trackedDomain.lastAlertAt {
467                    Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))")
468                }
469            }
470            .font(appDensity.font(.caption2))
471            .foregroundStyle(.secondary)
472
473            indicatorRow
474
475            if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
476                Text(note)
477                    .font(appDensity.font(.caption))
478                    .foregroundStyle(.secondary)
479                    .lineLimit(2)
480            } else if let summary = trackedDomain.lastChangeSummary {
481                Text(summary.message)
482                    .font(appDensity.font(.caption))
483                    .foregroundStyle(.secondary)
484                    .lineLimit(2)
485            }
486        }
487        .frame(maxWidth: .infinity, alignment: .leading)
488        .padding(.vertical, 4)
489    }
490
491    private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
492        switch status {
493        case .available:
494            return "Available"
495        case .registered:
496            return "Registered"
497        case .unknown, .none:
498            return "Unknown"
499        }
500    }
501
502    @ViewBuilder
503    private var statusBadge: some View {
504        if isRefreshing {
505            AppStatusBadgeView(model: .init(title: "Refreshing", systemImage: "arrow.clockwise", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.6)))
506        } else {
507            AppStatusBadgeView(model: AppStatusFactory.availability(trackedDomain.lastKnownAvailability))
508        }
509    }
510
511    @ViewBuilder
512    private var indicatorRow: some View {
513        HStack(spacing: 8) {
514            if trackedDomain.collaboration?.isShared == true {
515                AppStatusBadgeView(
516                    model: .init(
517                        title: "Shared",
518                        systemImage: "person.2.fill",
519                        foregroundColor: .cyan,
520                        backgroundColor: .cyan.opacity(0.16)
521                    )
522                )
523            }
524
525            AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary))
526
527            if trackedDomain.certificateWarningLevel != .none {
528                AppStatusBadgeView(model: certificateBadge)
529            }
530        }
531    }
532
533    private var certificateBadge: AppStatusBadgeModel {
534        let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon"
535        switch trackedDomain.certificateWarningLevel {
536        case .critical:
537            return .init(title: "Invalid \(days)", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
538        case .warning:
539            return .init(title: "Expiring \(days)", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
540        case .none:
541            return .init(title: "Valid", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
542        }
543    }
544}
545
546struct TrackedDomainDetailView: View {
547    @Bindable var viewModel: DomainViewModel
548    let trackedDomain: TrackedDomain
549    @Environment(\.dismiss) private var dismiss
550
551    @State private var noteDraft = ""
552    @State private var isEditingNote = false
553    @State private var tagsDraft = ""
554    @State private var isEditingTags = false
555    @State private var showRerunOptions = false
556    @State private var shareEntity: ShareableEntity?
557    @State private var showingAuditTimeline = false
558    @State private var auditStartInFlight = false
559
560    private var liveTrackedDomain: TrackedDomain {
561        viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
562    }
563
564    private var latestSnapshots: [HistoryEntry] {
565        viewModel.recentSnapshots(for: liveTrackedDomain)
566    }
567
568    private var latestDiffSections: [DomainDiffSection] {
569        viewModel.diffSectionsForLatestSnapshots(of: liveTrackedDomain)
570    }
571
572    var body: some View {
573        List {
574            Section {
575                WatchlistRowView(
576                    trackedDomain: liveTrackedDomain,
577                    isRefreshing: viewModel.refreshingTrackedDomainID == liveTrackedDomain.id
578                )
579            }
580            .listRowBackground(Color(.systemGray6).opacity(0.5))
581
582            Section {
583                Button {
584                    viewModel.refreshTrackedDomain(liveTrackedDomain)
585                } label: {
586                    Label("Manual Refresh", systemImage: "arrow.clockwise")
587                }
588
589                Button {
590                    showRerunOptions = true
591                } label: {
592                    Label("Re-run Inspection", systemImage: "magnifyingglass")
593                }
594
595                Button {
596                    Task {
597                        auditStartInFlight = true
598                        if await viewModel.startAudit(for: liveTrackedDomain.domain) != nil {
599                            showingAuditTimeline = true
600                        }
601                        auditStartInFlight = false
602                    }
603                } label: {
604                    Label(auditStartInFlight ? "Starting Audit…" : "Start Audit", systemImage: "checklist")
605                }
606                .disabled(auditStartInFlight)
607
608                if !viewModel.audits(for: liveTrackedDomain.domain).isEmpty {
609                    Button {
610                        showingAuditTimeline = true
611                    } label: {
612                        Label("View Audits", systemImage: "clock.badge.checkmark")
613                    }
614                }
615
616                Button {
617                    viewModel.togglePinned(for: liveTrackedDomain)
618                } label: {
619                    Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
620                }
621                .disabled(!viewModel.canEdit(liveTrackedDomain))
622
623                Button {
624                    viewModel.toggleMonitoring(for: liveTrackedDomain)
625                } label: {
626                    Label(
627                        liveTrackedDomain.monitoringEnabled ? "Disable Monitoring" : "Enable Monitoring",
628                        systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell"
629                    )
630                }
631                .disabled(!viewModel.canEdit(liveTrackedDomain))
632
633                Button {
634                    noteDraft = liveTrackedDomain.note ?? ""
635                    isEditingNote = true
636                } label: {
637                    Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text")
638                }
639                .disabled(!viewModel.canEdit(liveTrackedDomain))
640
641                Button {
642                    tagsDraft = liveTrackedDomain.tags.joined(separator: ", ")
643                    isEditingTags = true
644                } label: {
645                    Label(liveTrackedDomain.tags.isEmpty ? "Add Tags" : "Edit Tags", systemImage: "tag")
646                }
647                .disabled(!viewModel.canEdit(liveTrackedDomain))
648
649                Button {
650                    shareEntity = .trackedDomain(liveTrackedDomain.domain)
651                } label: {
652                    Label(liveTrackedDomain.collaboration?.isShared == true ? "Manage Share" : "Share Domain", systemImage: "person.2")
653                }
654            }
655            .listRowBackground(Color(.systemGray6).opacity(0.5))
656
657            if !liveTrackedDomain.tags.isEmpty {
658                Section("Tags") {
659                    TagChipRowView(tags: liveTrackedDomain.tags)
660                }
661                .listRowBackground(Color(.systemGray6).opacity(0.5))
662            }
663
664            Section("Monitoring Status") {
665                LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain))
666                LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain))
667                LabeledContent(
668                    "Last Change",
669                    value: liveTrackedDomain.monitoringState.lastChangeDate?.formatted(date: .abbreviated, time: .shortened) ?? "None"
670                )
671                if !liveTrackedDomain.pendingMonitoringAlerts.isEmpty {
672                    LabeledContent("Queued Alerts", value: "\(liveTrackedDomain.pendingMonitoringAlerts.count)")
673                }
674            }
675            .listRowBackground(Color(.systemGray6).opacity(0.5))
676
677            if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) {
678                Section("Latest Change Summary") {
679                    DomainChangeSummaryView(summary: summary)
680                }
681                .listRowBackground(Color.clear)
682            }
683
684            if !latestDiffSections.isEmpty {
685                Section("Latest Diff") {
686                    DomainDiffView(
687                        title: "Latest Snapshot vs Previous",
688                        sections: latestDiffSections,
689                        contextNote: latestSnapshots.count >= 2
690                            ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot)
691                            : nil,
692                        showsUnchanged: false,
693                        highlightedSectionID: nil
694                    )
695                }
696                .listRowBackground(Color.clear)
697            }
698
699            Section("Recent Snapshots") {
700                if latestSnapshots.isEmpty {
701                    Text("No snapshots yet")
702                        .font(.system(.caption, design: .monospaced))
703                        .foregroundStyle(.secondary)
704                } else {
705                    ForEach(latestSnapshots) { entry in
706                        NavigationLink {
707                            HistoryDetailView(viewModel: viewModel, entry: entry)
708                        } label: {
709                            VStack(alignment: .leading, spacing: 4) {
710                                Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
711                                    .font(.system(.caption, design: .monospaced))
712                                    .foregroundStyle(.primary)
713                                Text(entry.changeSummary?.hasChanges == true ? "Changed" : "Snapshot")
714                                    .font(.system(.caption2, design: .monospaced))
715                                    .foregroundStyle(.secondary)
716                            }
717                        }
718                    }
719                }
720            }
721            .listRowBackground(Color(.systemGray6).opacity(0.5))
722        }
723        .scrollContentBackground(.hidden)
724        .background(Color.black)
725        .navigationTitle(liveTrackedDomain.domain)
726        .preferredColorScheme(.dark)
727        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
728            dismiss()
729        }
730        .confirmationDialog("Re-run inspection", isPresented: $showRerunOptions) {
731            Button("Run with Current Settings") {
732                viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: false)
733            }
734            if latestSnapshots.first != nil {
735                Button("Run with Snapshot Resolver") {
736                    viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: true)
737                }
738            }
739            Button("Cancel", role: .cancel) {}
740        } message: {
741            Text(viewModel.resolverMismatchNote(for: liveTrackedDomain) ?? "Choose how to reproduce the most recent snapshot.")
742        }
743        .sheet(isPresented: $isEditingNote) {
744            NavigationStack {
745                Form {
746                    Section("Tracking Note") {
747                        TextField("Optional note", text: $noteDraft, axis: .vertical)
748                            .lineLimit(3...6)
749                    }
750                }
751                .navigationTitle("Edit Note")
752                .toolbar {
753                    ToolbarItem(placement: .cancellationAction) {
754                        Button("Cancel") {
755                            isEditingNote = false
756                        }
757                    }
758                    ToolbarItem(placement: .confirmationAction) {
759                        Button("Save") {
760                            viewModel.updateNote(noteDraft, for: liveTrackedDomain)
761                            isEditingNote = false
762                        }
763                    }
764                }
765            }
766        }
767        .sheet(isPresented: $isEditingTags) {
768            NavigationStack {
769                Form {
770                    Section("Tags") {
771                        TextField("comma, separated, tags", text: $tagsDraft)
772                            .textInputAutocapitalization(.never)
773                    }
774                }
775                .navigationTitle("Edit Tags")
776                .toolbar {
777                    ToolbarItem(placement: .cancellationAction) {
778                        Button("Cancel") {
779                            isEditingTags = false
780                        }
781                    }
782                    ToolbarItem(placement: .confirmationAction) {
783                        Button("Save") {
784                            let tags = tagsDraft.components(separatedBy: ",")
785                            viewModel.updateTags(tags, for: liveTrackedDomain)
786                            isEditingTags = false
787                        }
788                    }
789                }
790            }
791        }
792        .sheet(item: $shareEntity) { entity in
793            CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
794        }
795        .sheet(isPresented: $showingAuditTimeline) {
796            NavigationStack {
797                AuditDomainTimelineView(viewModel: viewModel, domain: liveTrackedDomain.domain)
798            }
799        }
800    }
801}