import SwiftUI import ProxyCore import GRDB struct MapLocalView: View { @State private var rules: [MapLocalRule] = [] @State private var showAddRule = false @State private var editingRule: MapLocalRule? @State private var ruleToDelete: MapLocalRule? @State private var showDeleteConfirmation = false @State private var observation: AnyDatabaseCancellable? private let rulesRepo = RulesRepository() var body: some View { List { Section { VStack(alignment: .leading, spacing: 8) { Text("Map Local") .font(.headline) Text("Intercept requests and replace the response with local content. Define custom mock responses for matched URLs.") .font(.subheadline) .foregroundStyle(.secondary) } .padding() } .listRowInsets(EdgeInsets()) Section("Rules") { if rules.isEmpty { EmptyStateView( icon: "doc.on.doc", title: "No Map Local Rules", subtitle: "Tap + to create a new rule." ) } else { ForEach(rules) { rule in Button { editingRule = rule } label: { VStack(alignment: .leading, spacing: 4) { Text(rule.name ?? rule.urlPattern) .font(.subheadline.weight(.medium)) Text(rule.urlPattern) .font(.caption) .foregroundStyle(.secondary) Text("Status: \(rule.responseStatus)") .font(.caption2) .foregroundStyle(.tertiary) } } .tint(.primary) } .onDelete { indexSet in if let index = indexSet.first { ruleToDelete = rules[index] showDeleteConfirmation = true } } } } } .navigationTitle("Map Local") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showAddRule = true } label: { Image(systemName: "plus") } } } .sheet(isPresented: $showAddRule) { AddMapLocalRuleSheet(rulesRepo: rulesRepo, isPresented: $showAddRule) } .sheet(item: $editingRule) { rule in AddMapLocalRuleSheet(rulesRepo: rulesRepo, existingRule: rule, isPresented: .constant(true)) } .confirmationDialog("Delete this rule?", isPresented: $showDeleteConfirmation, presenting: ruleToDelete) { rule in Button("Delete", role: .destructive) { if let id = rule.id { try? rulesRepo.deleteMapLocalRule(id: id) } } } .task { observation = rulesRepo.observeMapLocalRules() .start(in: DatabaseManager.shared.dbPool) { error in print("Map Local observation error: \(error)") } onChange: { newRules in rules = newRules } } } } // MARK: - Add Map Local Rule Sheet private struct AddMapLocalRuleSheet: View { let rulesRepo: RulesRepository let existingRule: MapLocalRule? @Binding var isPresented: Bool @Environment(\.dismiss) private var dismiss @State private var name = "" @State private var urlPattern = "" @State private var method = "ANY" @State private var responseStatus = "200" @State private var responseHeadersJSON = "" @State private var responseBody = "" @State private var contentType = "application/json" private var methods: [String] { ["ANY"] + ProxyConstants.httpMethods } init(rulesRepo: RulesRepository, existingRule: MapLocalRule? = nil, isPresented: Binding) { self.rulesRepo = rulesRepo self.existingRule = existingRule self._isPresented = isPresented } var body: some View { NavigationStack { Form { Section { TextField("Name (optional)", text: $name) TextField("URL Pattern (e.g. */api/*)", text: $urlPattern) .textInputAutocapitalization(.never) .autocorrectionDisabled() } Section { Picker("Method", selection: $method) { ForEach(methods, id: \.self) { m in Text(m).tag(m) } } } Section("Response") { TextField("Status Code", text: $responseStatus) .keyboardType(.numberPad) TextField("Content Type", text: $contentType) .textInputAutocapitalization(.never) .autocorrectionDisabled() } Section { TextEditor(text: $responseHeadersJSON) .font(.system(.body, design: .monospaced)) .frame(minHeight: 100) } header: { Text("Response Headers (JSON)") } footer: { Text("Optional JSON object of headers, for example {\"Cache-Control\":\"no-store\",\"X-Mock\":\"1\"}") } Section("Response Body") { TextEditor(text: $responseBody) .font(.system(.body, design: .monospaced)) .frame(minHeight: 120) } Section { Button("Save") { let status = Int(responseStatus) ?? 200 if let existing = existingRule { let updated = MapLocalRule( id: existing.id, name: name.isEmpty ? nil : name, urlPattern: urlPattern, method: method, responseStatus: status, responseHeaders: normalizedHeadersJSON, responseBody: responseBody.isEmpty ? nil : responseBody.data(using: .utf8), responseContentType: contentType.isEmpty ? nil : contentType, isEnabled: existing.isEnabled, createdAt: existing.createdAt ) try? rulesRepo.updateMapLocalRule(updated) } else { var rule = MapLocalRule( name: name.isEmpty ? nil : name, urlPattern: urlPattern, method: method, responseStatus: status, responseHeaders: normalizedHeadersJSON, responseBody: responseBody.isEmpty ? nil : responseBody.data(using: .utf8), responseContentType: contentType.isEmpty ? nil : contentType ) try? rulesRepo.insertMapLocalRule(&rule) } isPresented = false dismiss() } .disabled(urlPattern.isEmpty) } } .navigationTitle(existingRule == nil ? "New Map Local Rule" : "Edit Map Local Rule") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { isPresented = false dismiss() } } } .onAppear { if let rule = existingRule { name = rule.name ?? "" urlPattern = rule.urlPattern method = rule.method responseStatus = String(rule.responseStatus) if let body = rule.responseBody, let str = String(data: body, encoding: .utf8) { responseBody = str } responseHeadersJSON = prettyPrintedHeaders(rule.responseHeaders) ?? "" contentType = rule.responseContentType ?? "application/json" } } } } private var normalizedHeadersJSON: String? { let trimmed = responseHeadersJSON.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } guard let data = trimmed.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data), let dict = object as? [String: String], let normalized = try? JSONEncoder().encode(dict), let json = String(data: normalized, encoding: .utf8) else { return nil } return json } private func prettyPrintedHeaders(_ json: String?) -> String? { guard let json, let data = json.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data), let pretty = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]), let string = String(data: pretty, encoding: .utf8) else { return json } return string } }