Initial project setup - Phases 1-3 complete
This commit is contained in:
55
UI/Compose/CURLImportView.swift
Normal file
55
UI/Compose/CURLImportView.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct CURLImportView: View {
|
||||
let onImport: (ParsedCURLRequest) -> Void
|
||||
|
||||
@State private var curlText = ""
|
||||
@State private var errorMessage: String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Paste a cURL command to import as a request.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextEditor(text: $curlText)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 200)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.systemGray4))
|
||||
)
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Button("Import") {
|
||||
if let parsed = CURLParser.parse(curlText) {
|
||||
onImport(parsed)
|
||||
} else {
|
||||
errorMessage = "Invalid cURL command. Make sure it starts with 'curl'."
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(curlText.isEmpty)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Import cURL")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
180
UI/Compose/ComposeEditorView.swift
Normal file
180
UI/Compose/ComposeEditorView.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
|
||||
struct ComposeEditorView: View {
|
||||
let requestId: Int64
|
||||
|
||||
@State private var method = "GET"
|
||||
@State private var url = ""
|
||||
@State private var headersText = ""
|
||||
@State private var queryText = ""
|
||||
@State private var bodyText = ""
|
||||
@State private var selectedTab: EditorTab = .headers
|
||||
@State private var isSending = false
|
||||
@State private var responseStatus: Int?
|
||||
@State private var responseBody: String?
|
||||
|
||||
private let composeRepo = ComposeRepository()
|
||||
|
||||
enum EditorTab: String, CaseIterable {
|
||||
case headers = "Headers"
|
||||
case query = "Query"
|
||||
case body = "Body"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Method + URL
|
||||
HStack(spacing: 12) {
|
||||
Menu {
|
||||
ForEach(ProxyConstants.httpMethods, id: \.self) { m in
|
||||
Button(m) { method = m }
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(method)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.green)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
TextField("Tap to edit URL", text: $url)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.subheadline)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
.padding()
|
||||
|
||||
// Tabs
|
||||
Picker("Tab", selection: $selectedTab) {
|
||||
ForEach(EditorTab.allCases, id: \.self) { tab in
|
||||
Text(tab.rawValue).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
|
||||
// Tab Content
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
switch selectedTab {
|
||||
case .headers:
|
||||
headerEditor
|
||||
case .query:
|
||||
queryEditor
|
||||
case .body:
|
||||
bodyEditor
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// Response
|
||||
if let responseBody {
|
||||
Divider()
|
||||
ScrollView {
|
||||
Text(responseBody)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
.background(Color(.systemGray6))
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Request")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
Task { await sendRequest() }
|
||||
} label: {
|
||||
Text("Send")
|
||||
.font(.headline)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(url.isEmpty || isSending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if headersText.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "list.bullet.rectangle",
|
||||
title: "No Headers",
|
||||
subtitle: "Tap 'Edit Headers' to add headers."
|
||||
)
|
||||
}
|
||||
|
||||
Button("Edit Headers") {
|
||||
// TODO: Open header editor sheet
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
private var queryEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextEditor(text: $queryText)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 100)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.systemGray4))
|
||||
)
|
||||
Text("Format: key=value, one per line")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var bodyEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextEditor(text: $bodyText)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 200)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.systemGray4))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendRequest() async {
|
||||
guard let requestURL = URL(string: url) else { return }
|
||||
|
||||
isSending = true
|
||||
defer { isSending = false }
|
||||
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.httpMethod = method
|
||||
if !bodyText.isEmpty {
|
||||
request.httpBody = bodyText.data(using: .utf8)
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
responseStatus = httpResponse.statusCode
|
||||
}
|
||||
if let string = String(data: data, encoding: .utf8) {
|
||||
responseBody = string
|
||||
} else {
|
||||
responseBody = "\(data.count) bytes (binary)"
|
||||
}
|
||||
} catch {
|
||||
responseBody = "Error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
133
UI/Compose/ComposeListView.swift
Normal file
133
UI/Compose/ComposeListView.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
import ProxyCore
|
||||
import GRDB
|
||||
|
||||
struct ComposeListView: View {
|
||||
@State private var requests: [ComposeRequest] = []
|
||||
@State private var showClearConfirmation = false
|
||||
@State private var showTemplatePicker = false
|
||||
@State private var observation: AnyDatabaseCancellable?
|
||||
|
||||
private let composeRepo = ComposeRepository()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if requests.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "square.and.pencil",
|
||||
title: "No Requests",
|
||||
subtitle: "Create a new request to get started.",
|
||||
actionTitle: "New Request"
|
||||
) {
|
||||
createEmptyRequest()
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(requests) { request in
|
||||
NavigationLink(value: request.id) {
|
||||
HStack {
|
||||
MethodBadge(method: request.method)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(request.name)
|
||||
.font(.subheadline.weight(.medium))
|
||||
if let url = request.url, !url.isEmpty {
|
||||
Text(url)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
for index in indexSet {
|
||||
if let id = requests[index].id {
|
||||
try? composeRepo.delete(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Int64?.self) { id in
|
||||
if let id {
|
||||
ComposeEditorView(requestId: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Compose")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.disabled(requests.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("Empty Request") { createEmptyRequest() }
|
||||
Button("GET with Query") { createTemplate(method: "GET", name: "GET with Query") }
|
||||
Button("POST with JSON") { createTemplate(method: "POST", name: "POST with JSON", contentType: "application/json") }
|
||||
Button("POST with Form") { createTemplate(method: "POST", name: "POST with Form", contentType: "application/x-www-form-urlencoded") }
|
||||
Divider()
|
||||
Button("Import from cURL") { showTemplatePicker = true }
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Clear History", isPresented: $showClearConfirmation) {
|
||||
Button("Clear All", role: .destructive) {
|
||||
try? composeRepo.deleteAll()
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete all compose requests.")
|
||||
}
|
||||
.sheet(isPresented: $showTemplatePicker) {
|
||||
CURLImportView { parsed in
|
||||
var request = ComposeRequest(
|
||||
name: "Imported Request",
|
||||
method: parsed.method,
|
||||
url: parsed.url,
|
||||
headers: encodeHeaders(parsed.headers),
|
||||
body: parsed.body
|
||||
)
|
||||
try? composeRepo.insert(&request)
|
||||
showTemplatePicker = false
|
||||
}
|
||||
}
|
||||
.task {
|
||||
observation = composeRepo.observeRequests()
|
||||
.start(in: DatabaseManager.shared.dbPool) { error in
|
||||
print("Compose observation error: \(error)")
|
||||
} onChange: { newRequests in
|
||||
withAnimation {
|
||||
requests = newRequests
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createEmptyRequest() {
|
||||
var request = ComposeRequest()
|
||||
try? composeRepo.insert(&request)
|
||||
}
|
||||
|
||||
private func createTemplate(method: String, name: String, contentType: String? = nil) {
|
||||
var headers: String?
|
||||
if let contentType {
|
||||
headers = encodeHeaders([(key: "Content-Type", value: contentType)])
|
||||
}
|
||||
var request = ComposeRequest(name: name, method: method, headers: headers)
|
||||
try? composeRepo.insert(&request)
|
||||
}
|
||||
|
||||
private func encodeHeaders(_ headers: [(key: String, value: String)]) -> String? {
|
||||
var dict: [String: String] = [:]
|
||||
for h in headers { dict[h.key] = h.value }
|
||||
guard let data = try? JSONEncoder().encode(dict) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user