Initial project setup - Phases 1-3 complete

This commit is contained in:
Trey t
2026-04-06 11:28:40 -05:00
commit c77e506db5
293 changed files with 14233 additions and 0 deletions

View 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() }
}
}
}
}
}

View 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)"
}
}
}

View 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)
}
}