From 3310282d4bd45cda5dca91c703fbe3b1aef29049 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sun, 12 Apr 2026 00:46:30 -0700 Subject: [PATCH] initial commit --- Build/BuildHistoryStore.swift | 220 ++++++++++++ Build/BuildPreflightChecker.swift | 136 ++++++++ Build/PreflightView.swift | 79 +++++ Config/DaemonConfiguration.swift | 24 ++ Config/LocalGitConfiguration.swift | 114 +++++++ Config/ProjectStore.swift | 34 ++ Diagnostics/BuildDiagnosticParser.swift | 38 +++ Diagnostics/CompletionPopover.swift | 74 ++++ Editor/CodeEditorView.swift | 143 ++++++++ Editor/CompletionController.swift | 80 +++++ Editor/EditorKeyboardToolbar.swift | 123 +++++++ Editor/EditorState.swift | 117 +++++++ Editor/EditorTabBar.swift | 71 ++++ Editor/FindAcrossFilesView.swift | 98 ++++++ Editor/FindInFileView.swift | 73 ++++ Editor/GutterAnnotationView.swift | 124 +++++++ Editor/HardwareKeyboardCommandHandler.swift | 155 +++++++++ Editor/JumpToLineView.swift | 31 ++ Editor/RunestoneEditorView.swift | 108 ++++++ Git/GitCallbackHandler.swift | 74 ++++ Git/GitPanel.swift | 262 ++++++++++++++ Git/GitService.swift | 117 +++++++ Git/GitStore.swift | 76 +++++ Info.plist | 51 +++ Layout/BuildToolbar.swift | 103 ++++++ Layout/ContentView.swift | 358 ++++++++++++++++++++ Layout/EnhancedConsoleView.swift | 134 ++++++++ Navigator/FileOperationSheets.swift | 61 ++++ Navigator/GitAwareFileNavigatorView.swift | 127 +++++++ Network/BuildService.swift | 281 +++++++++++++++ Network/DaemonHealthMonitor.swift | 141 ++++++++ Network/FileSyncPipeline.swift | 135 ++++++++ Network/LSPClient.swift | 326 ++++++++++++++++++ Onboarding/OnboardingView.swift | 215 ++++++++++++ PadXcode.entitlements | 10 + PadXcodeApp.swift | 40 +++ README.md | 34 ++ SPM-DEPENDENCIES.md | 23 ++ SceneDelegate.swift | 10 + Settings/SettingsView.swift | 176 ++++++++++ Shared/SharedModels.swift | 174 ++++++++++ Theme/LanguageDetector.swift | 17 + Theme/PadXcodeTheme.swift | 151 +++++++++ UI/ToastSystem.swift | 77 +++++ Validation/FileOperationValidator.swift | 50 +++ Validation/RetryPolicy.swift | 31 ++ Validation/UnsavedChangesGuard.swift | 59 ++++ Validation/WorkingCopyGuard.swift | 80 +++++ generate.sh | 27 ++ project.yml | 98 ++++++ 50 files changed, 5360 insertions(+) create mode 100644 Build/BuildHistoryStore.swift create mode 100644 Build/BuildPreflightChecker.swift create mode 100644 Build/PreflightView.swift create mode 100644 Config/DaemonConfiguration.swift create mode 100644 Config/LocalGitConfiguration.swift create mode 100644 Config/ProjectStore.swift create mode 100644 Diagnostics/BuildDiagnosticParser.swift create mode 100644 Diagnostics/CompletionPopover.swift create mode 100644 Editor/CodeEditorView.swift create mode 100644 Editor/CompletionController.swift create mode 100644 Editor/EditorKeyboardToolbar.swift create mode 100644 Editor/EditorState.swift create mode 100644 Editor/EditorTabBar.swift create mode 100644 Editor/FindAcrossFilesView.swift create mode 100644 Editor/FindInFileView.swift create mode 100644 Editor/GutterAnnotationView.swift create mode 100644 Editor/HardwareKeyboardCommandHandler.swift create mode 100644 Editor/JumpToLineView.swift create mode 100644 Editor/RunestoneEditorView.swift create mode 100644 Git/GitCallbackHandler.swift create mode 100644 Git/GitPanel.swift create mode 100644 Git/GitService.swift create mode 100644 Git/GitStore.swift create mode 100644 Info.plist create mode 100644 Layout/BuildToolbar.swift create mode 100644 Layout/ContentView.swift create mode 100644 Layout/EnhancedConsoleView.swift create mode 100644 Navigator/FileOperationSheets.swift create mode 100644 Navigator/GitAwareFileNavigatorView.swift create mode 100644 Network/BuildService.swift create mode 100644 Network/DaemonHealthMonitor.swift create mode 100644 Network/FileSyncPipeline.swift create mode 100644 Network/LSPClient.swift create mode 100644 Onboarding/OnboardingView.swift create mode 100644 PadXcode.entitlements create mode 100644 PadXcodeApp.swift create mode 100644 README.md create mode 100644 SPM-DEPENDENCIES.md create mode 100644 SceneDelegate.swift create mode 100644 Settings/SettingsView.swift create mode 100644 Shared/SharedModels.swift create mode 100644 Theme/LanguageDetector.swift create mode 100644 Theme/PadXcodeTheme.swift create mode 100644 UI/ToastSystem.swift create mode 100644 Validation/FileOperationValidator.swift create mode 100644 Validation/RetryPolicy.swift create mode 100644 Validation/UnsavedChangesGuard.swift create mode 100644 Validation/WorkingCopyGuard.swift create mode 100755 generate.sh create mode 100644 project.yml diff --git a/Build/BuildHistoryStore.swift b/Build/BuildHistoryStore.swift new file mode 100644 index 0000000..d4f0e1b --- /dev/null +++ b/Build/BuildHistoryStore.swift @@ -0,0 +1,220 @@ +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 + +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]) + } +} diff --git a/Build/BuildPreflightChecker.swift b/Build/BuildPreflightChecker.swift new file mode 100644 index 0000000..2e0e6b4 --- /dev/null +++ b/Build/BuildPreflightChecker.swift @@ -0,0 +1,136 @@ +import Foundation +import SwiftUI + +enum PreflightStatus { + case pass + case warning(String) + case fail(String) +} + +struct PreflightCheck: Identifiable { + let id = UUID() + let name: String + let detail: String + let status: PreflightStatus + + var icon: String { + switch status { + case .pass: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .fail: return "xmark.circle.fill" + } + } + var color: Color { + switch status { + case .pass: return .green + case .warning: return .orange + case .fail: return .red + } + } +} + +struct PreflightReport { + let checks: [PreflightCheck] + var canProceed: Bool { + !checks.contains { if case .fail = $0.status { return true }; return false } + } + var hasWarnings: Bool { + checks.contains { if case .warning = $0.status { return true }; return false } + } +} + +@MainActor +final class BuildPreflightChecker { + private let config: DaemonConfiguration + private let buildService: BuildService + + init(config: DaemonConfiguration, buildService: BuildService) { + self.config = config; self.buildService = buildService + } + + func run(project: SavedProject?, device: ConnectedDevice?, action: String) async -> PreflightReport { + var checks: [PreflightCheck] = [] + checks.append(await checkDaemon()) + checks.append(checkProject(project)) + checks.append(checkTeamID()) + checks.append(checkDevice(device, action: action)) + if let p = project { checks.append(await checkPath(p)) } + checks.append(checkNoBuildRunning()) + return PreflightReport(checks: checks) + } + + private func checkDaemon() async -> PreflightCheck { + guard let url = URL(string: "\(config.baseURL)/health") else { + return PreflightCheck(name: "Daemon", detail: "Invalid URL", + status: .fail("Daemon URL malformed. Check Settings.")) + } + var req = URLRequest(url: url); req.timeoutInterval = 3 + do { + let (_, resp) = try await URLSession.shared.data(for: req) + guard (resp as? HTTPURLResponse)?.statusCode == 200 else { + return PreflightCheck(name: "Daemon", detail: "Bad response", + status: .fail("Daemon not responding correctly.")) + } + return PreflightCheck(name: "Daemon", detail: "Connected to \(config.baseURL)", status: .pass) + } catch { + return PreflightCheck(name: "Daemon", detail: error.localizedDescription, + status: .fail("Cannot reach \(config.baseURL). Check Tailscale.")) + } + } + + private func checkProject(_ project: SavedProject?) -> PreflightCheck { + guard let p = project else { + return PreflightCheck(name: "Project", detail: "None selected", + status: .fail("Select a project before building.")) + } + return PreflightCheck(name: "Project", detail: "\(p.name) — \(p.scheme)", status: .pass) + } + + private func checkTeamID() -> PreflightCheck { + let t = config.developmentTeam.trimmingCharacters(in: .whitespaces) + if t.isEmpty { + return PreflightCheck(name: "Team ID", detail: "Not set", + status: .fail("Enter your Team ID in Settings → Build.")) + } + let valid = t.count == 10 && t.allSatisfy({ $0.isLetter || $0.isNumber }) && t == t.uppercased() + return PreflightCheck(name: "Team ID", detail: t, + status: valid ? .pass : .warning("Format looks wrong — should be 10 uppercase alphanumerics.")) + } + + private func checkDevice(_ device: ConnectedDevice?, action: String) -> PreflightCheck { + if action == "build" { + return PreflightCheck(name: "Device", detail: "Build only", status: .pass) + } + guard let d = device else { + return PreflightCheck(name: "Device", detail: "None selected", + status: .fail("Select a paired device to deploy.")) + } + return PreflightCheck(name: "Device", + detail: "\(d.name) · iOS \(d.osVersion)", + status: d.isNetworkConnected ? .pass : + .warning("Device paired but may not be on network.")) + } + + private func checkPath(_ project: SavedProject) async -> PreflightCheck { + let encoded = project.rootPath.urlEncoded + guard let url = URL(string: "\(config.baseURL)/files/tree?path=\(encoded)") else { + return PreflightCheck(name: "Project Path", detail: project.rootPath, status: .pass) + } + var req = URLRequest(url: url); req.timeoutInterval = 5 + do { + let (_, resp) = try await URLSession.shared.data(for: req) + let code = (resp as? HTTPURLResponse)?.statusCode ?? 0 + return PreflightCheck(name: "Project Path", detail: project.rootPath, + status: code == 200 ? .pass : .fail("Path not found on Mac.")) + } catch { + return PreflightCheck(name: "Project Path", detail: "Check timed out", status: .warning("Could not verify path.")) + } + } + + private func checkNoBuildRunning() -> PreflightCheck { + buildService.isBuilding + ? PreflightCheck(name: "Build State", detail: "Already running", + status: .fail("Wait for current build to finish.")) + : PreflightCheck(name: "Build State", detail: "Ready", status: .pass) + } +} diff --git a/Build/PreflightView.swift b/Build/PreflightView.swift new file mode 100644 index 0000000..76b8f08 --- /dev/null +++ b/Build/PreflightView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct PreflightView: View { + let report: PreflightReport + let action: String + let onProceed: () -> Void + let onCancel: () -> Void + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + summaryHeader.padding(.horizontal, 20).padding(.vertical, 16) + .background(headerBg) + Divider() + List(report.checks) { check in + HStack(spacing: 10) { + Image(systemName: check.icon).foregroundStyle(check.color).font(.system(size: 16)).frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(check.name).font(.subheadline) + Text(check.detail).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + } + .padding(.vertical, 6) + } + .listStyle(.plain) + Divider() + HStack(spacing: 12) { + Button("Cancel", action: onCancel).buttonStyle(.bordered).frame(maxWidth: .infinity) + if report.canProceed { + Button(action: onProceed) { + Label(action == "buildAndRun" ? "Build & Run" : "Build", + systemImage: action == "buildAndRun" ? "play.fill" : "hammer.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(report.hasWarnings ? .orange : .green) + } else { + Button("Fix Issues", action: onCancel) + .buttonStyle(.borderedProminent).tint(.red).frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 20).padding(.vertical, 14) + .background(Color(.secondarySystemBackground)) + } + .navigationTitle("Pre-Build Checks") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel", action: onCancel) } } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + private var summaryHeader: some View { + HStack(spacing: 14) { + Image(systemName: summaryIcon).font(.system(size: 36, weight: .medium)).foregroundStyle(summaryColor) + VStack(alignment: .leading, spacing: 3) { + Text(summaryTitle).font(.headline) + Text(summarySubtitle).font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + } + } + + private var summaryIcon: String { !report.canProceed ? "xmark.circle.fill" : report.hasWarnings ? "exclamationmark.triangle.fill" : "checkmark.circle.fill" } + private var summaryColor: Color { !report.canProceed ? .red : report.hasWarnings ? .orange : .green } + private var summaryTitle: String { !report.canProceed ? "Build Blocked" : report.hasWarnings ? "Ready with Warnings" : "All Checks Passed" } + private var headerBg: Color { !report.canProceed ? Color.red.opacity(0.08) : report.hasWarnings ? Color.orange.opacity(0.08) : Color.green.opacity(0.08) } + + private var summarySubtitle: String { + let f = report.checks.filter { if case .fail = $0.status { return true }; return false }.count + let w = report.checks.filter { if case .warning = $0.status { return true }; return false }.count + let p = report.checks.filter { if case .pass = $0.status { return true }; return false }.count + var parts: [String] = [] + if f > 0 { parts.append("\(f) failure\(f == 1 ? "" : "s")") } + if w > 0 { parts.append("\(w) warning\(w == 1 ? "" : "s")") } + if p > 0 { parts.append("\(p) passed") } + return parts.joined(separator: " · ") + } +} diff --git a/Config/DaemonConfiguration.swift b/Config/DaemonConfiguration.swift new file mode 100644 index 0000000..d010eb3 --- /dev/null +++ b/Config/DaemonConfiguration.swift @@ -0,0 +1,24 @@ +import Foundation +import SwiftUI + +final class DaemonConfiguration: ObservableObject { + @AppStorage("daemonHost") var host: String = "100.x.x.x" + @AppStorage("daemonPort") var port: Int = 8080 + @AppStorage("developmentTeam") var developmentTeam: String = "" + @AppStorage("allowProvisioningUpdates") var allowProvisioningUpdates: Bool = true + @AppStorage("buildInRelease") var buildInRelease: Bool = false + @AppStorage("lspEnabled") var lspEnabled: Bool = true + @Published var lspConnected: Bool = false + + var baseURL: String { "http://\(host):\(port)" } + var wsBaseURL: String { "ws://\(host):\(port)" } + var lspWSURL: String { "ws://\(host):\(port)/lsp/stream" } + var buildConfiguration: String { buildInRelease ? "Release" : "Debug" } +} + +enum EditorThemeOption: String, CaseIterable, Identifiable, Codable { + case xcodeDefault = "xcode_dark" + case xcodeLight = "xcode_light" + var id: String { rawValue } + var displayName: String { self == .xcodeDefault ? "Xcode Dark" : "Xcode Light" } +} diff --git a/Config/LocalGitConfiguration.swift b/Config/LocalGitConfiguration.swift new file mode 100644 index 0000000..6287d9f --- /dev/null +++ b/Config/LocalGitConfiguration.swift @@ -0,0 +1,114 @@ +import Foundation +import SwiftUI +import UIKit + +struct LocalGitServer: Codable, Identifiable { + let id: UUID + var displayName: String + var tailscaleIP: String + var sshUser: String + var reposBasePath: String + var sshPort: Int + + var sshBaseURL: String { + let p = sshPort == 22 ? "" : ":\(sshPort)" + return "ssh://\(sshUser)@\(tailscaleIP)\(p)" + } + func repoURL(for name: String) -> String { "\(sshBaseURL)\(reposBasePath)/\(name).git" } + + init(id: UUID = UUID(), displayName: String, tailscaleIP: String, + sshUser: String, reposBasePath: String, sshPort: Int = 22) { + self.id = id; self.displayName = displayName; self.tailscaleIP = tailscaleIP + self.sshUser = sshUser; self.reposBasePath = reposBasePath; self.sshPort = sshPort + } +} + +final class LocalGitServerStore: ObservableObject { + @Published var servers: [LocalGitServer] = [] { didSet { persist() } } + private let key = "padxcode.localGitServers" + init() { load() } + func add(_ s: LocalGitServer) { servers.append(s) } + func remove(id: UUID) { servers.removeAll { $0.id == id } } + private func persist() { + if let d = try? JSONEncoder().encode(servers) { UserDefaults.standard.set(d, forKey: key) } + } + private func load() { + guard let d = UserDefaults.standard.data(forKey: key), + let v = try? JSONDecoder().decode([LocalGitServer].self, from: d) else { return } + servers = v + } +} + +// MARK: - SSH Setup View + +struct SSHKeySetupView: View { + @Environment(\.dismiss) private var dismiss + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SetupStep("1", title: "Copy Working Copy SSH key", + body: "Working Copy → Settings → SSH Keys → copy public key.") + SetupStep("2", title: "Enable Remote Login on Mac", + body: "System Settings → General → Sharing → Remote Login → On.") + SetupStep("3", title: "Add key to authorized_keys", body: "Run on your Mac:") + CodeBlock(""" +echo "PASTE_YOUR_WC_PUBLIC_KEY" >> ~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys +""") + SetupStep("4", title: "Create a bare repository", body: "Run on your Mac:") + CodeBlock(""" +mkdir -p ~/GitRemotes/NavidromePlayer.git +cd ~/GitRemotes/NavidromePlayer.git && git init --bare +cd ~/Dev/NavidromePlayer +git remote add origin ~/GitRemotes/NavidromePlayer.git +git push -u origin main +""") + SetupStep("5", title: "Clone in Working Copy", + body: "Working Copy → + → Clone → enter your SSH URL:") + CodeBlock("ssh://dallas@100.x.x.x/Users/dallas/GitRemotes/NavidromePlayer.git") + } + .padding(20) + } + .navigationTitle("SSH + Local Git Setup") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + } + } +} + +private struct SetupStep: View { + let step: String; let title: String; let body: String + init(_ step: String, title: String, body: String) { + self.step = step; self.title = title; self.body = body + } + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text(step).font(.system(.title3, design: .rounded).bold()) + .foregroundStyle(.white).frame(width: 28, height: 28) + .background(Color.accentColor, in: Circle()) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(body).font(.subheadline).foregroundStyle(.secondary) + } + } + } +} + +private struct CodeBlock: View { + let code: String + init(_ code: String) { self.code = code } + var body: some View { + HStack(alignment: .top) { + ScrollView(.horizontal, showsIndicators: false) { + Text(code).font(.system(.caption, design: .monospaced)) + .textSelection(.enabled).padding(12) + } + Button { UIPasteboard.general.string = code } + label: { Image(systemName: "doc.on.doc").font(.caption).padding(10) } + .buttonStyle(.plain).foregroundStyle(.secondary) + } + .background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(.separator), lineWidth: 0.5)) + } +} diff --git a/Config/ProjectStore.swift b/Config/ProjectStore.swift new file mode 100644 index 0000000..6494d5d --- /dev/null +++ b/Config/ProjectStore.swift @@ -0,0 +1,34 @@ +import Foundation + +struct SavedProject: Identifiable, Codable { + let id: UUID + var name: String + var rootPath: String + var scheme: String + var workingCopyRepoName: String + var bundleIdentifier: String + + init(id: UUID = UUID(), name: String, rootPath: String, scheme: String, + workingCopyRepoName: String = "", bundleIdentifier: String = "") { + self.id = id; self.name = name; self.rootPath = rootPath + self.scheme = scheme; self.workingCopyRepoName = workingCopyRepoName + self.bundleIdentifier = bundleIdentifier + } +} + +final class ProjectStore: ObservableObject { + @Published var projects: [SavedProject] = [] { didSet { persist() } } + private let key = "padxcode.projects" + init() { load() } + func add(_ p: SavedProject) { projects.append(p) } + func remove(id: UUID) { projects.removeAll { $0.id == id } } + func remove(atOffsets o: IndexSet) { projects.remove(atOffsets: o) } + private func persist() { + if let d = try? JSONEncoder().encode(projects) { UserDefaults.standard.set(d, forKey: key) } + } + private func load() { + guard let d = UserDefaults.standard.data(forKey: key), + let v = try? JSONDecoder().decode([SavedProject].self, from: d) else { return } + projects = v + } +} diff --git a/Diagnostics/BuildDiagnosticParser.swift b/Diagnostics/BuildDiagnosticParser.swift new file mode 100644 index 0000000..715037e --- /dev/null +++ b/Diagnostics/BuildDiagnosticParser.swift @@ -0,0 +1,38 @@ +import Foundation + +struct ParsedDiagnostic { + let filePath: String + let line: Int + let column: Int + let severity: DiagnosticRange.Severity + let message: String + + func toDiagnosticRange() -> DiagnosticRange { + DiagnosticRange(line: line, column: column, length: 40, + severity: severity, message: message) + } +} + +struct BuildDiagnosticParser { + private static let pattern = try! NSRegularExpression( + pattern: #"^(.+\.swift):(\d+):(\d+):\s+(error|warning|note):\s+(.+)$"# + ) + + static func parse(_ lines: [String]) -> [ParsedDiagnostic] { + lines.compactMap { parseLine($0) } + } + + private static func parseLine(_ line: String) -> ParsedDiagnostic? { + let range = NSRange(line.startIndex..., in: line) + guard let match = pattern.firstMatch(in: line, range: range) else { return nil } + func g(_ i: Int) -> String? { + guard let r = Range(match.range(at: i), in: line) else { return nil } + return String(line[r]) + } + guard let path = g(1), let lineStr = g(2), let lineNum = Int(lineStr), + let colStr = g(3), let colNum = Int(colStr), + let sev = g(4), let msg = g(5) else { return nil } + return ParsedDiagnostic(filePath: path, line: lineNum, column: colNum, + severity: sev == "error" ? .error : .warning, message: msg) + } +} diff --git a/Diagnostics/CompletionPopover.swift b/Diagnostics/CompletionPopover.swift new file mode 100644 index 0000000..5c9d66d --- /dev/null +++ b/Diagnostics/CompletionPopover.swift @@ -0,0 +1,74 @@ +// CompletionPopover.swift +// CompletionItem lives in Shared/SharedModels.swift — not redeclared here. +import SwiftUI + +struct CompletionPopover: View { + let items: [CompletionItem] + let onSelect: (CompletionItem) -> Void + @State private var selected: UUID? + + var body: some View { + VStack(spacing: 0) { + ForEach(items.prefix(8)) { item in + CompletionRow( + item: item, + isSelected: item.id == selected, + onSelect: { + selected = item.id + onSelect(item) + } + ) + if item.id != items.prefix(8).last?.id { + Divider().padding(.leading, 32) + } + } + } + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(color: .black.opacity(0.25), radius: 12, x: 0, y: 4) + .frame(width: 340) + } +} + +struct CompletionRow: View { + let item: CompletionItem + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 8) { + Image(systemName: item.symbolIcon) + .font(.system(size: 12)) + .frame(width: 20) + .foregroundStyle(symbolColor(for: item.kind)) + VStack(alignment: .leading, spacing: 1) { + Text(item.label) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.primary) + if !item.detail.isEmpty { + Text(item.detail) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(isSelected ? Color.accentColor.opacity(0.15) : .clear) + } + .buttonStyle(.plain) + } + + private func symbolColor(for kind: Int) -> Color { + switch kind { + case 2, 3: return .purple + case 6: return .blue + case 7: return .orange + case 8: return .teal + default: return .secondary + } + } +} diff --git a/Editor/CodeEditorView.swift b/Editor/CodeEditorView.swift new file mode 100644 index 0000000..93482fb --- /dev/null +++ b/Editor/CodeEditorView.swift @@ -0,0 +1,143 @@ +import SwiftUI +import Runestone + +struct CodeEditorView: View { + let filePath: String + @Binding var content: String + var diagnosticRanges: [DiagnosticRange] = [] + let lspClient: LSPClient + var onSave: () -> Void + var onBuild: () -> Void + var onRun: () -> Void + var onToggleNavigator: () -> Void + var onToggleConsole: () -> Void + + @EnvironmentObject var editorState: EditorState + + @StateObject private var completionCtrl: CompletionController + @StateObject private var keyboardBridge = EditorKeyboardBridge() + + @State private var activeTextView: Runestone.TextView? + @State private var cursorLine = 1 + @State private var cursorColumn = 1 + @State private var showJumpToLine = false + @State private var showFindInFile = false + + init(filePath: String, content: Binding, diagnosticRanges: [DiagnosticRange] = [], + lspClient: LSPClient, onSave: @escaping () -> Void, onBuild: @escaping () -> Void, + onRun: @escaping () -> Void, onToggleNavigator: @escaping () -> Void, onToggleConsole: @escaping () -> Void) { + self.filePath = filePath; self._content = content; self.diagnosticRanges = diagnosticRanges + self.lspClient = lspClient; self.onSave = onSave; self.onBuild = onBuild; self.onRun = onRun + self.onToggleNavigator = onToggleNavigator; self.onToggleConsole = onToggleConsole + _completionCtrl = StateObject(wrappedValue: CompletionController(lspClient: lspClient)) + } + + var body: some View { + ZStack(alignment: .topTrailing) { + RunestoneEditorView( + content: $content, + textViewRef: $activeTextView, + filePath: filePath, + theme: editorState.activeTheme, + showLineNumbers: editorState.showLineNumbers, + lineWrapping: editorState.lineWrapping, + indentWidth: editorState.indentWidth, + diagnosticRanges: diagnosticRanges, + onContentChange: { _ in }, + onCursorPositionChange: { l, c in cursorLine = l; cursorColumn = c }, + completionController: completionCtrl + ) + .ignoresSafeArea(.keyboard, edges: .bottom) + .overlay { + if completionCtrl.isVisible && !completionCtrl.items.isEmpty { + GeometryReader { geo in + let x = min(completionCtrl.anchorRect.minX, geo.size.width - 340) + let y = completionCtrl.anchorRect.maxY + 80 + CompletionPopover(items: completionCtrl.items) { item in + if let tv = activeTextView { completionCtrl.insert(item, into: tv) } + } + .position(x: x + 170, y: y) + } + } + } + + if !diagnosticRanges.isEmpty { + GutterAnnotationOverlay(diagnostics: diagnosticRanges, textView: activeTextView) + .allowsHitTesting(true) + } + + EditorCommandHandlerView( + textViewRef: $activeTextView, + onSave: onSave, onBuild: onBuild, onRun: onRun, + onToggleNavigator: onToggleNavigator, onToggleConsole: onToggleConsole, + onFind: { showFindInFile = true }, onJumpToLine: { showJumpToLine = true } + ) + .frame(width: 0, height: 0) + + EditorStatusBar(filePath: filePath, line: cursorLine, column: cursorColumn, diagnostics: diagnosticRanges) + .padding([.bottom, .trailing], 8).allowsHitTesting(false) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + EditorKeyboardToolbar(bridge: keyboardBridge) + } + .sheet(isPresented: $showJumpToLine) { + JumpToLineView(isPresented: $showJumpToLine, totalLines: content.components(separatedBy: "\n").count) { line in jumpTo(line) } + .presentationDetents([.height(160)]) + } + .sheet(isPresented: $showFindInFile) { + FindInFileView(content: content) { range in navigate(to: range) } + } + .onChange(of: activeTextView) { _, tv in keyboardBridge.activeTextView = tv } + .onReceive(NotificationCenter.default.publisher(for: .navigateToRange)) { note in + if let range = note.userInfo?["range"] as? NSRange { navigate(to: range) } + } + } + + private func jumpTo(_ line: Int) { + guard let tv = activeTextView else { return } + let lines = tv.text.components(separatedBy: "\n") + guard line >= 1, line <= lines.count else { return } + let offset = lines.prefix(line - 1).reduce(0) { $0 + $1.count + 1 } + let r = NSRange(location: offset, length: 0) + tv.selectedRange = r; tv.scrollRangeToVisible(r) + } + + private func navigate(to range: NSRange) { + guard let tv = activeTextView else { return } + tv.selectedRange = range; tv.scrollRangeToVisible(range) + } +} + +// MARK: - EditorStatusBar + +struct EditorStatusBar: View { + let filePath: String + let line: Int + let column: Int + let diagnostics: [DiagnosticRange] + + private var errorCount: Int { diagnostics.filter { $0.severity == .error }.count } + private var warningCount: Int { diagnostics.filter { $0.severity == .warning }.count } + + var body: some View { + HStack(spacing: 6) { + if errorCount > 0 { Label("\(errorCount)", systemImage: "xmark.circle.fill").foregroundStyle(.red).font(.system(.caption2, design: .monospaced)) } + if warningCount > 0 { Label("\(warningCount)", systemImage: "exclamationmark.triangle.fill").foregroundStyle(.orange).font(.system(.caption2, design: .monospaced)) } + if errorCount > 0 || warningCount > 0 { Text("·").foregroundStyle(.tertiary).font(.caption2) } + Text(URL(fileURLWithPath: filePath).lastPathComponent).font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary) + Text("·").foregroundStyle(.tertiary).font(.caption2) + Text("Ln \(line), Col \(column)").font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary) + Text("·").foregroundStyle(.tertiary).font(.caption2) + Text(langLabel).font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary) + } + .padding(.horizontal, 10).padding(.vertical, 4) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6)) + } + + private var langLabel: String { + switch (filePath as NSString).pathExtension.lowercased() { + case "swift": return "Swift"; case "json": return "JSON"; case "md": return "Markdown"; default: return "Plain Text" + } + } +} + diff --git a/Editor/CompletionController.swift b/Editor/CompletionController.swift new file mode 100644 index 0000000..b7357a7 --- /dev/null +++ b/Editor/CompletionController.swift @@ -0,0 +1,80 @@ +import UIKit +import Runestone + +@MainActor +final class CompletionController: ObservableObject { + @Published var items: [CompletionItem] = [] + @Published var isVisible: Bool = false + @Published var anchorRect: CGRect = .zero + + private weak var textView: Runestone.TextView? + private let lspClient: LSPClient + private var debounceTask: Task? + private var lastId = 0 + private let autoTriggers: Set = [".", "(", ","] + + init(lspClient: LSPClient) { self.lspClient = lspClient } + + func attach(to tv: Runestone.TextView) { self.textView = tv } + + func textDidChange(in tv: Runestone.TextView, filePath: String) { + guard let sel = tv.selectedRange, sel.location > 0 else { dismiss(); return } + let text = tv.text as NSString + let c = text.character(at: sel.location - 1) + let shouldTrigger = autoTriggers.contains(Character(Unicode.Scalar(c)!)) + + if shouldTrigger { + requestCompletions(tv: tv, filePath: filePath) + } else if isVisible { + debounceTask?.cancel() + debounceTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(200)) + guard !Task.isCancelled else { return } + await self?.requestCompletions(tv: tv, filePath: filePath) + } + } + } + + func requestManual(tv: Runestone.TextView, filePath: String) { + requestCompletions(tv: tv, filePath: filePath) + } + + func dismiss() { debounceTask?.cancel(); items = []; isVisible = false } + + func insert(_ item: CompletionItem, into tv: Runestone.TextView) { + guard let sel = tv.selectedRange else { dismiss(); return } + let text = tv.text as NSString + let wordRange = text.rangeOfCurrentWord(at: sel.location) + let insertText = item.insertText ?? item.label + tv.replace(wordRange, withText: insertText) + if insertText.hasSuffix("()") { + let loc = (tv.selectedRange?.location ?? 1) - 1 + tv.selectedRange = NSRange(location: loc, length: 0) + } + dismiss() + } + + private func requestCompletions(tv: Runestone.TextView, filePath: String) { + guard let sel = tv.selectedRange, + let loc = tv.textLocation(at: sel.location) else { return } + if let rect = tv.caretRect(for: sel.location) { + anchorRect = tv.convert(rect, to: nil) + } + lastId += 1; let id = lastId + Task { [weak self] in + guard let self else { return } + let results = await lspClient.requestCompletions(filePath: filePath, line: loc.lineNumber, column: loc.column) + guard self.lastId == id else { return } + if results.isEmpty { self.dismiss() } else { self.items = results; self.isVisible = true } + } + } +} + +private extension NSString { + func rangeOfCurrentWord(at location: Int) -> NSRange { + let wordChars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) + var start = location + while start > 0, let scalar = Unicode.Scalar(character(at: start - 1)), wordChars.contains(scalar) { start -= 1 } + return NSRange(location: start, length: location - start) + } +} diff --git a/Editor/EditorKeyboardToolbar.swift b/Editor/EditorKeyboardToolbar.swift new file mode 100644 index 0000000..d84c77c --- /dev/null +++ b/Editor/EditorKeyboardToolbar.swift @@ -0,0 +1,123 @@ +import SwiftUI +import Runestone + +final class EditorKeyboardBridge: ObservableObject { + weak var activeTextView: Runestone.TextView? +} + +struct EditorKeyboardToolbar: View { + @ObservedObject var bridge: EditorKeyboardBridge + @State private var showFindBar = false + @State private var searchQuery = "" + @State private var isRegex = false + + var body: some View { + VStack(spacing: 0) { + if showFindBar { + InlineFindBar(query: $searchQuery, isRegex: $isRegex, + onNext: findNext, onPrev: findPrev, + onDismiss: { withAnimation { showFindBar = false } }) + .transition(.move(edge: .top).combined(with: .opacity)) + Divider() + } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + KeyToolbarButton(icon: "increase.indent", label: "Indent") { bridge.activeTextView?.shiftRight() } + KeyToolbarButton(icon: "decrease.indent", label: "Unindent") { bridge.activeTextView?.shiftLeft() } + KeyToolbarDivider() + KeyToolbarButton(icon: "text.badge.slash", label: "Comment") { toggleComment() } + KeyToolbarDivider() + KeyToolbarText("(") { insertPair("(", ")") } + KeyToolbarText("[") { insertPair("[", "]") } + KeyToolbarText("{") { insertPair("{", "}") } + KeyToolbarDivider() + KeyToolbarText("->") { insert(" -> ") } + KeyToolbarText("??") { insert(" ?? ") } + KeyToolbarText("$0") { insert("$0") } + KeyToolbarDivider() + KeyToolbarButton(icon: "arrow.left", label: "Left") { moveCursor(-1) } + KeyToolbarButton(icon: "arrow.right", label: "Right") { moveCursor(+1) } + KeyToolbarDivider() + KeyToolbarButton(icon: "magnifyingglass", label: "Find") { withAnimation { showFindBar.toggle() } } + Spacer() + KeyToolbarButton(icon: "keyboard.chevron.compact.down", label: "Done") { + bridge.activeTextView?.resignFirstResponder() + } + } + .padding(.horizontal, 8).padding(.vertical, 6) + } + .background(.regularMaterial) + } + } + + private func insert(_ t: String) { bridge.activeTextView?.insertText(t) } + private func insertPair(_ l: String, _ r: String) { bridge.activeTextView?.insertText(l+r); moveCursor(-1) } + private func moveCursor(_ offset: Int) { + guard let tv = bridge.activeTextView, let sel = tv.selectedRange else { return } + tv.selectedRange = NSRange(location: max(0, sel.location + offset), length: 0) + } + private func toggleComment() { + guard let tv = bridge.activeTextView, let sel = tv.selectedRange else { return } + let text = tv.text as NSString + let lr = text.lineRange(for: sel) + let line = text.substring(with: lr) + let trimmed = line.trimmingCharacters(in: .whitespaces) + let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" })) + let newLine: String + if trimmed.hasPrefix("// ") { newLine = line.replacingFirstOccurrence(of: "// ", with: "") } + else if trimmed.hasPrefix("//") { newLine = line.replacingFirstOccurrence(of: "//", with: "") } + else { newLine = indent + "// " + line.dropFirst(indent.count) } + tv.replace(lr, withText: newLine) + } + private func findNext() { + guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return } + tv.selectNextOccurrence(matching: SearchOptions(query: searchQuery)) + } + private func findPrev() { + guard let tv = bridge.activeTextView, !searchQuery.isEmpty else { return } + tv.selectPreviousOccurrence(matching: SearchOptions(query: searchQuery)) + } +} + +struct InlineFindBar: View { + @Binding var query: String; @Binding var isRegex: Bool + var onNext: () -> Void; var onPrev: () -> Void; var onDismiss: () -> Void + var body: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Find…", text: $query).textFieldStyle(.plain) + .font(.system(.body, design: .monospaced)).autocorrectionDisabled().autocapitalization(.none) + .onSubmit(onNext) + Toggle(isOn: $isRegex) { Image(systemName: "ellipsis.curlybraces") } + .toggleStyle(.button).buttonStyle(.bordered).tint(isRegex ? .accentColor : .secondary) + Button(action: onPrev) { Image(systemName: "chevron.up") }.buttonStyle(.bordered).disabled(query.isEmpty) + Button(action: onNext) { Image(systemName: "chevron.down") }.buttonStyle(.bordered).disabled(query.isEmpty) + Button(action: onDismiss) { Image(systemName: "xmark") }.buttonStyle(.bordered) + } + .padding(.horizontal, 12).padding(.vertical, 6) + } +} + +struct KeyToolbarButton: View { + let icon: String; let label: String; let action: () -> Void + var body: some View { + Button(action: action) { Image(systemName: icon).frame(minWidth: 32, minHeight: 32).contentShape(Rectangle()) } + .buttonStyle(.plain).foregroundStyle(.primary).help(label) + } +} + +struct KeyToolbarText: View { + let text: String; let action: () -> Void + init(_ text: String, action: @escaping () -> Void) { self.text = text; self.action = action } + var body: some View { + Button(action: action) { + Text(text).font(.system(.callout, design: .monospaced).bold()) + .frame(minWidth: 32, minHeight: 32).contentShape(Rectangle()) + } + .buttonStyle(.plain).foregroundStyle(.primary) + } +} + +struct KeyToolbarDivider: View { + var body: some View { Divider().frame(height: 20).padding(.horizontal, 2) } +} diff --git a/Editor/EditorState.swift b/Editor/EditorState.swift new file mode 100644 index 0000000..ec1d2bb --- /dev/null +++ b/Editor/EditorState.swift @@ -0,0 +1,117 @@ +import SwiftUI +import Runestone // provides Theme protocol + +// MARK: - Open Tab + +struct EditorTab: Identifiable, Equatable { + let id = UUID() + let path: String + var content: String + var isDirty: Bool = false + var diagnostics: [DiagnosticRange] = [] + + var fileName: String { URL(fileURLWithPath: path).lastPathComponent } +} + +// MARK: - Editor State + +@MainActor +final class EditorState: ObservableObject { + + @Published var tabs: [EditorTab] = [] + @Published var activeTabId: UUID? + + // @AppStorage supports: Bool, Int, Double, String, Data, URL + // CGFloat → store as Double, expose computed CGFloat + // EditorThemeOption → store as String (rawValue), decode manually + @AppStorage("editorFontSize") var fontSizeStored: Double = 14 + @AppStorage("themeRawValue") var themeRawValue: String = EditorThemeOption.xcodeDefault.rawValue + @AppStorage("showLineNumbers") var showLineNumbers: Bool = true + @AppStorage("showInvisibles") var showInvisibles: Bool = false + @AppStorage("lineWrapping") var lineWrapping: Bool = true + @AppStorage("indentWidth") var indentWidth: Int = 4 + + /// Cast to CGFloat wherever Runestone or SwiftUI font APIs need it. + var fontSize: CGFloat { CGFloat(fontSizeStored) } + + /// Decoded from the stored rawValue string. + var selectedTheme: EditorThemeOption { + get { EditorThemeOption(rawValue: themeRawValue) ?? .xcodeDefault } + set { themeRawValue = newValue.rawValue } + } + + private var autosaveTask: Task? + var onSaveRequest: ((String, String) async -> Void)? + + // MARK: - Active Theme + + var activeTheme: any Theme { + switch selectedTheme { + case .xcodeDefault: return PadXcodeTheme() + case .xcodeLight: return PadXcodeLightTheme() + } + } + + // MARK: - Tab Management + + var activeTab: EditorTab? { + guard let id = activeTabId else { return nil } + return tabs.first { $0.id == id } + } + + func openFile(path: String, content: String) { + if let existing = tabs.first(where: { $0.path == path }) { + activeTabId = existing.id + return + } + let tab = EditorTab(path: path, content: content) + tabs.append(tab) + activeTabId = tab.id + } + + func closeTab(id: UUID) { + guard let idx = tabs.firstIndex(where: { $0.id == id }) else { return } + tabs.remove(at: idx) + if activeTabId == id { + activeTabId = tabs.isEmpty ? nil : tabs[min(idx, tabs.count - 1)].id + } + } + + func updateContent(tabId: UUID, newContent: String) { + guard let idx = tabs.firstIndex(where: { $0.id == tabId }) else { return } + tabs[idx].content = newContent + tabs[idx].isDirty = true + scheduleAutosave(tabId: tabId) + } + + // MARK: - Diagnostics + + func applyDiagnostics(_ diagnostics: [DiagnosticRange], toPath path: String) { + guard let idx = tabs.firstIndex(where: { $0.path == path }) else { return } + tabs[idx].diagnostics = diagnostics + } + + func clearDiagnostics(forPath path: String) { + guard let idx = tabs.firstIndex(where: { $0.path == path }) else { return } + tabs[idx].diagnostics = [] + } + + // MARK: - Autosave + + private func scheduleAutosave(tabId: UUID) { + autosaveTask?.cancel() + autosaveTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(1.5)) + guard !Task.isCancelled, + let self, + let tab = self.tabs.first(where: { $0.id == tabId }), + tab.isDirty else { return } + await self.onSaveRequest?(tab.path, tab.content) + if let idx = self.tabs.firstIndex(where: { $0.id == tabId }) { + self.tabs[idx].isDirty = false + } + } + } + + var dirtyTabNames: [String] { tabs.filter { $0.isDirty }.map { $0.fileName } } +} diff --git a/Editor/EditorTabBar.swift b/Editor/EditorTabBar.swift new file mode 100644 index 0000000..001cefc --- /dev/null +++ b/Editor/EditorTabBar.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct EditorTabBar: View { + @ObservedObject var editorState: EditorState + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(editorState.tabs) { tab in + EditorTabCell( + tab: tab, + isActive: tab.id == editorState.activeTabId, + onSelect: { editorState.activeTabId = tab.id }, + onClose: { editorState.closeTab(id: tab.id) } + ) + } + } + } + .frame(height: 36) + .background(Color(.secondarySystemBackground)) + .overlay(alignment: .bottom) { Divider() } + } +} + +struct EditorTabCell: View { + let tab: EditorTab + let isActive: Bool + let onSelect: () -> Void + let onClose: () -> Void + @State private var isHovered = false + + var body: some View { + HStack(spacing: 6) { + if tab.isDirty { + Circle().fill(Color.accentColor).frame(width: 6, height: 6) + } else { + Button(action: onClose) { + Image(systemName: "xmark").font(.system(size: 9, weight: .semibold)) + .frame(width: 14, height: 14).contentShape(Circle()) + } + .buttonStyle(.plain) + .opacity((isActive || isHovered) ? 1 : 0) + } + Image(systemName: fileIcon(for: tab.fileName)) + .font(.system(size: 11)).foregroundStyle(iconColor(for: tab.fileName)) + Text(tab.fileName) + .font(.system(.caption, design: .monospaced)).lineLimit(1) + } + .padding(.horizontal, 10).frame(height: 36) + .background(isActive ? Color(.systemBackground) : .clear) + .overlay(alignment: .bottom) { + if isActive { Rectangle().fill(Color.accentColor).frame(height: 2) } + } + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) + .onHover { isHovered = $0 } + } + + private func fileIcon(for name: String) -> String { + switch (name as NSString).pathExtension.lowercased() { + case "swift": return "swift"; case "json": return "curlybraces" + case "md": return "doc.text"; case "sh": return "terminal"; default: return "doc" + } + } + private func iconColor(for name: String) -> Color { + switch (name as NSString).pathExtension.lowercased() { + case "swift": return .orange; case "json": return .yellow + case "md": return .blue; default: return .secondary + } + } +} diff --git a/Editor/FindAcrossFilesView.swift b/Editor/FindAcrossFilesView.swift new file mode 100644 index 0000000..b97d9ba --- /dev/null +++ b/Editor/FindAcrossFilesView.swift @@ -0,0 +1,98 @@ +import SwiftUI +import Foundation + +struct FindAcrossFilesView: View { + let fileTree: FileNode? + let buildService: BuildService + let onOpenFile: (String, NSRange) -> Void + @Environment(\.dismiss) var dismiss + @State private var query = ""; @State private var isSearching = false; @State private var results: [FileResult] = [] + @FocusState private var focused: Bool + + struct FileResult: Identifiable { + let id = UUID(); let filePath: String; var matches: [Match] + var fileName: String { URL(fileURLWithPath: filePath).lastPathComponent } + struct Match: Identifiable { let id = UUID(); let range: NSRange; let lineNumber: Int; let lineText: String } + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search in project…", text: $query).textFieldStyle(.plain).font(.system(.body, design: .monospaced)) + .autocorrectionDisabled().autocapitalization(.none).focused($focused).onSubmit { Task { await runSearch() } } + Button { Task { await runSearch() } } label: { isSearching ? AnyView(ProgressView().scaleEffect(0.7)) : AnyView(Text("Search")) } + .buttonStyle(.borderedProminent).controlSize(.small).disabled(query.isEmpty || isSearching) + } + .padding(.horizontal, 14).padding(.vertical, 10).background(Color(.secondarySystemBackground)) + Divider() + if results.isEmpty { + ContentUnavailableView(query.isEmpty ? "Search Project" : "No Results", systemImage: "magnifyingglass") + } else { + List { + ForEach(results) { fr in + Section { + ForEach(fr.matches) { m in + HStack(alignment: .top, spacing: 10) { + Text("\(m.lineNumber)").font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary).frame(width: 36, alignment: .trailing) + Text(m.lineText).font(.system(.body, design: .monospaced)).lineLimit(1) + } + .onTapGesture { onOpenFile(fr.filePath, m.range); dismiss() } + } + } header: { + HStack { Text(fr.fileName).font(.subheadline.bold()); Spacer() + Text("\(fr.matches.count)").font(.caption2).padding(.horizontal, 6).padding(.vertical, 2).background(.quaternary, in: Capsule()) } + } + } + } + .listStyle(.plain) + } + } + .navigationTitle("Find in Project").navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } } + .onAppear { focused = true } + } + } + + private func runSearch() async { + guard !query.isEmpty, let tree = fileTree else { return } + isSearching = true; results = [] + let paths = collectPaths(from: tree).filter { isSearchable($0) } + var found: [FileResult] = [] + await withTaskGroup(of: FileResult?.self) { group in + for path in paths { + group.addTask { + guard let content = await buildService.fetchFileContent(path: path) else { return nil } + let matches = searchContent(content) + return matches.isEmpty ? nil : FileResult(filePath: path, matches: matches) + } + } + for await r in group { if let r { found.append(r) } } + } + results = found.sorted { $0.matches.count > $1.matches.count } + isSearching = false + } + + private func searchContent(_ content: String) -> [FileResult.Match] { + let lines = content.components(separatedBy: "\n"); var matches: [FileResult.Match] = []; var offset = 0 + let pattern = NSRegularExpression.escapedPattern(for: query) + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return [] } + for (i, line) in lines.enumerated() { + for m in regex.matches(in: line, range: NSRange(line.startIndex..., in: line)) { + matches.append(FileResult.Match(range: NSRange(location: offset+m.range.location, length: m.range.length), + lineNumber: i+1, lineText: line.trimmingCharacters(in: .whitespaces))) + } + offset += line.count + 1 + } + return matches + } + + private func collectPaths(from node: FileNode) -> [String] { + node.isDirectory ? (node.children ?? []).flatMap { collectPaths(from: $0) } : [node.path] + } + private func isSearchable(_ path: String) -> Bool { + ["swift","json","md","txt","yaml","yml","sh","xcconfig"].contains( + (path as NSString).pathExtension.lowercased()) + } +} diff --git a/Editor/FindInFileView.swift b/Editor/FindInFileView.swift new file mode 100644 index 0000000..f9d5666 --- /dev/null +++ b/Editor/FindInFileView.swift @@ -0,0 +1,73 @@ +import SwiftUI +import Foundation + +struct FindInFileView: View { + let content: String + let onNavigate: (NSRange) -> Void + @Environment(\.dismiss) var dismiss + @State private var query = ""; @State private var isRegex = false; @State private var isCaseSensitive = false + @State private var results: [Result] = []; @State private var selected = 0 + @FocusState private var focused: Bool + + struct Result: Identifiable { + let id = UUID(); let range: NSRange; let lineNumber: Int; let lineText: String + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Find…", text: $query).textFieldStyle(.plain).font(.system(.body, design: .monospaced)) + .autocorrectionDisabled().autocapitalization(.none).focused($focused).onSubmit { navigateNext() } + .onChange(of: query) { _, _ in runSearch() } + if !results.isEmpty { + Text("\(selected+1)/\(results.count)").font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary) + Button { navigatePrev() } label: { Image(systemName: "chevron.up") }.buttonStyle(.bordered).controlSize(.small) + Button { navigateNext() } label: { Image(systemName: "chevron.down") }.buttonStyle(.bordered).controlSize(.small) + } + } + .padding(.horizontal, 14).padding(.vertical, 10).background(Color(.secondarySystemBackground)) + Divider() + if results.isEmpty { + ContentUnavailableView(query.isEmpty ? "Type to search" : "No Results", systemImage: "magnifyingglass") + } else { + List(Array(results.enumerated()), id: \.element.id) { idx, r in + HStack(alignment: .top, spacing: 10) { + Text("\(r.lineNumber)").font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary).frame(width: 36, alignment: .trailing) + Text(r.lineText).font(.system(.body, design: .monospaced)).lineLimit(1) + } + .background(idx == selected ? Color.accentColor.opacity(0.1) : .clear) + .onTapGesture { selected = idx; onNavigate(r.range); dismiss() } + } + .listStyle(.plain) + } + } + .navigationTitle("Find in File").navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } } + .onAppear { focused = true } + } + .presentationDetents([.medium, .large]) + } + + private func runSearch() { + guard !query.isEmpty else { results = []; return } + let lines = content.components(separatedBy: "\n") + var found: [Result] = []; var offset = 0 + let pattern = isRegex ? query : NSRegularExpression.escapedPattern(for: query) + let options: NSRegularExpression.Options = isCaseSensitive ? [] : .caseInsensitive + guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { results = []; return } + for (i, line) in lines.enumerated() { + let r = NSRange(line.startIndex..., in: line) + for m in regex.matches(in: line, range: r) { + found.append(Result(range: NSRange(location: offset + m.range.location, length: m.range.length), + lineNumber: i + 1, lineText: line.trimmingCharacters(in: .whitespaces))) + } + offset += line.count + 1 + } + results = found; if !found.isEmpty { selected = 0; onNavigate(found[0].range) } + } + + private func navigateNext() { guard !results.isEmpty else { return }; selected = (selected+1) % results.count; onNavigate(results[selected].range) } + private func navigatePrev() { guard !results.isEmpty else { return }; selected = (selected-1+results.count) % results.count; onNavigate(results[selected].range) } +} diff --git a/Editor/GutterAnnotationView.swift b/Editor/GutterAnnotationView.swift new file mode 100644 index 0000000..e6b723b --- /dev/null +++ b/Editor/GutterAnnotationView.swift @@ -0,0 +1,124 @@ +// GutterAnnotationView.swift +// DiagnosticRange is defined in Shared/SharedModels.swift +// No Identifiable conformance extension needed here. +import SwiftUI +import Runestone + +// MARK: - Annotation Model (local to this file — wraps DiagnosticRange with position) + +struct GutterAnnotation: Identifiable { + let id = UUID() + let line: Int + let severity: DiagnosticRange.Severity + let message: String + + var color: Color { severity == .error ? Color(hex: "#F44747") : Color(hex: "#CCA700") } + var icon: String { severity == .error ? "xmark.circle.fill" : "exclamationmark.triangle.fill" } +} + +// MARK: - Overlay + +struct GutterAnnotationOverlay: View { + let diagnostics: [DiagnosticRange] + weak var textView: Runestone.TextView? + + @State private var annotations: [PositionedAnnotation] = [] + @State private var selected: GutterAnnotation? + + struct PositionedAnnotation: Identifiable { + let id = UUID() + let annotation: GutterAnnotation + let y: CGFloat + } + + var body: some View { + GeometryReader { _ in + ZStack(alignment: .topLeading) { + ForEach(annotations) { positioned in + annotationBadge(for: positioned) + } + } + } + .onAppear { computePositions() } + .onChange(of: diagnostics) { _, _ in computePositions() } + .popover(item: $selected) { annotation in + DiagnosticPopover(annotation: annotation) + } + } + + @ViewBuilder + private func annotationBadge(for positioned: PositionedAnnotation) -> some View { + HStack(spacing: 4) { + Image(systemName: positioned.annotation.icon) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(positioned.annotation.color) + Text(positioned.annotation.message) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(positioned.annotation.color) + .lineLimit(1) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(positioned.annotation.color.opacity(0.12), in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(positioned.annotation.color.opacity(0.3), lineWidth: 0.5)) + .position(x: 200, y: positioned.y) + .onTapGesture { selected = positioned.annotation } + .transition(.opacity) + } + + private func computePositions() { + guard let tv = textView else { return } + var result: [PositionedAnnotation] = [] + + for diag in diagnostics { + let ann = GutterAnnotation(line: diag.line, severity: diag.severity, message: diag.message) + guard let range = tv.range(atLine: ann.line, column: 0), + let rect = tv.caretRect(for: range.location) else { continue } + let y = rect.midY - tv.contentOffset.y + if y > 0 && y < tv.bounds.height { + result.append(PositionedAnnotation(annotation: ann, y: y)) + } + } + + withAnimation(.easeInOut(duration: 0.15)) { annotations = result } + } +} + +// MARK: - Diagnostic Popover + +struct DiagnosticPopover: View { + let annotation: GutterAnnotation + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: annotation.icon).foregroundStyle(annotation.color) + Text(annotation.severity == .error ? "Error" : "Warning") + .font(.subheadline.bold()).foregroundStyle(annotation.color) + Spacer() + Text("Line \(annotation.line)").font(.caption).foregroundStyle(.secondary) + } + Divider() + Text(annotation.message) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + .padding(12) + .frame(minWidth: 280, maxWidth: 420) + .presentationCompactAdaptation(.popover) + } +} + +// MARK: - Hex colour helper (local) +private extension Color { + init(hex: String) { + var h = hex.trimmingCharacters(in: .whitespacesAndNewlines) + h = h.hasPrefix("#") ? String(h.dropFirst()) : h + var rgb: UInt64 = 0 + Scanner(string: h).scanHexInt64(&rgb) + self.init( + red: Double((rgb >> 16) & 0xFF) / 255, + green: Double((rgb >> 8) & 0xFF) / 255, + blue: Double( rgb & 0xFF) / 255 + ) + } +} diff --git a/Editor/HardwareKeyboardCommandHandler.swift b/Editor/HardwareKeyboardCommandHandler.swift new file mode 100644 index 0000000..df3fdb0 --- /dev/null +++ b/Editor/HardwareKeyboardCommandHandler.swift @@ -0,0 +1,155 @@ +import UIKit +import SwiftUI +import Runestone + +final class EditorCommandHandler: UIViewController { + + weak var textView: Runestone.TextView? + var onSave: (() -> Void)? + var onBuild: (() -> Void)? + var onRun: (() -> Void)? + var onToggleNavigator: (() -> Void)? + var onToggleConsole: (() -> Void)? + var onFind: (() -> Void)? + var onJumpToLine: (() -> Void)? + var onFindInProject: (() -> Void)? + + override var canBecomeFirstResponder: Bool { true } + + override var keyCommands: [UIKeyCommand]? { + let cmds: [(String, String, UIKeyModifierFlags)] = [ + ("Save", "s", .command), + ("Undo", "z", .command), + ("Redo", "z", [.command, .shift]), + ("Indent", "]", .command), + ("Unindent", "[", .command), + ("Toggle Comment", "/", .command), + ("Move Line Up", UIKeyCommand.inputUpArrow, [.command, .alternate]), + ("Move Line Down", UIKeyCommand.inputDownArrow, [.command, .alternate]), + ("Duplicate Line", "d", [.command, .shift]), + ("Delete Line", "k", [.command, .shift]), + ("Find", "f", .command), + ("Find in Project", "f", [.command, .shift]), + ("Find Next", "g", .command), + ("Find Prev", "g", [.command, .shift]), + ("Jump to Line", "l", .command), + ("Top", UIKeyCommand.inputUpArrow, .command), + ("Bottom", UIKeyCommand.inputDownArrow, .command), + ("Build", "b", .command), + ("Run", "r", .command), + ("Navigator", "0", .command), + ("Console", "y", [.command, .shift]), + ("Font+", "+", .command), + ("Font-", "-", .command), + ] + return cmds.map { title, input, mods in + let sel = selectorFor(title) + let kc = UIKeyCommand(title: title, action: sel, input: input, modifierFlags: mods, discoverabilityTitle: title) + if [UIKeyCommand.inputUpArrow, UIKeyCommand.inputDownArrow].contains(input) { + kc.wantsPriorityOverSystemBehavior = true + } + return kc + } + } + + private func selectorFor(_ title: String) -> Selector { + switch title { + case "Save": return #selector(cmdSave) + case "Undo": return #selector(cmdUndo) + case "Redo": return #selector(cmdRedo) + case "Indent": return #selector(cmdIndent) + case "Unindent": return #selector(cmdUnindent) + case "Toggle Comment": return #selector(cmdComment) + case "Move Line Up": return #selector(cmdMoveUp) + case "Move Line Down": return #selector(cmdMoveDown) + case "Duplicate Line": return #selector(cmdDuplicate) + case "Delete Line": return #selector(cmdDeleteLine) + case "Find": return #selector(cmdFind) + case "Find in Project": return #selector(cmdFindProject) + case "Find Next": return #selector(cmdFindNext) + case "Find Prev": return #selector(cmdFindPrev) + case "Jump to Line": return #selector(cmdJumpToLine) + case "Top": return #selector(cmdTop) + case "Bottom": return #selector(cmdBottom) + case "Build": return #selector(cmdBuild) + case "Run": return #selector(cmdRun) + case "Navigator": return #selector(cmdNavigator) + case "Console": return #selector(cmdConsole) + case "Font+": return #selector(cmdFontBigger) + default: return #selector(cmdFontSmaller) + } + } + + @objc func cmdSave() { onSave?() } + @objc func cmdUndo() { textView?.undoManager?.undo() } + @objc func cmdRedo() { textView?.undoManager?.redo() } + @objc func cmdIndent() { textView?.shiftRight() } + @objc func cmdUnindent() { textView?.shiftLeft() } + @objc func cmdFind() { onFind?() } + @objc func cmdFindProject(){ onFindInProject?() } + @objc func cmdFindNext() {} + @objc func cmdFindPrev() {} + @objc func cmdJumpToLine() { onJumpToLine?() } + @objc func cmdBuild() { onBuild?() } + @objc func cmdRun() { onRun?() } + @objc func cmdNavigator() { onToggleNavigator?() } + @objc func cmdConsole() { onToggleConsole?() } + @objc func cmdFontBigger() {} + @objc func cmdFontSmaller(){} + + @objc func cmdComment() { + guard let tv = textView, let sel = tv.selectedRange else { return } + let text = tv.text as NSString; let lr = text.lineRange(for: sel) + let line = text.substring(with: lr); let trimmed = line.trimmingCharacters(in: .whitespaces) + let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" })) + let new: String + if trimmed.hasPrefix("// ") { new = line.replacingFirstOccurrence(of: "// ", with: "") } + else if trimmed.hasPrefix("//") { new = line.replacingFirstOccurrence(of: "//", with: "") } + else { new = indent + "// " + line.dropFirst(indent.count) } + tv.replace(lr, withText: new) + } + + @objc func cmdMoveUp() { + guard let tv = textView, let sel = tv.selectedRange else { return } + let text = tv.text as NSString; let cur = text.lineRange(for: sel) + guard cur.location > 0 else { return } + let prev = text.lineRange(for: NSRange(location: cur.location - 1, length: 0)) + let curLine = text.substring(with: cur); let prevLine = text.substring(with: prev) + tv.replace(NSRange(location: prev.location, length: prev.length + cur.length), withText: curLine + prevLine) + tv.selectedRange = NSRange(location: prev.location + curLine.count - 1, length: 0) + } + + @objc func cmdMoveDown() { + guard let tv = textView, let sel = tv.selectedRange else { return } + let text = tv.text as NSString; let cur = text.lineRange(for: sel) + let end = cur.location + cur.length; guard end < text.length else { return } + let next = text.lineRange(for: NSRange(location: end, length: 0)) + let curLine = text.substring(with: cur); let nextLine = text.substring(with: next) + tv.replace(NSRange(location: cur.location, length: cur.length + next.length), withText: nextLine + curLine) + tv.selectedRange = NSRange(location: cur.location + next.length, length: 0) + } + + @objc func cmdDuplicate() { + guard let tv = textView, let sel = tv.selectedRange else { return } + let text = tv.text as NSString; let lr = text.lineRange(for: sel) + let line = text.substring(with: lr) + tv.replace(NSRange(location: lr.location + lr.length, length: 0), withText: line) + } + + @objc func cmdDeleteLine() { + guard let tv = textView, let sel = tv.selectedRange else { return } + let text = tv.text as NSString + tv.replace(text.lineRange(for: sel), withText: "") + } + + @objc func cmdTop() { + textView?.selectedRange = NSRange(location: 0, length: 0) + textView?.scrollRangeToVisible(NSRange(location: 0, length: 0)) + } + + @objc func cmdBottom() { + guard let tv = textView else { return } + let end = NSRange(location: (tv.text as NSString).length, length: 0) + tv.selectedRange = end; tv.scrollRangeToVisible(end) + } +} diff --git a/Editor/JumpToLineView.swift b/Editor/JumpToLineView.swift new file mode 100644 index 0000000..1da8ffc --- /dev/null +++ b/Editor/JumpToLineView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct JumpToLineView: View { + @Binding var isPresented: Bool + let totalLines: Int + let onJump: (Int) -> Void + @State private var input = "" + @FocusState private var focused: Bool + + var body: some View { + VStack(spacing: 16) { + Text("Jump to Line").font(.headline) + HStack { + TextField("Line (1–\(totalLines))", text: $input) + .textFieldStyle(.roundedBorder).keyboardType(.numberPad).focused($focused).onSubmit { commit() } + Button("Go", action: commit).buttonStyle(.borderedProminent).disabled(parsed == nil) + } + } + .padding(24).frame(width: 320) + .onAppear { focused = true } + } + + private var parsed: Int? { + guard let n = Int(input), n >= 1, n <= totalLines else { return nil } + return n + } + private func commit() { + guard let line = parsed else { return } + onJump(line); isPresented = false + } +} diff --git a/Editor/RunestoneEditorView.swift b/Editor/RunestoneEditorView.swift new file mode 100644 index 0000000..1657263 --- /dev/null +++ b/Editor/RunestoneEditorView.swift @@ -0,0 +1,108 @@ +import SwiftUI +import UIKit +import Runestone + +struct RunestoneEditorView: UIViewRepresentable { + + @Binding var content: String + @Binding var textViewRef: Runestone.TextView? + let filePath: String + let theme: any Theme + let showLineNumbers: Bool + let lineWrapping: Bool + let indentWidth: Int + var diagnosticRanges: [DiagnosticRange] = [] + var onContentChange: ((String) -> Void)? + var onCursorPositionChange: ((Int, Int) -> Void)? + @ObservedObject var completionController: CompletionController + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIView(context: Context) -> Runestone.TextView { + let tv = Runestone.TextView() + configureAppearance(tv); configureEditing(tv) + tv.editorDelegate = context.coordinator + completionController.attach(to: tv) + DispatchQueue.main.async { textViewRef = tv } + loadState(into: tv) + return tv + } + + func updateUIView(_ tv: Runestone.TextView, context: Context) { + if tv.text != content && !context.coordinator.isEditing { loadState(into: tv) } + tv.showLineNumbers = showLineNumbers + tv.isLineWrappingEnabled = lineWrapping + tv.indentStrategy = indentWidth == 0 ? .tab : .space(length: indentWidth) + applyDiagnostics(to: tv) + } + + private func loadState(into tv: Runestone.TextView) { + let lang = LanguageDetector.language(for: filePath) + let capturedContent = content; let capturedTheme = theme + Task.detached(priority: .userInitiated) { + let state = TextViewState(text: capturedContent, theme: capturedTheme, + language: lang.map { TreeSitterLanguageMode(language: $0) }) + await MainActor.run { tv.setState(state) } + } + } + + private func configureAppearance(_ tv: Runestone.TextView) { + tv.backgroundColor = UIColor(hex: "#1E1E1E") + tv.showLineNumbers = showLineNumbers + tv.showSpaces = false; tv.showTabs = false; tv.showLineBreaks = false + tv.isLineWrappingEnabled = lineWrapping + tv.highlightSelectedLine = true + tv.lineHeightMultiplier = 1.4; tv.kern = 0.3 + tv.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 120, right: 0) + tv.verticalOverscrollFactor = 0.5 + } + + private func configureEditing(_ tv: Runestone.TextView) { + tv.isEditable = true; tv.autocorrectionType = .no; tv.autocapitalizationType = .none + tv.smartDashesType = .no; tv.smartQuotesType = .no + tv.smartInsertDeleteType = .no; tv.spellCheckingType = .no + tv.characterPairs = [ + BasicCharacterPair(leading: "(", trailing: ")"), + BasicCharacterPair(leading: "[", trailing: "]"), + BasicCharacterPair(leading: "{", trailing: "}"), + BasicCharacterPair(leading: "\"", trailing: "\""), + ] + tv.characterPairTrailingComponentDeletionMode = .immediatelyFollowingLeadingComponent + tv.indentStrategy = indentWidth == 0 ? .tab : .space(length: indentWidth) + } + + private func applyDiagnostics(to tv: Runestone.TextView) { + guard !diagnosticRanges.isEmpty else { tv.setHighlightedRanges([]); return } + let highlights: [HighlightedRange] = diagnosticRanges.compactMap { diag in + guard let range = tv.range(atLine: diag.line, column: diag.column) else { return nil } + let color: UIColor = diag.severity == .error ? UIColor(hex: "#F44747") : UIColor(hex: "#CCA700") + return HighlightedRange(range: range, color: color, cornerRadius: 0, underlineStyle: .single) + } + tv.setHighlightedRanges(highlights) + } + + // MARK: - Coordinator + + final class Coordinator: NSObject, TextViewDelegate { + var parent: RunestoneEditorView + var isEditing = false + init(_ parent: RunestoneEditorView) { self.parent = parent } + + func textViewDidChange(_ tv: Runestone.TextView) { + isEditing = true + parent.content = tv.text + parent.onContentChange?(tv.text) + parent.completionController.textDidChange(in: tv, filePath: parent.filePath) + isEditing = false + } + + func textViewDidChangeSelection(_ tv: Runestone.TextView) { + guard let sel = tv.selectedRange, let loc = tv.textLocation(at: sel.location) else { return } + parent.onCursorPositionChange?(loc.lineNumber, loc.column) + if !isEditing { parent.completionController.dismiss() } + } + + func textViewDidBeginEditing(_ tv: Runestone.TextView) { isEditing = true } + func textViewDidEndEditing(_ tv: Runestone.TextView) { isEditing = false; parent.completionController.dismiss() } + } +} diff --git a/Git/GitCallbackHandler.swift b/Git/GitCallbackHandler.swift new file mode 100644 index 0000000..8d43902 --- /dev/null +++ b/Git/GitCallbackHandler.swift @@ -0,0 +1,74 @@ +import Foundation +import UIKit + +@MainActor +final class GitCallbackHandler { + + private let store: GitStore + init(store: GitStore) { self.store = store } + + func handle(url: URL) { + guard url.scheme == "padxcode", url.host == "git-callback" else { return } + let params = parseQuery(url) + store.pendingOperation = nil + switch params["action"] ?? "" { + case "repos": handleRepos(params) + case "status": handleStatus(params) + case "log": handleLog(params) + case "branches": handleBranches(params) + case "checkout": NotificationCenter.default.post(name: .gitCheckoutCompleted, object: nil) + case "commit": store.fileStatuses = []; NotificationCenter.default.post(name: .gitCommitCompleted, object: nil) + case "push": NotificationCenter.default.post(name: .gitPushCompleted, object: nil) + case "pull","fetch": NotificationCenter.default.post(name: .gitSyncCompleted, object: nil) + case "merge": NotificationCenter.default.post(name: .gitMergeCompleted, object: nil) + case "write": NotificationCenter.default.post(name: .gitWriteCompleted, object: nil) + case "read": handleRead(params) + case "error": + let msg = params["errorMessage"] ?? "Working Copy error." + store.lastError = msg + NotificationCenter.default.post(name: .gitOperationFailed, object: nil, + userInfo: ["message": msg]) + default: break + } + } + + private func handleRepos(_ p: [String: String]) { + guard let json = extractJSON(p), + let repos = try? JSONDecoder().decode([GitRepository].self, from: json) else { return } + store.repositories = repos + } + private func handleStatus(_ p: [String: String]) { + guard let json = extractJSON(p), + let statuses = try? JSONDecoder().decode([GitFileStatus].self, from: json) else { return } + store.fileStatuses = statuses.filter { $0.status != "unchanged" } + } + private func handleLog(_ p: [String: String]) { + guard let json = extractJSON(p), + let commits = try? JSONDecoder().decode([GitCommit].self, from: json) else { return } + store.commitLog = commits + } + private func handleBranches(_ p: [String: String]) { + guard let json = extractJSON(p), + let branches = try? JSONDecoder().decode([GitBranch].self, from: json) else { return } + store.branches = branches + if let current = branches.first(where: { !$0.isRemote }) { store.currentBranch = current.name } + } + private func handleRead(_ p: [String: String]) { + guard let repo = p["repo"], let path = p["path"], let text = p["text"] else { return } + let decoded = text.removingPercentEncoding ?? text + NotificationCenter.default.post(name: .gitReadCompleted, object: nil, + userInfo: ["repo": repo, "path": path, "content": decoded]) + } + + private func extractJSON(_ params: [String: String]) -> Data? { + let raw = params[""] ?? params["text"] ?? "" + return (raw.removingPercentEncoding ?? raw).data(using: .utf8) + } + private func parseQuery(_ url: URL) -> [String: String] { + guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + else { return [:] } + var result: [String: String] = [:] + for item in items { result[item.name] = item.value ?? "" } + return result + } +} diff --git a/Git/GitPanel.swift b/Git/GitPanel.swift new file mode 100644 index 0000000..7cff82b --- /dev/null +++ b/Git/GitPanel.swift @@ -0,0 +1,262 @@ +import SwiftUI +import UIKit + +struct GitPanel: View { + @ObservedObject var gitStore: GitStore + let gitService: GitService + + @State private var tab: PanelTab = .changes + @State private var showCommit = false + @State private var showBranchPicker = false + @State private var showNewBranch = false + @State private var commitMessage = "" + @State private var newBranchName = "" + + enum PanelTab: String, CaseIterable { + case changes = "Changes"; case history = "History"; case branches = "Branches" + var icon: String { switch self { case .changes: return "square.and.pencil"; case .history: return "clock"; case .branches: return "arrow.triangle.branch" } } + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack(spacing: 8) { + Menu { + ForEach(gitStore.repositories) { repo in + Button(repo.name) { + gitStore.activeRepo = repo.name + gitService.fetchStatus(repo: repo.name) + gitService.fetchBranches(repo: repo.name) + } + } + Divider() + Button { gitService.listRepositories() } label: { Label("Refresh Repos", systemImage: "arrow.clockwise") } + } label: { + HStack(spacing: 4) { + Image(systemName: "externaldrive.fill").font(.caption) + Text(gitStore.activeRepo.isEmpty ? "Select Repo" : gitStore.activeRepo) + .font(.system(.caption, design: .monospaced)).lineLimit(1) + Image(systemName: "chevron.down").font(.caption2) + } + .foregroundStyle(.primary) + } + Spacer() + Button { showBranchPicker = true; gitService.fetchBranches(repo: gitStore.activeRepo) } label: { + HStack(spacing: 3) { + Image(systemName: "arrow.triangle.branch").font(.caption) + Text(gitStore.currentBranch).font(.system(.caption, design: .monospaced)).lineLimit(1) + } + .padding(.horizontal, 6).padding(.vertical, 3) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 4)) + } + .disabled(gitStore.activeRepo.isEmpty) + Button { gitService.pull(repo: gitStore.activeRepo) } label: { Image(systemName: "arrow.triangle.2.circlepath").font(.caption) } + .disabled(gitStore.activeRepo.isEmpty) + } + .padding(.horizontal, 10).padding(.vertical, 8) + + Divider() + + Picker("", selection: $tab) { + ForEach(PanelTab.allCases, id: \.self) { t in Label(t.rawValue, systemImage: t.icon).tag(t) } + } + .pickerStyle(.segmented).padding(.horizontal, 8).padding(.vertical, 6) + + Divider() + + switch tab { + case .changes: changesTab + case .history: historyTab + case .branches: branchesTab + } + } + .sheet(isPresented: $showCommit) { + commitSheet + } + .sheet(isPresented: $showBranchPicker) { + NavigationStack { + List(gitStore.branches) { branch in + Button { + showBranchPicker = false + gitService.checkout(repo: gitStore.activeRepo, + branch: branch.isRemote ? branch.localName : branch.name) + } label: { + HStack { + Image(systemName: branch.isRemote ? "cloud" : "arrow.triangle.branch").font(.caption).foregroundStyle(.secondary) + Text(branch.name).font(.system(.body, design: .monospaced)) + Spacer() + if branch.name == gitStore.currentBranch { Image(systemName: "checkmark").foregroundStyle(.accentColor) } + } + }.buttonStyle(.plain) + } + .navigationTitle("Switch Branch").navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { showBranchPicker = false } } } + } + .presentationDetents([.medium]) + } + .onReceive(NotificationCenter.default.publisher(for: .gitCheckoutCompleted)) { _ in + guard !gitStore.activeRepo.isEmpty else { return } + gitService.fetchBranches(repo: gitStore.activeRepo) + gitService.fetchStatus(repo: gitStore.activeRepo) + } + .onReceive(NotificationCenter.default.publisher(for: .gitCommitCompleted)) { _ in + guard !gitStore.activeRepo.isEmpty else { return } + gitService.fetchStatus(repo: gitStore.activeRepo) + } + } + + // MARK: - Changes Tab + private var changesTab: some View { + VStack(spacing: 0) { + if gitStore.fileStatuses.isEmpty { + ContentUnavailableView("No Changes", systemImage: "checkmark.circle", + description: Text("Working tree is clean.")) + } else { + List(gitStore.fileStatuses) { file in + HStack(spacing: 8) { + Image(systemName: file.statusIcon).font(.caption).foregroundStyle(file.statusSwiftUIColor).frame(width: 16) + VStack(alignment: .leading, spacing: 1) { + Text(file.name).font(.system(.body, design: .monospaced)) + Text(file.path).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Text({ switch file.status { case "modified": return "M"; case "added": return "A"; case "deleted": return "D"; default: return "?" } }()) + .font(.system(.caption2, design: .monospaced).bold()) + .foregroundStyle(file.statusSwiftUIColor) + } + } + .listStyle(.plain) + } + Divider() + HStack { + Button { gitService.fetchStatus(repo: gitStore.activeRepo) } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.plain) + Spacer() + Text("\(gitStore.fileStatuses.count) changed").font(.caption2).foregroundStyle(.secondary) + Spacer() + Button("Commit…") { showCommit = true } + .buttonStyle(.borderedProminent).controlSize(.small) + .disabled(gitStore.fileStatuses.isEmpty || gitStore.activeRepo.isEmpty) + } + .padding(.horizontal, 10).padding(.vertical, 8) + } + } + + // MARK: - History Tab + private var historyTab: some View { + Group { + if gitStore.commitLog.isEmpty { + ContentUnavailableView("No History", systemImage: "clock", + description: Text("Tap refresh to load commits.")) + .onAppear { if !gitStore.activeRepo.isEmpty { gitService.fetchLog(repo: gitStore.activeRepo) } } + } else { + List(gitStore.commitLog) { commit in + VStack(alignment: .leading, spacing: 3) { + Text(commit.summary).font(.subheadline).lineLimit(2) + HStack(spacing: 6) { + Text(commit.shortId).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary) + .padding(.horizontal, 4).padding(.vertical, 1) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) + Text(commit.author.components(separatedBy: "<").first?.trimmingCharacters(in: .whitespaces) ?? commit.author) + .font(.caption).foregroundStyle(.secondary) + Spacer() + Text(commit.formattedDate).font(.caption2).foregroundStyle(.tertiary) + } + } + .padding(.vertical, 2) + } + .listStyle(.plain) + } + } + } + + // MARK: - Branches Tab + private var branchesTab: some View { + List { + Section("Local") { + ForEach(gitStore.branches.filter { !$0.isRemote }) { b in + branchRow(b) + } + Button { showNewBranch = true } label: { Label("New Branch…", systemImage: "plus").font(.subheadline) } + } + let remotes = gitStore.branches.filter { $0.isRemote } + if !remotes.isEmpty { + Section("Remote") { ForEach(remotes) { b in branchRow(b) } } + } + } + .listStyle(.plain) + .sheet(isPresented: $showNewBranch) { + NavigationStack { + Form { + Section("Branch Name") { + TextField("feature/my-feature", text: $newBranchName) + .autocorrectionDisabled().autocapitalization(.none) + } + } + .navigationTitle("New Branch").navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { showNewBranch = false; newBranchName = "" } } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { + gitService.createBranch(repo: gitStore.activeRepo, branch: newBranchName) + showNewBranch = false; newBranchName = "" + } + .disabled(newBranchName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + .presentationDetents([.height(220)]) + } + } + + private func branchRow(_ branch: GitBranch) -> some View { + Button { gitService.checkout(repo: gitStore.activeRepo, branch: branch.isRemote ? branch.localName : branch.name) } + label: { + HStack { + Image(systemName: branch.isRemote ? "cloud" : "arrow.triangle.branch") + .font(.caption).foregroundStyle(branch.name == gitStore.currentBranch ? .accentColor : .secondary) + Text(branch.name).font(.system(.body, design: .monospaced)) + .foregroundStyle(branch.name == gitStore.currentBranch ? .accentColor : .primary) + .bold(branch.name == gitStore.currentBranch) + Spacer() + if branch.name == gitStore.currentBranch { Text("current").font(.caption2).foregroundStyle(.accentColor) } + } + } + .buttonStyle(.plain) + } + + // MARK: - Commit Sheet + private var commitSheet: some View { + NavigationStack { + Form { + Section("Changed Files (\(gitStore.fileStatuses.count))") { + ForEach(gitStore.fileStatuses) { f in + HStack(spacing: 6) { + Image(systemName: f.statusIcon).font(.caption).foregroundStyle(f.statusSwiftUIColor).frame(width: 16) + Text(f.name).font(.system(.caption, design: .monospaced)) + } + } + } + Section("Commit Message") { + TextEditor(text: $commitMessage) + .font(.system(.body, design: .monospaced)).frame(minHeight: 80) + .autocorrectionDisabled().autocapitalization(.sentences) + } + Section { + Button("Commit & Push") { + gitService.commitAndPush(repo: gitStore.activeRepo, message: commitMessage) + commitMessage = ""; showCommit = false + } + .disabled(commitMessage.trimmingCharacters(in: .whitespaces).isEmpty) + Button("Commit Only") { + gitService.commit(repo: gitStore.activeRepo, message: commitMessage) + commitMessage = ""; showCommit = false + } + .disabled(commitMessage.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .navigationTitle("Commit Changes").navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { showCommit = false } } } + } + .presentationDetents([.medium, .large]) + } +} diff --git a/Git/GitService.swift b/Git/GitService.swift new file mode 100644 index 0000000..0979c8b --- /dev/null +++ b/Git/GitService.swift @@ -0,0 +1,117 @@ +import UIKit + +/// Builds and launches Working Copy x-callback-url commands. +/// Works with any remote — GitHub, local bare repo over SSH via Tailscale, +/// Gitea, etc. The remote URL is managed entirely inside Working Copy; +/// PadXcode only needs the repo display name. +final class GitService { + + private let apiKey: String + private let callbackBase = "padxcode://git-callback" + + init(apiKey: String) { self.apiKey = apiKey } + + // MARK: - Navigation + + func listRepositories() { open(build("repos", [:])) } + + func fetchStatus(repo: String) { + open(build("status", ["repo": repo, "unchanged": "1"], success: "status")) + } + func fetchLog(repo: String, limit: Int = 30) { + open(build("log", ["repo": repo, "limit": "\(limit)"], success: "log")) + } + func fetchBranches(repo: String) { + open(build("branches", ["repo": repo], success: "branches")) + } + + // MARK: - Branch operations + + func checkout(repo: String, branch: String) { + open(build("checkout", ["repo": repo, "branch": branch], success: "checkout")) + } + func createBranch(repo: String, branch: String) { + open(build("checkout", ["repo": repo, "branch": branch, "mode": "create"], success: "checkout")) + } + func merge(repo: String, branch: String) { + open(build("merge", ["repo": repo, "branch": branch], success: "merge")) + } + + // MARK: - Commit / Push / Pull + + func commit(repo: String, message: String, path: String = "", limit: Int = 999) { + var p: [String: String] = ["repo": repo, "message": message, "limit": "\(limit)"] + if !path.isEmpty { p["path"] = path } + open(build("commit", p, success: "commit")) + } + func push(repo: String) { open(build("push", ["repo": repo], success: "push")) } + func pull(repo: String) { open(build("pull", ["repo": repo], success: "pull")) } + func fetch(repo: String) { open(build("fetch", ["repo": repo], success: "fetch")) } + + /// Commit then push in one Working Copy session using the `chain` command. + /// This is the primary action used by the Git panel Commit & Push button. + func commitAndPush(repo: String, message: String, path: String = "") { + var components = URLComponents(string: "working-copy://x-callback-url/chain")! + var items: [URLQueryItem] = [ + .init(name: "key", value: apiKey), + .init(name: "repo", value: repo), + .init(name: "x-error", value: "\(callbackBase)?action=error"), + .init(name: "command", value: "commit"), + .init(name: "message", value: message), + .init(name: "limit", value: "999"), + ] + if !path.isEmpty { items.append(.init(name: "path", value: path)) } + items += [ + .init(name: "command", value: "push"), + .init(name: "x-success", value: "\(callbackBase)?action=push"), + ] + components.queryItems = items + if let url = components.url { UIApplication.shared.open(url) } + } + + // MARK: - File operations + + /// Write a file into the Working Copy repo (called by FileSyncPipeline on every save). + /// mode=overwrite: replaces even if the file has uncommitted changes. + /// askCommit=false: silent write, user commits manually from the Git panel. + func writeFile(repo: String, path: String, content: String, askCommit: Bool = false) { + var p: [String: String] = ["repo": repo, "path": path, "text": content, "mode": "overwrite"] + if askCommit { p["askcommit"] = "1" } + open(build("write", p, success: "write")) + } + + // MARK: - Open in Working Copy + + func openInWorkingCopy(repo: String, path: String, mode: String = "content") { + var c = URLComponents(string: "working-copy://open")! + c.queryItems = [.init(name: "repo", value: repo), + .init(name: "path", value: path), + .init(name: "mode", value: mode)] + if let url = c.url { UIApplication.shared.open(url) } + } + + // MARK: - URL Builder + + private func build( + _ command: String, + _ params: [String: String], + success action: String? = nil + ) -> URL { + var c = URLComponents(string: "working-copy://x-callback-url/\(command)")! + var items: [URLQueryItem] = [ + .init(name: "key", value: apiKey), + .init(name: "x-error", value: "\(callbackBase)?action=error"), + ] + if let action { + // Append a trailing "&" so Working Copy appends the JSON result as a bare value + items.append(.init(name: "x-success", value: "\(callbackBase)?action=\(action)&")) + } + for (k, v) in params { items.append(.init(name: k, value: v)) } + c.queryItems = items + return c.url! + } + + private func open(_ url: URL) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } +} diff --git a/Git/GitStore.swift b/Git/GitStore.swift new file mode 100644 index 0000000..e63509b --- /dev/null +++ b/Git/GitStore.swift @@ -0,0 +1,76 @@ +import Foundation +import SwiftUI +import UIKit + +struct GitRepository: Identifiable, Codable { + var id: String { name } + let name: String; let branch: String; let head: String + let status: String; let remotes: [GitRemote] +} +struct GitRemote: Codable { + let name: String; let url: String; let fetch: Bool; let push: Bool +} +struct GitFileStatus: Identifiable, Codable { + var id: String { path } + let name: String; let path: String; let status: String; let kind: String + + var statusIcon: String { + switch status { + case "modified": return "pencil.circle.fill" + case "added": return "plus.circle.fill" + case "deleted": return "minus.circle.fill" + case "untracked": return "questionmark.circle.fill" + default: return "circle" + } + } + var statusSwiftUIColor: Color { + switch status { + case "modified": return .orange + case "added": return .green + case "deleted": return .red + case "untracked": return .secondary + default: return .primary + } + } +} +struct GitCommit: Identifiable, Codable { + var id: String { commitId } + let summary: String; let commitId: String; let author: String + let timestamp: String; let description: String + var shortId: String { String(commitId.prefix(7)) } + var formattedDate: String { + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let d = iso.date(from: timestamp) else { return timestamp } + let f = RelativeDateTimeFormatter(); f.unitsStyle = .abbreviated + return f.localizedString(for: d, relativeTo: Date()) + } + enum CodingKeys: String, CodingKey { + case summary, author, timestamp, description; case commitId = "id" + } +} +struct GitBranch: Identifiable, Codable { + var id: String { name } + let name: String; let head: String; let latest: String + var isRemote: Bool { name.hasPrefix("origin/") } + var localName: String { name.replacingOccurrences(of: "origin/", with: "") } +} + +@MainActor +final class GitStore: ObservableObject { + @Published var repositories: [GitRepository] = [] + @Published var activeRepo: String = "" + @Published var currentBranch: String = "main" + @Published var fileStatuses: [GitFileStatus] = [] + @Published var commitLog: [GitCommit] = [] + @Published var branches: [GitBranch] = [] + @Published var isWorkingCopyInstalled = false + @Published var pendingOperation: String? + @Published var lastError: String? + + func checkInstallation() { + isWorkingCopyInstalled = UIApplication.shared.canOpenURL( + URL(string: "working-copy://")! + ) + } +} diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..2cc1fe0 --- /dev/null +++ b/Info.plist @@ -0,0 +1,51 @@ + + + + + CFBundleName + PadXcode + CFBundleIdentifier + ca.dallasgroot.PadXcode + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + UILaunchStoryboardName + LaunchScreen + CFBundleURLTypes + + + CFBundleURLName + ca.dallasgroot.PadXcode + CFBundleURLSchemes + + padxcode + + + + LSApplicationQueriesSchemes + + working-copy + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UIRequiresFullScreen + + + diff --git a/Layout/BuildToolbar.swift b/Layout/BuildToolbar.swift new file mode 100644 index 0000000..7d31953 --- /dev/null +++ b/Layout/BuildToolbar.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct BuildToolbar: View { + let scheme: String + let devices: [ConnectedDevice] + @Binding var selectedDevice: ConnectedDevice? + let isBuilding: Bool + let buildService: BuildService + let onBuild: () -> Void + let onRun: () -> Void + let onRefreshDevices: () -> Void + var onFindInProject: (() -> Void)? = nil + + @State private var showHistory = false + + var body: some View { + HStack(spacing: 10) { + Label(scheme, systemImage: "hammer") + .font(.system(.subheadline, design: .monospaced)) + .foregroundStyle(.secondary).lineLimit(1) + + Spacer() + + // Device picker + Menu { + Button { + selectedDevice = nil + } label: { + HStack { + Image(systemName: "hammer"); Text("Build Only (No Device)") + if selectedDevice == nil { Image(systemName: "checkmark") } + } + } + Divider() + if devices.isEmpty { + Label("No devices paired", systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + } else { + ForEach(devices) { device in + Button { + selectedDevice = device + } label: { + HStack { + Image(systemName: device.isNetworkConnected + ? "iphone.radiowaves.left.and.right" : "iphone") + VStack(alignment: .leading) { + Text(device.name) + Text("\(device.model) · \(device.osVersion)").font(.caption) + } + if selectedDevice?.udid == device.udid { Image(systemName: "checkmark") } + } + } + } + } + Divider() + Button(action: onRefreshDevices) { Label("Refresh Devices", systemImage: "arrow.clockwise") } + } label: { + if let d = selectedDevice { + Label(d.name, systemImage: d.isNetworkConnected ? "iphone.radiowaves.left.and.right" : "iphone") + .font(.subheadline) + } else { + Label("No Device", systemImage: "iphone.slash") + .font(.subheadline).foregroundStyle(.secondary) + } + } + + Divider().frame(height: 20) + + Button(action: onBuild) { + Label("Build", systemImage: "hammer.fill").font(.subheadline.bold()) + } + .disabled(isBuilding).buttonStyle(.bordered) + .keyboardShortcut("b", modifiers: .command) + + Button(action: onRun) { + HStack(spacing: 5) { + if isBuilding { ProgressView().scaleEffect(0.7).tint(.white) } + else { Image(systemName: "play.fill") } + Text(isBuilding ? "Building…" : "Run").font(.subheadline.bold()) + } + } + .disabled(isBuilding || selectedDevice == nil) + .buttonStyle(.borderedProminent) + .tint(isBuilding || selectedDevice == nil ? .secondary : .green) + .keyboardShortcut("r", modifiers: .command) + + Divider().frame(height: 20) + + if let findAction = onFindInProject { + Button(action: findAction) { Image(systemName: "magnifyingglass") } + .buttonStyle(.plain).foregroundStyle(.secondary).help("Find in Project ⌘⇧F") + } + + Button { showHistory = true } label: { Image(systemName: "clock.arrow.circlepath") } + .buttonStyle(.plain).foregroundStyle(.secondary).help("Build History") + .sheet(isPresented: $showHistory) { BuildHistoryView(historyStore: buildService.historyStore) } + + DaemonStatusIndicator(monitor: buildService.healthMonitor) + } + .padding(.horizontal, 14).padding(.vertical, 8) + .background(.bar) + } +} diff --git a/Layout/ContentView.swift b/Layout/ContentView.swift new file mode 100644 index 0000000..837c0d9 --- /dev/null +++ b/Layout/ContentView.swift @@ -0,0 +1,358 @@ +import SwiftUI +import Foundation + +struct ContentView: View { + @EnvironmentObject var daemonConfig: DaemonConfiguration + @EnvironmentObject var projectStore: ProjectStore + @EnvironmentObject var editorState: EditorState + @EnvironmentObject var gitStore: GitStore + @EnvironmentObject var lspClient: LSPClient + let gitService: GitService + + @StateObject private var buildService: BuildService + @StateObject private var syncPipeline: FileSyncPipeline + + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var leftPanel: LeftPanel = .files + @State private var consoleHeight: CGFloat = 180 + @State private var consoleVisible = true + @State private var activeProject: SavedProject? + @State private var selectedDevice: ConnectedDevice? + @State private var showSettings = false + @State private var showProjectPicker = false + @State private var showFindInProject = false + @State private var showNewFile = false + @State private var showRename = false + @State private var newFilePath = "" + @State private var renamingNode: FileNode? + + @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false + + enum LeftPanel { case files, git } + + init(gitService: GitService) { + self.gitService = gitService + let config = DaemonConfiguration() + let store = GitStore() + let bs = BuildService(config: config) + _buildService = StateObject(wrappedValue: bs) + _syncPipeline = StateObject(wrappedValue: FileSyncPipeline( + buildService: bs, gitService: gitService, + gitStore: store, config: config)) + } + + var body: some View { + Group { + if !hasCompletedOnboarding { OnboardingView() } + else { mainIDE } + } + .toastOverlay() + } + + // MARK: - Main IDE + + private var mainIDE: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + leftColumn + } detail: { + rightColumn + } + .task { await initialLoad() } + .onChange(of: buildService.consoleLines) { _, lines in applyDiagnostics(from: lines) } + .onChange(of: lspClient.diagnostics) { _, diags in + for (path, ranges) in diags { editorState.applyDiagnostics(ranges, toPath: path) } + } + .onReceive(NotificationCenter.default.publisher(for: .gitCommitCompleted)) { _ in + ToastStore.shared.show("Committed successfully", style: .git) + refreshGit() + } + .onReceive(NotificationCenter.default.publisher(for: .gitPushCompleted)) { _ in + ToastStore.shared.show("Pushed to remote", style: .success) + } + .onReceive(NotificationCenter.default.publisher(for: .gitCheckoutCompleted)) { _ in + ToastStore.shared.show("Switched branch", style: .git); refreshGit() + } + .onReceive(NotificationCenter.default.publisher(for: .gitOperationFailed)) { note in + let msg = note.userInfo?["message"] as? String ?? "Git operation failed" + ToastStore.shared.show(msg, style: .error) + } + .onReceive(NotificationCenter.default.publisher(for: .consoleClearRequested)) { _ in + buildService.consoleLines = [] + } + .sheet(isPresented: $showSettings) { + SettingsView(editorState: editorState, projectStore: projectStore) + } + .sheet(isPresented: $showProjectPicker) { + ProjectPickerSheet(projectStore: projectStore, activeProject: $activeProject) { project in + Task { await loadProject(project) } + } + } + .sheet(isPresented: $showFindInProject) { + FindAcrossFilesView( + fileTree: buildService.fileTree, + buildService: buildService + ) { path, range in + Task { await openFileAtRange(path: path, range: range) } + } + } + .sheet(isPresented: $showNewFile) { + NewFileSheet(parentPath: newFilePath) { parent, name in Task { await createFile(parent: parent, name: name) } } + } + .sheet(isPresented: $showRename) { + if let node = renamingNode { + RenameSheet(node: node) { node, name in Task { await renameFile(node: node, newName: name) } } + } + } + } + + // MARK: - Left Column + + private var leftColumn: some View { + VStack(spacing: 0) { + Picker("Panel", selection: $leftPanel) { + Label("Files", systemImage: "folder").tag(LeftPanel.files) + Label("Git", systemImage: "arrow.triangle.branch").tag(LeftPanel.git) + } + .pickerStyle(.segmented).padding(.horizontal,8).padding(.vertical,6) + Divider() + switch leftPanel { + case .files: + GitAwareFileNavigatorView( + tree: buildService.fileTree, + fileStatuses: gitStore.fileStatuses, + onSelect: { node in guard !node.isDirectory else { return }; Task { await openFile(node) } }, + onCreateFile: { path in newFilePath = path; showNewFile = true }, + onCreateFolder: { path in Task { await createFile(parent: path, name: ".gitkeep") } }, + onRename: { node in renamingNode = node; showRename = true }, + onDelete: { node in Task { await deleteFile(node) } } + ) + case .git: + GitPanel(gitStore: gitStore, gitService: gitService) + } + } + .navigationTitle(leftPanel == .files ? (activeProject?.name ?? "Files") : "Source Control") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { showProjectPicker = true } label: { Image(systemName: "folder.badge.gearshape") } + } + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { Task { if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) } } } + label: { Image(systemName: "arrow.clockwise") } + Button { showSettings = true } label: { Image(systemName: "gearshape") } + } + } + } + + // MARK: - Right Column + + private var rightColumn: some View { + VStack(spacing: 0) { + BuildToolbar( + scheme: activeProject?.scheme ?? "No Project", + devices: buildService.devices, + selectedDevice: $selectedDevice, + isBuilding: buildService.isBuilding, + buildService: buildService, + onBuild: { triggerBuild("build") }, + onRun: { triggerBuild("buildAndRun") }, + onRefreshDevices: { Task { await buildService.fetchDevices() } } + ) + Divider() + if !editorState.tabs.isEmpty { EditorTabBar(editorState: editorState) } + + if let tab = editorState.activeTab { + CodeEditorView( + filePath: tab.path, + content: Binding( + get: { tab.content }, + set: { nc in + editorState.updateContent(tabId: tab.id, newContent: nc) + syncPipeline.scheduleSave(path: tab.path, content: nc) + } + ), + diagnosticRanges: tab.diagnostics, + lspClient: lspClient, + onSave: { Task { await syncPipeline.saveNow(path: tab.path, content: tab.content) + editorState.markSaved(tabId: tab.id) + ToastStore.shared.show("Saved", style: .success, duration: 1.5) } }, + onBuild: { triggerBuild("build") }, + onRun: { triggerBuild("buildAndRun") }, + onToggleNavigator: { withAnimation { columnVisibility = columnVisibility == .all ? .detailOnly : .all } }, + onToggleConsole: { withAnimation { consoleVisible.toggle() } } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: tab.path) { _, p in + if daemonConfig.lspEnabled { lspClient.didOpen(filePath: p, content: tab.content) } + } + } else { + ContentUnavailableView("No File Open", systemImage: "doc.text", + description: Text("Select a file from the navigator.")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if consoleVisible { + ConsoleResizeHandle(height: $consoleHeight); Divider() + EnhancedConsoleView(lines: buildService.consoleLines).frame(height: consoleHeight) + } + } + } + + // MARK: - Actions + + private func initialLoad() async { + gitStore.checkInstallation() + await buildService.fetchDevices() + editorState.onSaveRequest = { path, content in await buildService.saveFile(path: path, content: content) } + if let last = projectStore.projects.first { await loadProject(last) } + } + + private func loadProject(_ project: SavedProject) async { + activeProject = project + async let tree: Void = buildService.fetchFileTree(projectPath: project.rootPath) + async let lsp: Void = { + if daemonConfig.lspEnabled { await lspClient.connect(projectPath: project.rootPath) } + }() + await tree; await lsp + syncPipeline.registerProject(projectRoot: project.rootPath, + repoName: project.workingCopyRepoName.isEmpty ? project.name : project.workingCopyRepoName) + if gitStore.isWorkingCopyInstalled { + gitStore.activeRepo = project.workingCopyRepoName.isEmpty ? project.name : project.workingCopyRepoName + gitService.fetchStatus(repo: gitStore.activeRepo) + gitService.fetchBranches(repo: gitStore.activeRepo) + } + } + + private func openFile(_ node: FileNode) async { + guard let content = await buildService.fetchFileContent(path: node.path) else { return } + editorState.openFile(path: node.path, content: content) + if daemonConfig.lspEnabled { lspClient.didOpen(filePath: node.path, content: content) } + } + + private func openFileAtRange(path: String, range: NSRange) async { + await openFile(FileNode(name: URL(fileURLWithPath: path).lastPathComponent, + path: path, isDirectory: false, children: nil)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: .navigateToRange, object: nil, userInfo: ["range": range]) + } + } + + private func triggerBuild(_ action: String) { + guard let project = activeProject else { ToastStore.shared.show("No project selected", style:.warning); return } + editorState.tabs.forEach { editorState.clearDiagnostics(forPath: $0.path) } + Task { + await buildService.startBuild( + projectPath: project.rootPath, scheme: project.scheme, + selectedDevice: selectedDevice, action: action, + bundleIdentifier: nil) + } + } + + private func applyDiagnostics(from lines: [ConsoleLine]) { + let parsed = BuildDiagnosticParser.parse(lines.map(\.text)) + let grouped = Dictionary(grouping: parsed, by: \.filePath) + for (path, diags) in grouped { + editorState.applyDiagnostics(diags.map { $0.toDiagnosticRange() }, toPath: path) + } + } + + private func refreshGit() { + guard !gitStore.activeRepo.isEmpty else { return } + gitService.fetchStatus(repo: gitStore.activeRepo) + gitService.fetchLog(repo: gitStore.activeRepo) + } + + // MARK: - File Operations + + private func createFile(parent: String, name: String) async { + guard let url = URL(string: "\(daemonConfig.baseURL)/files/create") else { return } + let body = ["parentPath": parent, "fileName": name, "content": ""] + guard let data = try? JSONEncoder().encode(body) else { return } + var req = URLRequest(url: url); req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type"); req.httpBody = data + if let (_, resp) = try? await URLSession.shared.data(for: req), + (resp as? HTTPURLResponse)?.statusCode == 201 { + ToastStore.shared.show("Created \(name)", style: .success) + if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) } + } + } + + private func renameFile(node: FileNode, newName: String) async { + guard let url = URL(string: "\(daemonConfig.baseURL)/files/rename") else { return } + let body = ["path": node.path, "newName": newName] + guard let data = try? JSONEncoder().encode(body) else { return } + var req = URLRequest(url: url); req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type"); req.httpBody = data + if let (_, resp) = try? await URLSession.shared.data(for: req), + (resp as? HTTPURLResponse)?.statusCode == 200 { + ToastStore.shared.show("Renamed to \(newName)", style: .success) + if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) } + } + } + + private func deleteFile(_ node: FileNode) async { + guard let encoded = node.path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "\(daemonConfig.baseURL)/files/delete?path=\(encoded)") else { return } + var req = URLRequest(url: url); req.httpMethod = "DELETE" + if let (_, resp) = try? await URLSession.shared.data(for: req), + (resp as? HTTPURLResponse)?.statusCode == 204 { + ToastStore.shared.show("Deleted \(node.name)", style: .warning) + if let tab = editorState.tabs.first(where: { $0.path == node.path }) { editorState.closeTab(id: tab.id) } + if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) } + } + } +} + +// MARK: - Console Resize Handle + +struct ConsoleResizeHandle: View { + @Binding var height: CGFloat + @State private var startHeight: CGFloat = 0 + var body: some View { + Rectangle().fill(Color(.systemFill)).frame(height: 6).contentShape(Rectangle()) + .gesture(DragGesture() + .onChanged { v in height = max(80, min(600, startHeight - v.translation.height)) } + .onEnded { _ in startHeight = height }) + .onAppear { startHeight = height } + } +} + +// MARK: - Project Picker Sheet + +struct ProjectPickerSheet: View { + @ObservedObject var projectStore: ProjectStore + @Binding var activeProject: SavedProject? + let onSelect: (SavedProject) -> Void + @Environment(\.dismiss) var dismiss + @State private var showAdd = false + + var body: some View { + NavigationStack { + List { + ForEach(projectStore.projects) { p in + Button { + activeProject = p; onSelect(p); dismiss() + } label: { + HStack { + VStack(alignment:.leading, spacing:3) { + Text(p.name).font(.headline).foregroundStyle(.primary) + Text(p.rootPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) + Text("Scheme: \(p.scheme)").font(.caption2).foregroundStyle(.tertiary) + } + Spacer() + if p.id == activeProject?.id { Image(systemName:"checkmark.circle.fill").foregroundStyle(.accentColor) } + } + }.buttonStyle(.plain) + } + .onDelete { projectStore.remove(atOffsets: $0) } + } + .navigationTitle("Projects").navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .navigationBarTrailing) { Button { showAdd = true } label: { Image(systemName:"plus") } } + } + .sheet(isPresented: $showAdd) { AddProjectView(projectStore: projectStore) } + } + .presentationDetents([.medium, .large]) + } +} diff --git a/Layout/EnhancedConsoleView.swift b/Layout/EnhancedConsoleView.swift new file mode 100644 index 0000000..e615937 --- /dev/null +++ b/Layout/EnhancedConsoleView.swift @@ -0,0 +1,134 @@ +import SwiftUI +import UIKit + +struct EnhancedConsoleView: View { + let lines: [ConsoleLine] + var onClear: (() -> Void)? = nil + + @State private var filterMode: FilterMode = .all + @State private var searchText = "" + @State private var showSearch = false + @State private var isPinned = true + @State private var fontSize: CGFloat = 11 + + enum FilterMode: String, CaseIterable { + case all, errors, warnings, info + var label: String { rawValue.capitalized } + var icon: String { + switch self { + case .all: return "list.bullet"; case .errors: return "xmark.circle" + case .warnings: return "exclamationmark.triangle"; case .info: return "info.circle" + } + } + } + + private var filtered: [ConsoleLine] { + var result = lines + switch filterMode { + case .errors: result = result.filter { $0.type == .error } + case .warnings: result = result.filter { $0.type == .warning } + case .info: result = result.filter { $0.type == .info || $0.type == .standard } + case .all: break + } + if !searchText.isEmpty { + result = result.filter { $0.text.localizedCaseInsensitiveContains(searchText) } + } + return result + } + + private var errorCount: Int { lines.filter { $0.type == .error }.count } + private var warningCount: Int { lines.filter { $0.type == .warning }.count } + + var body: some View { + VStack(spacing: 0) { + // Toolbar + HStack(spacing: 6) { + ForEach(FilterMode.allCases, id: \.self) { mode in + Button { + filterMode = mode + } label: { + HStack(spacing: 3) { + Image(systemName: mode.icon).font(.system(size: 10)) + if mode == .errors && errorCount > 0 { Text("\(errorCount)").font(.system(.caption2, design: .monospaced).bold()) } + if mode == .warnings && warningCount > 0 { Text("\(warningCount)").font(.system(.caption2, design: .monospaced).bold()) } + } + .padding(.horizontal, 7).padding(.vertical, 3) + .background(filterMode == mode ? pillColor(mode).opacity(0.2) : Color(.tertiarySystemFill), + in: RoundedRectangle(cornerRadius: 5)) + .foregroundStyle(filterMode == mode ? pillColor(mode) : .secondary) + } + .buttonStyle(.plain) + } + Spacer() + Button { withAnimation { showSearch.toggle(); if !showSearch { searchText = "" } } } + label: { Image(systemName: "magnifyingglass").foregroundStyle(showSearch ? .accentColor : .secondary) } + .buttonStyle(.plain) + Button { UIPasteboard.general.string = filtered.map(\.text).joined(separator: "\n") } + label: { Image(systemName: "doc.on.doc").foregroundStyle(.secondary) }.buttonStyle(.plain) + Button { isPinned.toggle() } + label: { Image(systemName: isPinned ? "pin.fill" : "pin.slash").foregroundStyle(isPinned ? .accentColor : .secondary) } + .buttonStyle(.plain) + if let clear = onClear { + Button(action: clear) { Image(systemName: "trash").foregroundStyle(.secondary) }.buttonStyle(.plain) + } + } + .padding(.horizontal, 10).padding(.vertical, 5) + .background(Color(.secondarySystemBackground)) + + if showSearch { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary).font(.caption) + TextField("Filter output…", text: $searchText) + .font(.system(.callout, design: .monospaced)).autocorrectionDisabled().autocapitalization(.none) + if !searchText.isEmpty { + Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) }.buttonStyle(.plain) + Text("\(filtered.count)").font(.caption2).foregroundStyle(.secondary) + } + } + .padding(.horizontal, 10).padding(.vertical, 6).background(Color(.systemBackground)) + Divider() + } + + Divider() + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filtered) { line in + HStack(alignment: .top, spacing: 4) { + Rectangle().fill(gutterColor(line.type)).frame(width: 2).padding(.vertical, 1) + Text(line.text) + .font(.system(size: fontSize, design: .monospaced)) + .foregroundStyle(line.color) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 1) + .id(line.id) + } + } + .padding(.horizontal, 8).padding(.vertical, 4) + } + .background(Color(.systemBackground)) + .onChange(of: filtered.count) { _, _ in + if isPinned, let last = filtered.last { + withAnimation(.linear(duration: 0.08)) { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + } + } + } + + private func pillColor(_ mode: FilterMode) -> Color { + switch mode { + case .errors: return .red; case .warnings: return .orange + case .info: return .blue; case .all: return .accentColor + } + } + private func gutterColor(_ type: ConsoleLine.LineType) -> Color { + switch type { + case .error: return .red; case .warning: return .orange + case .success: return .green; case .info: return .accentColor; default: return .clear + } + } +} diff --git a/Navigator/FileOperationSheets.swift b/Navigator/FileOperationSheets.swift new file mode 100644 index 0000000..1e19e90 --- /dev/null +++ b/Navigator/FileOperationSheets.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct NewFileSheet: View { + let parentPath: String + var onConfirm: (String, String) -> Void + @Environment(\.dismiss) var dismiss + @State private var fileName = "NewFile.swift" + @FocusState private var focused: Bool + + var body: some View { + NavigationStack { + Form { + Section("File Name") { + TextField("FileName.swift", text: $fileName) + .autocorrectionDisabled().autocapitalization(.none).focused($focused) + } + Section("Location") { + Text(parentPath).font(.caption).foregroundStyle(.secondary).lineLimit(2).truncationMode(.middle) + } + } + .navigationTitle("New File").navigationBarTitleDisplayMode(.inline) + .onAppear { focused = true } + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { onConfirm(parentPath, fileName); dismiss() } + .disabled(fileName.isEmpty) + } + } + } + .presentationDetents([.medium]) + } +} + +struct RenameSheet: View { + let node: FileNode + var onConfirm: (FileNode, String) -> Void + @Environment(\.dismiss) var dismiss + @State private var newName = "" + @FocusState private var focused: Bool + + var body: some View { + NavigationStack { + Form { + Section(node.isDirectory ? "Folder Name" : "File Name") { + TextField(node.name, text: $newName).autocorrectionDisabled().autocapitalization(.none).focused($focused) + } + } + .navigationTitle("Rename").navigationBarTitleDisplayMode(.inline) + .onAppear { newName = node.name; focused = true } + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Rename") { onConfirm(node, newName); dismiss() } + .disabled(newName.isEmpty || newName == node.name) + } + } + } + .presentationDetents([.height(200)]) + } +} diff --git a/Navigator/GitAwareFileNavigatorView.swift b/Navigator/GitAwareFileNavigatorView.swift new file mode 100644 index 0000000..229af03 --- /dev/null +++ b/Navigator/GitAwareFileNavigatorView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import UIKit + +struct GitAwareFileNavigatorView: View { + let tree: FileNode? + let fileStatuses: [GitFileStatus] + let onSelect: (FileNode) -> Void + var onCreateFile: ((String) -> Void)? = nil + var onCreateFolder: ((String) -> Void)? = nil + var onRename: ((FileNode) -> Void)? = nil + var onDelete: ((FileNode) -> Void)? = nil + + private var statusMap: [String: GitFileStatus] { + Dictionary(uniqueKeysWithValues: fileStatuses.map { ($0.path, $0) }) + } + + var body: some View { + Group { + if let tree { + List { + GitAwareNodeRow(node: tree, statusMap: statusMap, depth: 0, + onSelect: onSelect, onCreateFile: onCreateFile, + onCreateFolder: onCreateFolder, + onRename: onRename, onDelete: onDelete) + } + .listStyle(.sidebar) + } else { + ContentUnavailableView("No Project Loaded", + systemImage: "folder.badge.questionmark", + description: Text("Check your daemon connection.")) + } + } + } +} + +struct GitAwareNodeRow: View { + let node: FileNode + let statusMap: [String: GitFileStatus] + let depth: Int + let onSelect: (FileNode) -> Void + var onCreateFile: ((String) -> Void)? + var onCreateFolder: ((String) -> Void)? + var onRename: ((FileNode) -> Void)? + var onDelete: ((FileNode) -> Void)? + + @State private var isExpanded = true + @State private var showDeleteAlert = false + + private var fileStatus: GitFileStatus? { + statusMap.values.first { node.path.hasSuffix($0.path) } + } + + var body: some View { + if node.isDirectory { + DisclosureGroup(isExpanded: $isExpanded) { + ForEach(node.children ?? []) { child in + GitAwareNodeRow(node: child, statusMap: statusMap, depth: depth + 1, + onSelect: onSelect, onCreateFile: onCreateFile, + onCreateFolder: onCreateFolder, + onRename: onRename, onDelete: onDelete) + } + } label: { + Label(node.name, systemImage: isExpanded ? "folder.fill" : "folder") + .font(.body) + } + .contextMenu { directoryContextMenu } + } else { + Button { onSelect(node) } label: { + HStack(spacing: 6) { + Image(systemName: fileIcon(for: node.name)) + .font(.system(size: 12)).foregroundStyle(iconColor(for: node.name)).frame(width: 16) + Text(node.name) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(fileStatus == nil ? .primary : fileStatus!.statusSwiftUIColor) + Spacer() + if let s = fileStatus { + Text(badge(s.status)) + .font(.system(.caption2, design: .monospaced).bold()) + .foregroundStyle(s.statusSwiftUIColor) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .contextMenu { fileContextMenu } + .alert("Delete \"\(node.name)\"?", isPresented: $showDeleteAlert) { + Button("Delete", role: .destructive) { onDelete?(node) } + Button("Cancel", role: .cancel) {} + } + } + } + + private func badge(_ status: String) -> String { + switch status { case "modified": return "M"; case "added": return "A" + case "deleted": return "D"; case "untracked": return "?"; default: return "" } + } + + private func fileIcon(for name: String) -> String { + switch (name as NSString).pathExtension.lowercased() { + case "swift": return "swift"; case "json": return "curlybraces" + case "md": return "doc.text"; case "sh": return "terminal" + case "xcconfig": return "gearshape"; default: return "doc" + } + } + private func iconColor(for name: String) -> Color { + switch (name as NSString).pathExtension.lowercased() { + case "swift": return .orange; case "json": return .yellow + case "md": return .blue; default: return .secondary + } + } + + @ViewBuilder private var directoryContextMenu: some View { + Button { onCreateFile?(node.path) } label: { Label("New File", systemImage: "doc.badge.plus") } + Button { onCreateFolder?(node.path) } label: { Label("New Folder", systemImage: "folder.badge.plus") } + Divider() + Button { onRename?(node) } label: { Label("Rename…", systemImage: "pencil") } + Divider() + Button(role: .destructive) { showDeleteAlert = true } label: { Label("Delete", systemImage: "trash") } + } + + @ViewBuilder private var fileContextMenu: some View { + Button { onSelect(node) } label: { Label("Open", systemImage: "arrow.up.right.square") } + Button { onRename?(node) } label: { Label("Rename…", systemImage: "pencil") } + Divider() + Button(role: .destructive) { showDeleteAlert = true } label: { Label("Delete", systemImage: "trash") } + } +} diff --git a/Network/BuildService.swift b/Network/BuildService.swift new file mode 100644 index 0000000..b61df60 --- /dev/null +++ b/Network/BuildService.swift @@ -0,0 +1,281 @@ +// BuildService.swift +// All shared models (FileNode, ConnectedDevice, ConsoleLine, etc.) +// live in Shared/SharedModels.swift — do not redeclare them here. + +import Foundation +import Combine +import SwiftUI + +@MainActor +final class BuildService: ObservableObject { + + @Published var fileTree: FileNode? + @Published var devices: [ConnectedDevice] = [] + @Published var consoleLines: [ConsoleLine] = [] + @Published var isBuilding: Bool = false + @Published var errorMessage: String? + + let historyStore = BuildHistoryStore() + let healthMonitor: DaemonHealthMonitor + + let config: DaemonConfiguration + + private var webSocketTask: URLSessionWebSocketTask? + private var buildStartTime: Date? + private var currentBuildRecord: PartialBuildRecord? + + private struct PartialBuildRecord { + let id = UUID() + let projectName: String + let scheme: String + let action: String + let configuration: String + let destination: String + var errorCount = 0 + var warningCount = 0 + var logBuffer = "" + } + + init(config: DaemonConfiguration) { + self.config = config + self.healthMonitor = DaemonHealthMonitor(config: config) + healthMonitor.onReconnected = { [weak self] in await self?.onDaemonReconnected() } + healthMonitor.onDisconnected = { [weak self] in self?.handleDaemonDisconnected() } + healthMonitor.startPolling() + } + + // MARK: - File Tree + + func fetchFileTree(projectPath: String) async { + guard let url = URL(string: "\(config.baseURL)/files/tree?path=\(projectPath.urlEncoded)") else { return } + do { + let (data, _) = try await URLSession.shared.data(from: url) + fileTree = try JSONDecoder().decode(FileNode.self, from: data) + } catch { + errorMessage = "File tree: \(error.localizedDescription)" + } + } + + // MARK: - File Content + + func fetchFileContent(path: String) async -> String? { + guard let url = URL(string: "\(config.baseURL)/files/content?path=\(path.urlEncoded)") else { return nil } + do { + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode(FileContentResponse.self, from: data).content + } catch { + errorMessage = "Load file: \(error.localizedDescription)" + return nil + } + } + + // MARK: - File Save + + func saveFile(path: String, content: String) async { + do { + try FileOperationValidator.validateSave(path: path, content: content) + } catch { + appendConsole("⚠️ \(error.localizedDescription)", type: .warning) + return + } + do { + try await withRetry(policy: .fileSave) { + guard let url = URL(string: "\(self.config.baseURL)/files/save") else { + throw URLError(.badURL) + } + let body: [String: String] = ["path": path, "content": content] + guard let data = try? JSONEncoder().encode(body) else { throw URLError(.cannotDecodeContentData) } + var req = URLRequest(url: url) + req.httpMethod = "PUT" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = data + req.timeoutInterval = 8 + let (_, resp) = try await URLSession.shared.data(for: req) + guard (resp as? HTTPURLResponse)?.statusCode == 204 else { throw URLError(.badServerResponse) } + } + } catch { + appendConsole("❌ Save failed: \(error.localizedDescription)", type: .error) + ToastStore.shared.show("Save failed", style: .error) + } + } + + // MARK: - Devices + + func fetchDevices() async { + guard let url = URL(string: "\(config.baseURL)/devices/list") else { return } + do { + let (data, _) = try await URLSession.shared.data(from: url) + devices = try JSONDecoder().decode([ConnectedDevice].self, from: data) + } catch { + errorMessage = "Devices: \(error.localizedDescription)" + } + } + + // MARK: - Build + + func startBuild( + projectPath: String, + scheme: String, + selectedDevice: ConnectedDevice?, + action: String = "build", + preBuildHooks: [PreBuildHookRequest] = [], + bundleIdentifier: String? = nil + ) async { + guard !isBuilding else { return } + isBuilding = true + buildStartTime = Date() + consoleLines = [] + + let destination: String + let destinationLabel: String + + if let device = selectedDevice { + destination = "platform=iOS,id=\(device.udid)" + destinationLabel = "\(device.name) (\(device.osVersion))" + } else { + destination = "" + destinationLabel = "Build Only" + } + + let resolvedAction = (selectedDevice == nil) ? "build" : action + + if selectedDevice == nil && action == "buildAndRun" { + appendConsole("⚠️ No device selected — building without deploying.", type: .warning) + } + + currentBuildRecord = PartialBuildRecord( + projectName: URL(fileURLWithPath: projectPath).lastPathComponent, + scheme: scheme, + action: resolvedAction, + configuration: config.buildConfiguration, + destination: destinationLabel + ) + + let buildReq = BuildRequest( + projectPath: projectPath, + scheme: scheme, + destination: destination, + action: resolvedAction, + configuration: config.buildConfiguration, + preBuildHooks: preBuildHooks, + bundleIdentifier: bundleIdentifier, + extraBuildSettings: [] + ) + + guard let url = URL(string: "\(config.baseURL)/build/start"), + let bodyData = try? JSONEncoder().encode(buildReq) else { + isBuilding = false; return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = bodyData + + do { + let (data, _) = try await URLSession.shared.data(for: request) + let session = try JSONDecoder().decode(BuildSessionResponse.self, from: data) + await connectBuildStream(webSocketURL: session.webSocketURL) + } catch { + appendConsole("❌ Failed to reach daemon: \(error.localizedDescription)", type: .error) + isBuilding = false + commitBuildRecord(outcome: .failure) + } + } + + // MARK: - WebSocket + + private func connectBuildStream(webSocketURL: String) async { + guard let url = URL(string: webSocketURL) else { return } + webSocketTask?.cancel() + webSocketTask = URLSession.shared.webSocketTask(with: url) + webSocketTask?.resume() + appendConsole("🔌 Connected to build stream.", type: .info) + await receiveLoop() + } + + private func receiveLoop() async { + guard let task = webSocketTask, task.state == .running else { + isBuilding = false; return + } + do { + let message = try await task.receive() + switch message { + case .string(let text): handleIncomingLog(text) + case .data(let data): + if let text = String(data: data, encoding: .utf8) { handleIncomingLog(text) } + @unknown default: break + } + await receiveLoop() + } catch { + isBuilding = false + commitBuildRecord(outcome: .failure) + } + } + + private func handleIncomingLog(_ text: String) { + guard let data = text.data(using: .utf8), + let msg = try? JSONDecoder().decode(BuildLogMessage.self, from: data) else { + appendConsole(text, type: .standard); return + } + let newLines = ConsoleLine.fromBuildLogMessage(msg) + consoleLines.append(contentsOf: newLines) + if var rec = currentBuildRecord { + rec.errorCount += newLines.filter { $0.type == .error }.count + rec.warningCount += newLines.filter { $0.type == .warning }.count + rec.logBuffer = String((rec.logBuffer + newLines.map(\.text).joined(separator: "\n")).suffix(2000)) + currentBuildRecord = rec + } + if msg.type == .status { + isBuilding = false + commitBuildRecord(outcome: msg.exitCode == 0 ? .success : .failure) + } + } + + // MARK: - History + + private func commitBuildRecord(outcome: BuildRecord.Outcome) { + guard let rec = currentBuildRecord else { return } + let duration = buildStartTime.map { Date().timeIntervalSince($0) } ?? 0 + historyStore.append(BuildRecord( + id: rec.id, + projectName: rec.projectName, + scheme: rec.scheme, + date: buildStartTime ?? Date(), + duration: duration, + action: rec.action, + configuration: rec.configuration, + destination: rec.destination, + outcome: outcome, + errorCount: rec.errorCount, + warningCount: rec.warningCount, + logSnippet: String(rec.logBuffer.suffix(500)) + )) + currentBuildRecord = nil + buildStartTime = nil + } + + // MARK: - Reconnection + + private func onDaemonReconnected() async { + appendConsole("🔌 Reconnected to daemon.", type: .info) + await fetchDevices() + } + + private func handleDaemonDisconnected() { + guard isBuilding else { return } + appendConsole("⚠️ Lost connection during build.", type: .error) + isBuilding = false + commitBuildRecord(outcome: .failure) + } + + // MARK: - Console + + func appendConsole(_ text: String, type: ConsoleLine.LineType) { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + .map { ConsoleLine(text: String($0), type: type) } + consoleLines.append(contentsOf: lines) + } + + func clearConsole() { consoleLines = [] } +} diff --git a/Network/DaemonHealthMonitor.swift b/Network/DaemonHealthMonitor.swift new file mode 100644 index 0000000..70d58d3 --- /dev/null +++ b/Network/DaemonHealthMonitor.swift @@ -0,0 +1,141 @@ +import Foundation +import SwiftUI + +enum DaemonConnectionState: Equatable { + case unknown + case connecting + case connected(latencyMs: Int) + case disconnected(reason: String) + case reconnecting(attempt: Int) +} + +@MainActor +final class DaemonHealthMonitor: ObservableObject { + @Published var state: DaemonConnectionState = .unknown + @Published var consecutiveFailures = 0 + + var onReconnected: (() async -> Void)? + var onDisconnected: (() -> Void)? + + private let config: DaemonConfiguration + private var pollTask: Task? + private let pollInterval: TimeInterval = 8 + private let timeout: TimeInterval = 4 + + init(config: DaemonConfiguration) { self.config = config } + + func startPolling() { + pollTask?.cancel() + pollTask = Task { [weak self] in + while !Task.isCancelled { + await self?.poll() + try? await Task.sleep(for: .seconds(self?.pollInterval ?? 8)) + } + } + } + + func stopPolling() { pollTask?.cancel(); pollTask = nil } + + func reconnectNow() async { consecutiveFailures = 0; await poll() } + + private func poll() async { + guard let url = URL(string: "\(config.baseURL)/health") else { return } + var req = URLRequest(url: url); req.timeoutInterval = timeout + let start = Date() + do { + let wasDown: Bool + if case .disconnected = state { wasDown = true } + else if case .reconnecting = state { wasDown = true } + else { wasDown = false } + state = consecutiveFailures > 0 ? .reconnecting(attempt: consecutiveFailures) : .connecting + let (_, resp) = try await URLSession.shared.data(for: req) + guard (resp as? HTTPURLResponse)?.statusCode == 200 else { + handleFailure("Bad status"); return + } + let ms = Int(Date().timeIntervalSince(start) * 1000) + consecutiveFailures = 0 + state = .connected(latencyMs: ms) + if wasDown { await onReconnected?() } + } catch { + handleFailure(error.localizedDescription) + } + } + + private func handleFailure(_ reason: String) { + consecutiveFailures += 1 + state = .disconnected(reason: reason) + onDisconnected?() + } +} + +// MARK: - Status Indicator View + +struct DaemonStatusIndicator: View { + @ObservedObject var monitor: DaemonHealthMonitor + @State private var showDetail = false + + var body: some View { + Button { showDetail.toggle() } label: { + HStack(spacing: 4) { + Circle().fill(indicatorColor).frame(width: 8, height: 8) + Text(shortLabel) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .popover(isPresented: $showDetail) { + VStack(alignment: .leading, spacing: 8) { + Label("Mac Daemon", systemImage: "desktopcomputer").font(.headline) + Divider() + HStack { Text("Status").foregroundStyle(.secondary); Spacer() + Text(monitor.state.statusLabel).bold() } + if case .connected(let ms) = monitor.state { + HStack { Text("Latency").foregroundStyle(.secondary); Spacer() + Text("\(ms) ms").bold() } + } + if monitor.consecutiveFailures > 0 { + HStack { Text("Failures").foregroundStyle(.secondary); Spacer() + Text("\(monitor.consecutiveFailures)").foregroundStyle(.red).bold() } + } + if case .disconnected = monitor.state { + Button("Reconnect Now") { Task { await monitor.reconnectNow() } } + .buttonStyle(.borderedProminent).controlSize(.small).padding(.top, 4) + } + } + .padding(16).frame(width: 220) + } + } + + private var indicatorColor: Color { + switch monitor.state { + case .connected: return .green + case .connecting: return .yellow + case .reconnecting: return .orange + case .disconnected: return .red + default: return .secondary + } + } + + private var shortLabel: String { + switch monitor.state { + case .connected(let ms): return "\(ms)ms" + case .connecting: return "…" + case .reconnecting(let n): return "retry \(n)" + case .disconnected: return "offline" + default: return "—" + } + } +} + +extension DaemonConnectionState { + var statusLabel: String { + switch self { + case .connected: return "Connected" + case .connecting: return "Connecting…" + case .reconnecting(let n): return "Reconnecting (\(n))" + case .disconnected(let r): return "Disconnected: \(r)" + case .unknown: return "Unknown" + } + } +} diff --git a/Network/FileSyncPipeline.swift b/Network/FileSyncPipeline.swift new file mode 100644 index 0000000..98d7743 --- /dev/null +++ b/Network/FileSyncPipeline.swift @@ -0,0 +1,135 @@ +import Foundation + +// MARK: - Sync Event + +enum SyncEvent { + case savedToDaemon(path: String) + case savedToWorkingCopy(path: String) + case syncFailed(path: String, error: String) +} + +// MARK: - FileSyncPipeline + +/// Coordinates saving a file to both the Mac daemon (for building) +/// and Working Copy on the iPad (for git staging). +/// +/// Flow: +/// Edit in editor +/// └─► saveNow() / scheduleSave() +/// └─► BuildService.saveFile() — REST PUT to Mac daemon +/// └─► [if WC configured] +/// GitService.writeFile() — working-copy://x-callback-url/write +@MainActor +final class FileSyncPipeline: ObservableObject { + + @Published var isSyncing: Bool = false + @Published var lastEvent: SyncEvent? + + private let buildService: BuildService + private let gitService: GitService + private let gitStore: GitStore + + /// Keyed by file path — cancels previous debounce when a new edit arrives. + private var debounceTimers: [String: Task] = [:] + + init( + buildService: BuildService, + gitService: GitService, + gitStore: GitStore + ) { + self.buildService = buildService + self.gitService = gitService + self.gitStore = gitStore + } + + // MARK: - Project Path → Working Copy Repo Mapping + + /// Call once per project when it loads. + /// Stores a mapping from the Mac absolute root path to the WC repo name. + /// projectRoot: "/Users/dallas/Dev/NavidromePlayer/NavidromePlayer" + /// repoName: "NavidromePlayer" + func registerProject(projectRoot: String, repoName: String) { + UserDefaults.standard.set(repoName, forKey: wcKey(projectRoot)) + } + + private func wcKey(_ root: String) -> String { "wc_repo_for_\(root)" } + + private func workingCopyMapping( + for macPath: String + ) -> (repo: String, relativePath: String)? { + let allKeys = UserDefaults.standard + .dictionaryRepresentation().keys + .filter { $0.hasPrefix("wc_repo_for_") } + + for key in allKeys { + let root = String(key.dropFirst("wc_repo_for_".count)) + guard macPath.hasPrefix(root) else { continue } + let repo = UserDefaults.standard.string(forKey: key) ?? "" + let relative = String(macPath.dropFirst(root.count)) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return (repo: repo, relativePath: relative) + } + return nil + } + + // MARK: - Debounced Save (called on every keystroke) + + /// Waits 1.5 s after the last edit then saves. + /// Cancels any pending save for the same path when a new edit arrives. + func scheduleSave(path: String, content: String) { + debounceTimers[path]?.cancel() + debounceTimers[path] = Task { [weak self] in + try? await Task.sleep(for: .seconds(1.5)) + guard !Task.isCancelled else { return } + await self?.executeSave(path: path, content: content) + } + } + + // MARK: - Immediate Save (⌘S or tab switch) + + func saveNow(path: String, content: String) async { + debounceTimers[path]?.cancel() + await executeSave(path: path, content: content) + } + + // MARK: - Execution + + private func executeSave(path: String, content: String) async { + isSyncing = true + defer { isSyncing = false } + + // ── Step 1: Save to Mac daemon ───────────────────────────────────── + await buildService.saveFile(path: path, content: content) + lastEvent = .savedToDaemon(path: path) + + // ── Step 2: Mirror to Working Copy ───────────────────────────────── + // Only runs if: + // • Working Copy is installed + // • An active repo is set in GitStore + // • A path mapping exists for this project + guard gitStore.isWorkingCopyInstalled, + !gitStore.activeRepo.isEmpty, + let mapping = workingCopyMapping(for: path) else { return } + + // Validate WC can be reached before firing the URL scheme + do { + try WorkingCopyGuard.validate( + projectName: gitStore.activeRepo, + requiresApiKey: true + ) + } catch { + // WC not fully configured yet — silently skip. + // The daemon save already succeeded so nothing is lost. + return + } + + gitService.writeFile( + repo: mapping.repo, + path: mapping.relativePath, + content: content, + askCommit: false // Silent write — user commits manually from Git panel + ) + + lastEvent = .savedToWorkingCopy(path: path) + } +} diff --git a/Network/LSPClient.swift b/Network/LSPClient.swift new file mode 100644 index 0000000..3ea0ea1 --- /dev/null +++ b/Network/LSPClient.swift @@ -0,0 +1,326 @@ +// LSPClient.swift +// CompletionItem, DefinitionLocation, DiagnosticRange all live in +// Shared/SharedModels.swift — not redeclared here. +import Foundation + +// MARK: - LSP Request/Response types + +struct LSPRequest: Encodable { + let jsonrpc = "2.0" + let id: Int? + let method: String + let params: JSONValue? +} + +struct LSPResponse: Decodable { + let jsonrpc: String + let id: Int? + let result: JSONValue? + let error: LSPError? + let method: String? + let params: JSONValue? +} + +struct LSPError: Decodable { + let code: Int + let message: String +} + +// MARK: - JSONValue (generic JSON tree for LSP params) + +indirect enum JSONValue: Codable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let v = try? c.decode(String.self) { self = .string(v); return } + if let v = try? c.decode(Int.self) { self = .int(v); return } + if let v = try? c.decode(Double.self) { self = .double(v); return } + if let v = try? c.decode(Bool.self) { self = .bool(v); return } + if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return } + if let v = try? c.decode([JSONValue].self) { self = .array(v); return } + self = .null + } + + func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .string(let v): try c.encode(v) + case .int(let v): try c.encode(v) + case .double(let v): try c.encode(v) + case .bool(let v): try c.encode(v) + case .object(let v): try c.encode(v) + case .array(let v): try c.encode(v) + case .null: try c.encodeNil() + } + } +} + +// MARK: - LSP Client + +@MainActor +final class LSPClient: ObservableObject { + + @Published var isConnected: Bool = false + @Published var completions: [CompletionItem] = [] + @Published var diagnostics: [String: [DiagnosticRange]] = [:] + @Published var hoverText: String? + @Published var definitionLocation: DefinitionLocation? + + private var webSocketTask: URLSessionWebSocketTask? + private var messageId = 0 + private var pendingRequests: [Int: CheckedContinuation] = [:] + private let config: DaemonConfiguration + + init(config: DaemonConfiguration) { self.config = config } + + // MARK: - Connection + + func connect(projectPath: String) async { + guard let startURL = URL(string: "\(config.baseURL)/lsp/start") else { return } + var req = URLRequest(url: startURL) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONEncoder().encode(["projectPath": projectPath]) + _ = try? await URLSession.shared.data(for: req) + + guard let wsURL = URL(string: config.lspWSURL) else { return } + webSocketTask?.cancel() + webSocketTask = URLSession.shared.webSocketTask(with: wsURL) + webSocketTask?.resume() + isConnected = true + config.lspConnected = true + + await sendInitialize(projectPath: projectPath) + Task { await receiveLoop() } + } + + func disconnect() { + webSocketTask?.cancel() + webSocketTask = nil + isConnected = false + config.lspConnected = false + } + + // MARK: - LSP Lifecycle + + private func sendInitialize(projectPath: String) async { + let params = JSONValue.object([ + "processId": .int(Int(ProcessInfo.processInfo.processIdentifier)), + "rootUri": .string("file://\(projectPath)"), + "capabilities": .object([ + "textDocument": .object([ + "completion": .object(["completionItem": .object(["snippetSupport": .bool(false)])]), + "hover": .object(["contentFormat": .array([.string("plaintext")])]), + "publishDiagnostics": .object([:]) + ]) + ]), + "initializationOptions": .object([:]) + ]) + _ = try? await sendRequest(method: "initialize", params: params) + sendNotification(method: "initialized", params: .object([:])) + } + + // MARK: - Document Sync + + func didOpen(filePath: String, content: String) { + sendNotification(method: "textDocument/didOpen", params: .object([ + "textDocument": .object([ + "uri": .string("file://\(filePath)"), + "languageId": .string(languageIdentifier(for: filePath)), + "version": .int(1), + "text": .string(content) + ]) + ])) + } + + func didChange(filePath: String, content: String, version: Int) { + sendNotification(method: "textDocument/didChange", params: .object([ + "textDocument": .object(["uri": .string("file://\(filePath)"), "version": .int(version)]), + "contentChanges": .array([.object(["text": .string(content)])]) + ])) + } + + func didSave(filePath: String) { + sendNotification(method: "textDocument/didSave", params: .object([ + "textDocument": .object(["uri": .string("file://\(filePath)")]) + ])) + } + + func didClose(filePath: String) { + sendNotification(method: "textDocument/didClose", params: .object([ + "textDocument": .object(["uri": .string("file://\(filePath)")]) + ])) + } + + // MARK: - Completions + + func requestCompletions(filePath: String, line: Int, column: Int) async -> [CompletionItem] { + guard let response = try? await sendRequest( + method: "textDocument/completion", + params: textDocumentPositionParams(filePath: filePath, line: line - 1, character: column - 1) + ) else { return [] } + return parseCompletions(from: response) + } + + // MARK: - Hover + + func requestHover(filePath: String, line: Int, column: Int) async -> String? { + guard let response = try? await sendRequest( + method: "textDocument/hover", + params: textDocumentPositionParams(filePath: filePath, line: line - 1, character: column - 1) + ) else { return nil } + if case .object(let obj) = response.result, + case .object(let contents) = obj["contents"], + case .string(let value) = contents["value"] { return value } + return nil + } + + // MARK: - Definition + + func requestDefinition(filePath: String, line: Int, column: Int) async -> DefinitionLocation? { + guard let response = try? await sendRequest( + method: "textDocument/definition", + params: textDocumentPositionParams(filePath: filePath, line: line - 1, character: column - 1) + ) else { return nil } + return parseDefinition(from: response) + } + + // MARK: - WebSocket Send/Receive + + @discardableResult + private func sendRequest(method: String, params: JSONValue?) async throws -> LSPResponse { + messageId += 1 + let id = messageId + let data = try JSONEncoder().encode(LSPRequest(id: id, method: method, params: params)) + guard let text = String(data: data, encoding: .utf8) else { throw URLError(.badServerResponse) } + + return try await withCheckedThrowingContinuation { cont in + pendingRequests[id] = cont + webSocketTask?.send(.string(text)) { error in + if let error { + Task { @MainActor in + self.pendingRequests.removeValue(forKey: id) + cont.resume(throwing: error) + } + } + } + } + } + + private func sendNotification(method: String, params: JSONValue?) { + guard let data = try? JSONEncoder().encode(LSPRequest(id: nil, method: method, params: params)), + let text = String(data: data, encoding: .utf8) else { return } + webSocketTask?.send(.string(text)) { _ in } + } + + private func receiveLoop() async { + guard let task = webSocketTask, task.state == .running else { + isConnected = false; return + } + do { + let message = try await task.receive() + switch message { + case .string(let text): handleMessage(text) + case .data(let data): + if let text = String(data: data, encoding: .utf8) { handleMessage(text) } + @unknown default: break + } + await receiveLoop() + } catch { + isConnected = false + config.lspConnected = false + } + } + + private func handleMessage(_ text: String) { + guard let data = text.data(using: .utf8), + let response = try? JSONDecoder().decode(LSPResponse.self, from: data) else { return } + if let id = response.id, let cont = pendingRequests.removeValue(forKey: id) { + cont.resume(returning: response); return + } + if let method = response.method { handleNotification(method: method, params: response.params) } + } + + private func handleNotification(method: String, params: JSONValue?) { + if method == "textDocument/publishDiagnostics" { handlePublishDiagnostics(params) } + } + + private func handlePublishDiagnostics(_ params: JSONValue?) { + guard case .object(let obj) = params, + case .string(let uri) = obj["uri"], + case .array(let diags) = obj["diagnostics"] else { return } + + let filePath = uri.replacingOccurrences(of: "file://", with: "") + + diagnostics[filePath] = diags.compactMap { diag -> DiagnosticRange? in + guard case .object(let d) = diag, + case .object(let rng) = d["range"], + case .object(let start) = rng["start"], + case .int(let line) = start["line"], + case .int(let col) = start["character"], + case .int(let sev) = d["severity"] else { return nil } + let msg: String + if case .string(let m) = d["message"] { msg = m } else { msg = "" } + return DiagnosticRange( + line: line + 1, column: col + 1, length: 30, + severity: sev == 1 ? .error : .warning, message: msg + ) + } + } + + // MARK: - Parsing + + private func parseCompletions(from response: LSPResponse) -> [CompletionItem] { + var items: [JSONValue] = [] + if case .array(let a) = response.result { items = a } + else if case .object(let o) = response.result, + case .array(let a) = o["items"] { items = a } + + return items.compactMap { item -> CompletionItem? in + guard case .object(let obj) = item, + case .string(let label) = obj["label"] else { return nil } + let detail: String; if case .string(let d) = obj["detail"] { detail = d } else { detail = "" } + let insertText: String?; if case .string(let t) = obj["insertText"] { insertText = t } else { insertText = nil } + let filterText: String?; if case .string(let f) = obj["filterText"] { filterText = f } else { filterText = nil } + let kind: Int; if case .int(let k) = obj["kind"] { kind = k } else { kind = 0 } + return CompletionItem(label: label, detail: detail, kind: kind, insertText: insertText, filterText: filterText) + } + } + + private func parseDefinition(from response: LSPResponse) -> DefinitionLocation? { + guard case .object(let loc) = response.result, + case .string(let uri) = loc["uri"], + case .object(let rng) = loc["range"], + case .object(let start) = rng["start"], + case .int(let line) = start["line"], + case .int(let col) = start["character"] else { return nil } + return DefinitionLocation( + filePath: uri.replacingOccurrences(of: "file://", with: ""), + line: line + 1, column: col + 1 + ) + } + + private func textDocumentPositionParams(filePath: String, line: Int, character: Int) -> JSONValue { + .object([ + "textDocument": .object(["uri": .string("file://\(filePath)")]), + "position": .object(["line": .int(line), "character": .int(character)]) + ]) + } + + private func languageIdentifier(for path: String) -> String { + switch (path as NSString).pathExtension.lowercased() { + case "swift": return "swift" + case "c": return "c" + case "cpp": return "cpp" + case "m": return "objective-c" + default: return "plaintext" + } + } +} diff --git a/Onboarding/OnboardingView.swift b/Onboarding/OnboardingView.swift new file mode 100644 index 0000000..5151c03 --- /dev/null +++ b/Onboarding/OnboardingView.swift @@ -0,0 +1,215 @@ +import SwiftUI +import UIKit + +struct OnboardingView: View { + @EnvironmentObject var daemonConfig: DaemonConfiguration + @AppStorage("hasCompletedOnboarding") private var hasCompleted = false + + @State private var step = 0 + @State private var tailscaleIP = "" + @State private var wcApiKey = "" + @State private var testResult: TestResult = .idle + + enum TestResult: Equatable { + case idle, testing, success, failure(String) + static func ==(a: TestResult, b: TestResult) -> Bool { + switch (a,b) { + case (.idle,.idle),(.testing,.testing),(.success,.success): return true + case (.failure(let x),.failure(let y)): return x == y + default: return false + } + } + } + + private let steps = ["Welcome","Connect to Mac","Working Copy","Ready"] + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Progress dots + HStack(spacing: 6) { + ForEach(0.. 0 { Button("Back") { withAnimation { step -= 1 } }.buttonStyle(.bordered) } + Spacer() + if step < steps.count - 1 { + Button("Next") { withAnimation { step += 1 } } + .buttonStyle(.borderedProminent) + .disabled(step == 1 && testResult != .success) + } else { + Button("Get Started") { applyAndFinish() } + .buttonStyle(.borderedProminent) + .disabled(testResult != .success && tailscaleIP.isEmpty) + } + } + .padding(.horizontal, 24).padding(.bottom, 32) + } + } + } + + // MARK: - Steps + + private var welcomeStep: some View { + VStack(spacing: 28) { + Image(systemName: "laptopcomputer.and.ipad").font(.system(size:72,weight:.thin)).foregroundStyle(.accentColor).padding(.top,40) + VStack(spacing:10) { + Text("Welcome to PadXcode").font(.largeTitle.bold()).multilineTextAlignment(.center) + Text("A native Swift IDE on your iPad, backed by your Mac.").font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center).padding(.horizontal,24) + } + LazyVGrid(columns: [.init(.flexible()),.init(.flexible())], spacing:12) { + ForEach([("swift","Runestone Editor",.orange),("sparkles","Tree-sitter Syntax",.yellow), + ("hammer.fill","xcodebuild Builds",.blue),("iphone.radiowaves.left.and.right","devicectl Deploy",.green), + ("wand.and.stars","sourcekit-lsp",.purple),("arrow.triangle.branch","Working Copy Git",.pink)] as [(String,String,Color)], id:\.1) { icon,name,color in + HStack(spacing:10) { + Image(systemName:icon).font(.title3).foregroundStyle(color).frame(width:32) + Text(name).font(.subheadline); Spacer() + }.padding(12).background(.quaternary, in: RoundedRectangle(cornerRadius:10)) + } + }.padding(.horizontal,24) + Spacer(minLength:40) + } + } + + private var daemonStep: some View { + VStack(spacing: 28) { + Image(systemName:"network").font(.system(size:72,weight:.thin)).foregroundStyle(.blue).padding(.top,40) + VStack(spacing:10) { + Text("Connect to Your Mac").font(.largeTitle.bold()).multilineTextAlignment(.center) + Text("Enter your Mac's Tailscale IP to connect to the daemon.").font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center).padding(.horizontal,24) + } + VStack(spacing:14) { + VStack(alignment:.leading,spacing:6) { + Label("Tailscale IP Address", systemImage:"network").font(.subheadline.bold()) + TextField("100.x.x.x", text:$tailscaleIP) + .textFieldStyle(.roundedBorder).keyboardType(.numbersAndPunctuation) + .autocorrectionDisabled().autocapitalization(.none) + .onChange(of:tailscaleIP) { _,_ in testResult = .idle } + } + GroupBox { + VStack(alignment:.leading,spacing:6) { + Label("Mac terminal:", systemImage:"terminal").font(.caption.bold()) + Text("open mac-daemon/PadXcodeDaemon.xcodeproj") + .font(.system(.caption,design:.monospaced)).padding(6) + .frame(maxWidth:.infinity,alignment:.leading) + .background(Color(.secondarySystemBackground),in:RoundedRectangle(cornerRadius:6)) + } + } + Button { + Task { await testConnection() } + } label: { + HStack { + if case .testing = testResult { ProgressView().scaleEffect(0.8) } + Text(testLabel) + }.frame(maxWidth:.infinity) + } + .buttonStyle(.borderedProminent) + .disabled(tailscaleIP.isEmpty || testResult == .testing) + + if case .failure(let m) = testResult { + Label(m, systemImage:"xmark.circle").font(.caption).foregroundStyle(.red) + } + if case .success = testResult { + Label("Connected to daemon ✓", systemImage:"checkmark.circle.fill").font(.caption).foregroundStyle(.green) + } + } + .padding(20).background(.regularMaterial, in:RoundedRectangle(cornerRadius:14)).padding(.horizontal,24) + Spacer(minLength:40) + } + } + + private var workingCopyStep: some View { + VStack(spacing: 28) { + Image(systemName:"arrow.triangle.branch").font(.system(size:72,weight:.thin)).foregroundStyle(.purple).padding(.top,40) + VStack(spacing:10) { + Text("Working Copy (Optional)").font(.largeTitle.bold()).multilineTextAlignment(.center) + Text("Connect Working Copy to commit and push from within PadXcode.").font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center).padding(.horizontal,24) + } + VStack(spacing:14) { + let installed = UIApplication.shared.canOpenURL(URL(string:"working-copy://")!) + if installed { + Label("Working Copy detected ✓", systemImage:"checkmark.circle.fill").foregroundStyle(.green).font(.subheadline) + } else { + Label("Working Copy not installed", systemImage:"exclamationmark.triangle").foregroundStyle(.orange).font(.subheadline) + } + VStack(alignment:.leading,spacing:6) { + Label("API Key", systemImage:"key.fill").font(.subheadline.bold()) + SecureField("Paste from Working Copy → Settings → Advanced", text:$wcApiKey) + .textFieldStyle(.roundedBorder).autocorrectionDisabled().autocapitalization(.none) + } + Text("For local git over Tailscale: clone via SSH in Working Copy using ssh://user@100.x.x.x/path/to/repo.git") + .font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center) + } + .padding(20).background(.regularMaterial, in:RoundedRectangle(cornerRadius:14)).padding(.horizontal,24) + Spacer(minLength:40) + } + } + + private var readyStep: some View { + VStack(spacing: 28) { + Image(systemName:"checkmark.seal.fill").font(.system(size:72,weight:.thin)).foregroundStyle(.green).padding(.top,40) + VStack(spacing:10) { + Text("You're All Set").font(.largeTitle.bold()).multilineTextAlignment(.center) + Text("Open a project and start building.").font(.body).foregroundStyle(.secondary) + } + VStack(spacing:10) { + summaryRow("network", .blue, "Mac Daemon", tailscaleIP.isEmpty ? "Not configured" : tailscaleIP) + summaryRow("arrow.triangle.branch", .purple, "Working Copy", wcApiKey.isEmpty ? "Not configured" : "Connected") + summaryRow("wand.and.stars", .yellow, "sourcekit-lsp", "Auto-starts with project") + summaryRow("hammer.fill", .orange, "Build & Deploy", "Ready") + } + .padding(20).background(.regularMaterial, in:RoundedRectangle(cornerRadius:14)).padding(.horizontal,24) + Spacer(minLength:40) + } + } + + private func summaryRow(_ icon: String, _ color: Color, _ label: String, _ value: String) -> some View { + HStack { + Image(systemName:icon).foregroundStyle(color).frame(width:24) + Text(label).font(.subheadline) + Spacer() + Text(value).font(.subheadline).foregroundStyle(.secondary) + } + } + + // MARK: - Actions + + private var testLabel: String { + switch testResult { + case .testing: return "Testing…"; case .success: return "Connected ✓"; default: return "Test Connection" + } + } + + private func testConnection() async { + testResult = .testing + let host = tailscaleIP.trimmingCharacters(in:.whitespaces) + guard let url = URL(string:"http://\(host):8080/health") else { testResult = .failure("Invalid IP"); return } + var req = URLRequest(url:url); req.timeoutInterval = 4 + do { + let (_,resp) = try await URLSession.shared.data(for:req) + testResult = (resp as? HTTPURLResponse)?.statusCode == 200 ? .success : .failure("Unexpected response") + } catch { testResult = .failure(error.localizedDescription) } + } + + private func applyAndFinish() { + daemonConfig.host = tailscaleIP.trimmingCharacters(in:.whitespaces) + daemonConfig.port = 8080 + if !wcApiKey.isEmpty { UserDefaults.standard.set(wcApiKey, forKey:"workingCopyAPIKey") } + hasCompleted = true + } +} diff --git a/PadXcode.entitlements b/PadXcode.entitlements new file mode 100644 index 0000000..214a3ce --- /dev/null +++ b/PadXcode.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.security.network.client + + + diff --git a/PadXcodeApp.swift b/PadXcodeApp.swift new file mode 100644 index 0000000..f5d1b0a --- /dev/null +++ b/PadXcodeApp.swift @@ -0,0 +1,40 @@ +import SwiftUI +import Foundation + +@main +struct PadXcodeApp: App { + @StateObject private var daemonConfig = DaemonConfiguration() + @StateObject private var projectStore = ProjectStore() + @StateObject private var editorState = EditorState() + @StateObject private var gitStore = GitStore() + @StateObject private var lspClient: LSPClient + + private let gitService: GitService + private let gitCallbackHandler: GitCallbackHandler + + init() { + let config = DaemonConfiguration() + let apiKey = UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? "" + let store = GitStore() + _daemonConfig = StateObject(wrappedValue: config) + _lspClient = StateObject(wrappedValue: LSPClient(config: config)) + _gitStore = StateObject(wrappedValue: store) + gitService = GitService(apiKey: apiKey) + gitCallbackHandler = GitCallbackHandler(store: store) + } + + var body: some Scene { + WindowGroup { + ContentView(gitService: gitService) + .environmentObject(daemonConfig) + .environmentObject(projectStore) + .environmentObject(editorState) + .environmentObject(gitStore) + .environmentObject(lspClient) + .onOpenURL { url in + Task { @MainActor in gitCallbackHandler.handle(url: url) } + } + } + .commands { CommandGroup(replacing: .undoRedo) { } } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..7132ced --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# PadXcode — iPad App + +Native iPadOS code editor. Connects to PadXcode-Daemon on your Mac over Tailscale. + +## Xcode Setup +```bash +./generate.sh +open PadXcode.xcodeproj +# Signing & Capabilities → set your Development Team +# Destination: your iPad → ⌘R +``` + +## Git Setup + +Your Raspberry Pi is the central git remote. +Working Copy on iPad and Xcode on Mac both push/pull to/from the Pi. + +**Add the Pi as remote in Working Copy:** +- Working Copy → your repo → Remote → set to your Pi's HTTP URL +- e.g. `http://pi.tailscale-ip/PadXcode-iPad.git` + +**Clone on Mac (for Xcode source control):** +```bash +git clone http://pi.tailscale-ip/PadXcode-iPad.git ~/Dev/PadXcode-iPad +open ~/Dev/PadXcode-iPad/PadXcode.xcodeproj +``` +Xcode handles all git operations (pull, commit, push) from Source Control. + +## Workflow +1. Edit code in PadXcode on iPad +2. Git panel → Commit & Push → Working Copy pushes to Pi +3. On Mac: Xcode → Source Control → Pull (gets iPad changes from Pi) +4. Mac can also commit and push back to Pi from Xcode +5. Daemon builds whatever is in the Mac working copy diff --git a/SPM-DEPENDENCIES.md b/SPM-DEPENDENCIES.md new file mode 100644 index 0000000..ff2be1b --- /dev/null +++ b/SPM-DEPENDENCIES.md @@ -0,0 +1,23 @@ +# iPad App — Swift Package Dependencies + +Add these in Xcode → File → Add Package Dependencies: + +## 1. Runestone +URL: https://github.com/simonbs/Runestone.git +Version: Up to Next Major from 0.5.1 +Product to add: Runestone → PadXcode target + +## 2. TreeSitterLanguages +URL: https://github.com/simonbs/TreeSitterLanguages.git +Version: Up to Next Major from 0.1.10 +Products to add (all to PadXcode target): + - TreeSitterSwiftRunestone + - TreeSitterJSONRunestone + - TreeSitterMarkdownRunestone + - TreeSitterBashRunestone + +## Notes +- Do NOT add TreeSitter{Language} or TreeSitter{Language}Queries directly. + Only add the TreeSitter{Language}Runestone product — it pulls in the others. +- These packages require iOS 14+ (we target iOS 17+, so no issue). +- Build in Release configuration for best Runestone performance on large files. diff --git a/SceneDelegate.swift b/SceneDelegate.swift new file mode 100644 index 0000000..d1d0cbf --- /dev/null +++ b/SceneDelegate.swift @@ -0,0 +1,10 @@ +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { + guard let ws = scene as? UIWindowScene else { return } + ws.sizeRestrictions?.minimumSize = CGSize(width: 900, height: 600) + ws.sizeRestrictions?.maximumSize = CGSize(width: 99999, height: 99999) + } +} diff --git a/Settings/SettingsView.swift b/Settings/SettingsView.swift new file mode 100644 index 0000000..12c8bd8 --- /dev/null +++ b/Settings/SettingsView.swift @@ -0,0 +1,176 @@ +import SwiftUI +import UIKit + +struct SettingsView: View { + @EnvironmentObject var daemonConfig: DaemonConfiguration + @ObservedObject var editorState: EditorState + @ObservedObject var projectStore: ProjectStore + + @State private var pendingHost = "" + @State private var pendingPort = "" + @State private var wcApiKey = "" + @State private var connStatus: ConnStatus = .untested + @State private var showAddProject = false + + enum ConnStatus { case untested, testing, success, failure(String) + var label: String { switch self { case .untested: return "Not tested"; case .testing: return "Testing…"; case .success: return "Connected ✓"; case .failure(let m): return "Failed: \(m)" } } + var color: Color { switch self { case .success: return .green; case .failure: return .red; case .testing: return .secondary; default: return .secondary } } + static func ==(a: ConnStatus, b: ConnStatus) -> Bool { + switch (a,b) { case (.success,.success),(.untested,.untested),(.testing,.testing): return true; case (.failure(let x),.failure(let y)): return x==y; default: return false } + } + } + + var body: some View { + NavigationStack { + Form { + // ── Daemon ──────────────────────────────────────────────── + Section { + HStack { + Label("Tailscale IP", systemImage:"network"); Spacer() + TextField("100.x.x.x", text:$pendingHost).multilineTextAlignment(.trailing).keyboardType(.numbersAndPunctuation).autocorrectionDisabled().autocapitalization(.none).frame(width:160).onChange(of:pendingHost){_,_ in connStatus = .untested} + } + HStack { + Label("Port", systemImage:"antenna.radiowaves.left.and.right"); Spacer() + TextField("8080", text:$pendingPort).multilineTextAlignment(.trailing).keyboardType(.numberPad).frame(width:80) + } + HStack { Text("Status").foregroundStyle(.secondary); Spacer(); Text(connStatus.label).foregroundStyle(connStatus.color).font(.subheadline) } + Button("Test Connection") { Task { await testConnection() } }.disabled(pendingHost.isEmpty) + Button("Save & Apply") { applyConnection() }.buttonStyle(.borderedProminent) + .disabled(pendingHost.isEmpty || connStatus != .success) + } header: { Text("Mac Daemon") } + + // ── Projects ────────────────────────────────────────────── + Section { + ForEach(projectStore.projects) { p in + VStack(alignment:.leading, spacing:2) { + Text(p.name).font(.body) + Text(p.rootPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) + Text("Scheme: \(p.scheme) · WC: \(p.workingCopyRepoName.isEmpty ? "not set" : p.workingCopyRepoName)").font(.caption2).foregroundStyle(.tertiary) + }.padding(.vertical,2) + }.onDelete { projectStore.remove(atOffsets:$0) } + Button { showAddProject = true } label: { Label("Add Project", systemImage:"plus.circle") } + } header: { Text("Projects") } footer: { Text("Mac path should be the directory containing project.yml or .xcodeproj") } + + // ── Editor ──────────────────────────────────────────────── + Section("Editor Appearance") { + HStack { + Label("Font Size", systemImage:"textformat.size"); Spacer() + Stepper("\(Int(editorState.fontSize))pt", value:$editorState.fontSize, in:8...32, step:1).frame(width:160) + } + Picker(selection:$editorState.selectedTheme) { + ForEach(EditorThemeOption.allCases) { o in Text(o.displayName).tag(o) } + } label: { Label("Theme", systemImage:"paintbrush") } + Toggle(isOn:$editorState.showLineNumbers) { Label("Line Numbers", systemImage:"list.number") } + Toggle(isOn:$editorState.lineWrapping) { Label("Line Wrapping", systemImage:"text.word.spacing") } + HStack { + Label("Indent Width", systemImage:"arrow.right.to.line"); Spacer() + Picker("", selection:$editorState.indentWidth) { + Text("2 spaces").tag(2); Text("4 spaces").tag(4); Text("Tab").tag(0) + }.labelsHidden().pickerStyle(.menu) + } + } + + // ── Build ───────────────────────────────────────────────── + Section("Build") { + HStack { + Label("Team ID", systemImage:"person.badge.key"); Spacer() + TextField("XXXXXXXXXX", text:$daemonConfig.developmentTeam).multilineTextAlignment(.trailing).autocorrectionDisabled().autocapitalization(.none).frame(width:120) + } + Toggle(isOn:$daemonConfig.allowProvisioningUpdates) { Label("Allow Provisioning Updates", systemImage:"checkmark.seal") } + Toggle(isOn:$daemonConfig.buildInRelease) { Label("Build in Release", systemImage:"bolt") } + } + + // ── Working Copy ────────────────────────────────────────── + Section { + HStack { + Label("API Key", systemImage:"key.fill"); Spacer() + SecureField("Paste from Working Copy", text:$wcApiKey).multilineTextAlignment(.trailing).autocorrectionDisabled().autocapitalization(.none).frame(width:200) + .onChange(of:wcApiKey){_,k in UserDefaults.standard.set(k, forKey:"workingCopyAPIKey")} + } + let installed = UIApplication.shared.canOpenURL(URL(string:"working-copy://")!) + Label(installed ? "Working Copy detected ✓" : "Working Copy not installed", + systemImage: installed ? "checkmark.circle.fill" : "exclamationmark.triangle") + .foregroundStyle(installed ? .green : .orange).font(.caption) + Text("For local git over Tailscale, clone in Working Copy using:\nssh://user@100.x.x.x/path/to/repo.git") + .font(.caption).foregroundStyle(.secondary) + } header: { Text("Working Copy (Git)") } + + // ── LSP ─────────────────────────────────────────────────── + Section { + Toggle(isOn:$daemonConfig.lspEnabled) { Label("sourcekit-lsp Proxy", systemImage:"wand.and.stars") } + if daemonConfig.lspEnabled { + HStack { + Label("Status", systemImage:"circle.fill").foregroundStyle(daemonConfig.lspConnected ? Color.green : Color.red) + Spacer() + Text(daemonConfig.lspConnected ? "Running" : "Disconnected").foregroundStyle(.secondary) + } + } + } header: { Text("Language Server (LSP)") } + + // ── About ───────────────────────────────────────────────── + Section("About") { + LabeledContent("Version", value:"1.0.0-alpha") + LabeledContent("Editor Engine", value:"Runestone 0.5.1 / Tree-sitter") + LabeledContent("Protocol", value:"REST + WebSocket") + } + } + .navigationTitle("Settings").navigationBarTitleDisplayMode(.inline) + .onAppear { pendingHost = daemonConfig.host; pendingPort = String(daemonConfig.port) + wcApiKey = UserDefaults.standard.string(forKey:"workingCopyAPIKey") ?? "" } + .sheet(isPresented:$showAddProject) { AddProjectView(projectStore:projectStore) } + } + } + + private func testConnection() async { + connStatus = .testing + let host = pendingHost.trimmingCharacters(in:.whitespaces) + let port = Int(pendingPort) ?? 8080 + guard let url = URL(string:"http://\(host):\(port)/health") else { connStatus = .failure("Invalid URL"); return } + var req = URLRequest(url:url); req.timeoutInterval = 4 + do { + let (_,resp) = try await URLSession.shared.data(for:req) + connStatus = (resp as? HTTPURLResponse)?.statusCode == 200 ? .success : .failure("Unexpected response") + } catch { connStatus = .failure(error.localizedDescription) } + } + + private func applyConnection() { + daemonConfig.host = pendingHost.trimmingCharacters(in:.whitespaces) + daemonConfig.port = Int(pendingPort) ?? 8080 + } +} + +// MARK: - Add Project View + +struct AddProjectView: View { + @ObservedObject var projectStore: ProjectStore + @Environment(\.dismiss) var dismiss + @State private var name = ""; @State private var path = "" + @State private var scheme = ""; @State private var wcRepo = "" + + var body: some View { + NavigationStack { + Form { + Section("Project Details") { + TextField("Name (e.g. NavidromePlayer)", text:$name) + TextField("/Users/dallas/Dev/NavidromePlayer/NavidromePlayer", text:$path).autocorrectionDisabled().autocapitalization(.none).keyboardType(.URL) + TextField("Xcode Scheme", text:$scheme).autocorrectionDisabled().autocapitalization(.none) + } + Section("Working Copy") { + TextField("Repo name in Working Copy (e.g. NavidromePlayer)", text:$wcRepo).autocorrectionDisabled().autocapitalization(.none) + } footer: { Text("Must match the display name in Working Copy's repo list.") } + } + .navigationTitle("Add Project").navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement:.cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement:.confirmationAction) { + Button("Add") { + projectStore.add(SavedProject(name: name.isEmpty ? scheme : name, + rootPath: path, scheme: scheme, + workingCopyRepoName: wcRepo)) + dismiss() + }.disabled(path.isEmpty || scheme.isEmpty) + } + } + }.presentationDetents([.medium]) + } +} diff --git a/Shared/SharedModels.swift b/Shared/SharedModels.swift new file mode 100644 index 0000000..b079c07 --- /dev/null +++ b/Shared/SharedModels.swift @@ -0,0 +1,174 @@ +import Foundation +import SwiftUI + +// MARK: - File System + +struct FileNode: Codable, Identifiable { + var id: String { path } + let name: String + let path: String + let isDirectory: Bool + var children: [FileNode]? +} + +// MARK: - Devices + +struct ConnectedDevice: Codable, Identifiable { + var id: String { udid } + let udid: String + let name: String + let model: String + let osVersion: String + let isNetworkConnected: Bool +} + +// MARK: - Build Wire Types + +struct BuildLogMessage: Codable { + enum MessageType: String, Codable { case stdout, stderr, status } + let type: MessageType + let text: String + let exitCode: Int? +} + +struct BuildRequest: Codable { + let projectPath: String + let scheme: String + let destination: String + let action: String + let configuration: String + let preBuildHooks: [PreBuildHookRequest] + let bundleIdentifier: String? + let extraBuildSettings: [String] +} + +struct PreBuildHookRequest: Codable { + let name: String + let command: String + let workingDirectory: String + let timeoutSeconds: Int + let continueOnFailure: Bool + + static func xcodeGen(projectRoot: String) -> PreBuildHookRequest { + PreBuildHookRequest(name: "XcodeGen", command: "./generate.sh", + workingDirectory: projectRoot, timeoutSeconds: 60, + continueOnFailure: false) + } +} + +struct BuildSessionResponse: Codable { + let sessionId: String + let webSocketURL: String +} + +struct FileContentResponse: Codable { + let path: String + let content: String +} + +// MARK: - Console + +struct ConsoleLine: Identifiable { + let id = UUID() + let text: String + let type: LineType + + enum LineType { case standard, error, warning, success, info } + + var color: Color { + switch type { + case .standard: return .primary + case .error: return Color(red: 1.0, green: 0.45, blue: 0.45) + case .warning: return Color(red: 1.0, green: 0.75, blue: 0.35) + case .success: return Color(red: 0.45, green: 0.95, blue: 0.55) + case .info: return .secondary + } + } + + static func fromBuildLogMessage(_ msg: BuildLogMessage) -> [ConsoleLine] { + msg.text + .split(separator: "\n", omittingEmptySubsequences: false) + .map { raw in + let text = String(raw) + let type: LineType + if text.contains(": error:") { type = .error } + else if text.contains(": warning:") { type = .warning } + else if msg.type == .stderr { type = .error } + else if msg.type == .status { type = msg.exitCode == 0 ? .success : .error } + else { type = .standard } + return ConsoleLine(text: text, type: type) + } + } +} + +// MARK: - Diagnostics + +struct DiagnosticRange: Identifiable, Equatable { + enum Severity { case error, warning } + let id = UUID() + let line: Int + let column: Int + let length: Int + let severity: Severity + let message: String +} + +// MARK: - LSP / Completions + +struct CompletionItem: Identifiable { + let id = UUID() + let label: String + let detail: String + let kind: Int + let insertText: String? + let filterText: String? + + var symbolIcon: String { + switch kind { + case 2, 3: return "function" + case 6: return "variable" + case 7: return "cube" + case 8: return "curlybraces" + case 9: return "arrow.triangle.branch" + case 10: return "square.split.bottomrightquarter" + case 14: return "key.fill" + case 15: return "chevron.left.forwardslash.chevron.right" + default: return "doc.text" + } + } +} + +struct DefinitionLocation { + let filePath: String + let line: Int + let column: Int +} + +// MARK: - Notification Names + +extension Notification.Name { + static let gitCheckoutCompleted = Notification.Name("git.checkout.completed") + static let gitCommitCompleted = Notification.Name("git.commit.completed") + static let gitPushCompleted = Notification.Name("git.push.completed") + static let gitSyncCompleted = Notification.Name("git.sync.completed") + static let gitMergeCompleted = Notification.Name("git.merge.completed") + static let gitWriteCompleted = Notification.Name("git.write.completed") + static let gitReadCompleted = Notification.Name("git.read.completed") + static let gitOperationFailed = Notification.Name("git.operation.failed") + static let consoleClearRequested = Notification.Name("console.clear.requested") + static let navigateToRange = Notification.Name("editor.navigate.range") + static let triggerCompletion = Notification.Name("editor.trigger.completion") + static let openSettings = Notification.Name("padxcode.open.settings") +} + +// MARK: - String helpers + +extension String { + var urlEncoded: String { + addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? self + } + func replacingFirstOccurrence(of target: String, with replacement: String) -> String { + guard let range = self.range(of: target) else { return self } + return self.replacingCharacters(in: range, with: replacement) + } +} diff --git a/Theme/LanguageDetector.swift b/Theme/LanguageDetector.swift new file mode 100644 index 0000000..e39d4ed --- /dev/null +++ b/Theme/LanguageDetector.swift @@ -0,0 +1,17 @@ +import Runestone +import TreeSitterSwiftRunestone +import TreeSitterJSONRunestone +import TreeSitterMarkdownRunestone +import TreeSitterBashRunestone + +enum LanguageDetector { + static func language(for filePath: String) -> TreeSitterLanguage? { + switch (filePath as NSString).pathExtension.lowercased() { + case "swift": return .swift + case "json": return .json + case "md", "markdown": return .markdown + case "sh", "bash": return .bash + default: return nil + } + } +} diff --git a/Theme/PadXcodeTheme.swift b/Theme/PadXcodeTheme.swift new file mode 100644 index 0000000..fa826fa --- /dev/null +++ b/Theme/PadXcodeTheme.swift @@ -0,0 +1,151 @@ +import UIKit +import Runestone + +final class PadXcodeTheme: Theme { + var backgroundColor: UIColor { UIColor(hex: "#1E1E1E") } + var userInterfaceStyle: UIUserInterfaceStyle { .dark } + var font: UIFont { UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) } + var textColor: UIColor { UIColor(hex: "#D4D4D4") } + var gutterBackgroundColor: UIColor { UIColor(hex: "#252526") } + var gutterHairlineColor: UIColor { UIColor(hex: "#3C3C3C") } + var lineNumberColor: UIColor { UIColor(hex: "#6E7681") } + var lineNumberFont: UIFont { UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) } + var selectedLineBackgroundColor: UIColor { UIColor(hex: "#2A2D2E") } + var selectedLinesLineNumberColor: UIColor { UIColor(hex: "#C8C8C8") } + var selectedLinesGutterBackgroundColor: UIColor { UIColor(hex: "#2A2D2E") } + var markedTextBackgroundColor: UIColor { UIColor(hex: "#264F78").withAlphaComponent(0.4) } + var markedTextBackgroundCornerRadius: CGFloat { 2 } + var invisibleCharactersColor: UIColor { UIColor(hex: "#404040") } + var pageGuideHairlineColor: UIColor { UIColor(hex: "#404040") } + var pageGuideBackgroundColor: UIColor { UIColor(hex: "#1A1A1A") } + + func textStyle(for highlightName: String) -> TextStyle? { + switch highlightName { + case "keyword", "keyword.control", "keyword.operator", "keyword.other", + "keyword.declaration", "storage.type": + return TextStyle(color: UIColor(hex: "#FF7AB2"), bold: true) + case "storage.modifier": + return TextStyle(color: UIColor(hex: "#FF7AB2")) + case "type", "type.builtin", "entity.name.type", "entity.name.type.class", + "entity.name.type.struct", "entity.name.type.enum", "entity.name.type.protocol": + return TextStyle(color: UIColor(hex: "#DABAFF")) + case "type.parameter": + return TextStyle(color: UIColor(hex: "#6BDFFF")) + case "function", "entity.name.function", "meta.function-call", "function.method": + return TextStyle(color: UIColor(hex: "#67B7FB")) + case "function.builtin": + return TextStyle(color: UIColor(hex: "#67B7FB"), bold: true) + case "variable", "variable.other", "variable.other.member": + return TextStyle(color: UIColor(hex: "#D4D4D4")) + case "variable.builtin": + return TextStyle(color: UIColor(hex: "#FF7AB2")) + case "property", "variable.other.property": + return TextStyle(color: UIColor(hex: "#72B9D5")) + case "constant", "constant.builtin": + return TextStyle(color: UIColor(hex: "#D9C97C"), bold: true) + case "constant.numeric", "number": + return TextStyle(color: UIColor(hex: "#D9C97C")) + case "string", "string.special": + return TextStyle(color: UIColor(hex: "#FF8170")) + case "string.escape": + return TextStyle(color: UIColor(hex: "#FF8170"), bold: true) + case "comment", "comment.line", "comment.block": + return TextStyle(color: UIColor(hex: "#7F8C98"), italic: true) + case "comment.block.documentation": + return TextStyle(color: UIColor(hex: "#6A9955"), italic: true) + case "operator", "punctuation.operator", "punctuation.bracket", "punctuation.delimiter": + return TextStyle(color: UIColor(hex: "#D4D4D4")) + case "attribute": + return TextStyle(color: UIColor(hex: "#BF86C8")) + default: + return nil + } + } +} + +final class PadXcodeLightTheme: Theme { + var backgroundColor: UIColor { UIColor(hex: "#FFFFFF") } + var userInterfaceStyle: UIUserInterfaceStyle { .light } + var font: UIFont { UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) } + var textColor: UIColor { UIColor(hex: "#000000") } + var gutterBackgroundColor: UIColor { UIColor(hex: "#F2F2F2") } + var gutterHairlineColor: UIColor { UIColor(hex: "#D8D8D8") } + var lineNumberColor: UIColor { UIColor(hex: "#A0A0A0") } + var lineNumberFont: UIFont { UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) } + var selectedLineBackgroundColor: UIColor { UIColor(hex: "#ECF5FF") } + var selectedLinesLineNumberColor: UIColor { UIColor(hex: "#3A3A3A") } + var selectedLinesGutterBackgroundColor: UIColor { UIColor(hex: "#E8F0FA") } + var markedTextBackgroundColor: UIColor { UIColor(hex: "#B5D0F7").withAlphaComponent(0.4) } + var markedTextBackgroundCornerRadius: CGFloat { 2 } + var invisibleCharactersColor: UIColor { UIColor(hex: "#CCCCCC") } + var pageGuideHairlineColor: UIColor { UIColor(hex: "#DDDDDD") } + var pageGuideBackgroundColor: UIColor { UIColor(hex: "#F9F9F9") } + + func textStyle(for highlightName: String) -> TextStyle? { + switch highlightName { + case "keyword", "keyword.control", "keyword.operator", "keyword.other", + "keyword.declaration", "storage.type": + return TextStyle(color: UIColor(hex: "#AD3DA4"), bold: true) + case "storage.modifier": + return TextStyle(color: UIColor(hex: "#AD3DA4")) + case "type", "type.builtin", "entity.name.type", "entity.name.type.class", + "entity.name.type.struct", "entity.name.type.enum", "entity.name.type.protocol": + return TextStyle(color: UIColor(hex: "#703DAA")) + case "type.parameter": + return TextStyle(color: UIColor(hex: "#047CB0")) + case "function", "entity.name.function", "meta.function-call", "function.method": + return TextStyle(color: UIColor(hex: "#3900A0")) + case "function.builtin": + return TextStyle(color: UIColor(hex: "#3900A0"), bold: true) + case "variable", "variable.other", "variable.other.member": + return TextStyle(color: UIColor(hex: "#000000")) + case "variable.builtin": + return TextStyle(color: UIColor(hex: "#AD3DA4")) + case "property", "variable.other.property": + return TextStyle(color: UIColor(hex: "#047CB0")) + case "constant", "constant.builtin": + return TextStyle(color: UIColor(hex: "#272AD8"), bold: true) + case "constant.numeric", "number": + return TextStyle(color: UIColor(hex: "#272AD8")) + case "string", "string.special": + return TextStyle(color: UIColor(hex: "#C41A16")) + case "string.escape": + return TextStyle(color: UIColor(hex: "#C41A16"), bold: true) + case "comment", "comment.line", "comment.block": + return TextStyle(color: UIColor(hex: "#5D6C79"), italic: true) + case "comment.block.documentation": + return TextStyle(color: UIColor(hex: "#265B31"), italic: true) + case "operator", "punctuation.operator", "punctuation.bracket", "punctuation.delimiter": + return TextStyle(color: UIColor(hex: "#000000")) + case "attribute": + return TextStyle(color: UIColor(hex: "#6C36A9")) + default: + return nil + } + } +} + +// MARK: - TextStyle helpers + +extension TextStyle { + init(color: UIColor, bold: Bool = false, italic: Bool = false) { + self.init() + self.color = color + if bold { self.fontTraits.insert(.traitBold) } + if italic { self.fontTraits.insert(.traitItalic) } + } +} + +// MARK: - UIColor hex + +extension UIColor { + convenience init(hex: String) { + var h = hex.trimmingCharacters(in: .whitespacesAndNewlines) + h = h.hasPrefix("#") ? String(h.dropFirst()) : h + var rgb: UInt64 = 0 + Scanner(string: h).scanHexInt64(&rgb) + self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat( rgb & 0xFF) / 255, alpha: 1) + } +} diff --git a/UI/ToastSystem.swift b/UI/ToastSystem.swift new file mode 100644 index 0000000..c2b251f --- /dev/null +++ b/UI/ToastSystem.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct Toast: Identifiable { + enum Style { + case success, error, warning, info, git + var icon: String { + switch self { + case .success: return "checkmark.circle.fill" + case .error: return "xmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .info: return "info.circle.fill" + case .git: return "arrow.triangle.branch" + } + } + var color: Color { + switch self { + case .success: return .green; case .error: return .red + case .warning: return .orange; case .info: return .accentColor; case .git: return .purple + } + } + } + let id = UUID() + let message: String + let style: Style + let duration: TimeInterval + init(_ message: String, style: Style = .info, duration: TimeInterval = 3) { + self.message = message; self.style = style; self.duration = duration + } +} + +@MainActor +final class ToastStore: ObservableObject { + static let shared = ToastStore() + @Published var toasts: [Toast] = [] + + func show(_ toast: Toast) { + toasts.append(toast) + Task { + try? await Task.sleep(for: .seconds(toast.duration)) + toasts.removeAll { $0.id == toast.id } + } + } + func show(_ message: String, style: Toast.Style = .info, duration: TimeInterval = 3) { + show(Toast(message, style: style, duration: duration)) + } +} + +struct ToastOverlay: ViewModifier { + @ObservedObject var store: ToastStore + func body(content: Content) -> some View { + content.overlay(alignment: .bottom) { + VStack(spacing: 8) { + ForEach(store.toasts) { toast in + HStack(spacing: 10) { + Image(systemName: toast.style.icon).foregroundStyle(toast.style.color) + Text(toast.message).font(.subheadline).lineLimit(2) + Spacer() + Button { store.toasts.removeAll { $0.id == toast.id } } + label: { Image(systemName: "xmark").font(.caption.bold()).foregroundStyle(.secondary) } + .buttonStyle(.plain) + } + .padding(.horizontal, 14).padding(.vertical, 10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4) + .padding(.horizontal, 16) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .padding(.bottom, 60) + .animation(.spring(response: 0.3), value: store.toasts.count) + } + } +} + +extension View { + func toastOverlay() -> some View { modifier(ToastOverlay(store: ToastStore.shared)) } +} diff --git a/Validation/FileOperationValidator.swift b/Validation/FileOperationValidator.swift new file mode 100644 index 0000000..907ee7f --- /dev/null +++ b/Validation/FileOperationValidator.swift @@ -0,0 +1,50 @@ +import Foundation + +enum FileValidationError: LocalizedError { + case pathTraversal(String) + case emptyPath + case exceedsMaxSize(Int, Int) + case unsupportedBinaryExtension(String) + case reservedSystemPath(String) + + var errorDescription: String? { + switch self { + case .pathTraversal(let p): return "Path traversal in '\(p)'" + case .emptyPath: return "File path is empty" + case .exceedsMaxSize(let a, let l): return "File \(a/1_048_576)MB exceeds \(l/1_048_576)MB limit" + case .unsupportedBinaryExtension(let e): return ".\(e) is binary and cannot be text-edited" + case .reservedSystemPath(let p): return "'\(p)' is a system path" + } + } +} + +enum FileOperationValidator { + private static let maxBytes: Int = 10 * 1_048_576 + private static let binaryExtensions: Set = [ + "png","jpg","jpeg","gif","webp","pdf","zip","tar","gz", + "ipa","a","dylib","framework","app","xcassets","car","nib", + "storyboard","mp3","mp4","mov" + ] + private static let reservedPrefixes = ["/System/","/usr/","/bin/","/sbin/","/private/var/db/"] + + static func validateSave(path: String, content: String) throws { + try validatePath(path) + guard content.utf8.count <= maxBytes else { + throw FileValidationError.exceedsMaxSize(content.utf8.count, maxBytes) + } + } + + static func validatePath(_ path: String) throws { + guard !path.isEmpty else { throw FileValidationError.emptyPath } + if path.contains("../") || path.contains("/..") { + throw FileValidationError.pathTraversal(path) + } + for prefix in reservedPrefixes where path.hasPrefix(prefix) { + throw FileValidationError.reservedSystemPath(path) + } + let ext = (path as NSString).pathExtension.lowercased() + if binaryExtensions.contains(ext) { + throw FileValidationError.unsupportedBinaryExtension(ext) + } + } +} diff --git a/Validation/RetryPolicy.swift b/Validation/RetryPolicy.swift new file mode 100644 index 0000000..ca665b9 --- /dev/null +++ b/Validation/RetryPolicy.swift @@ -0,0 +1,31 @@ +import Foundation + +struct RetryPolicy { + let maxAttempts: Int + let baseDelay: TimeInterval + let maxDelay: TimeInterval + let shouldRetry: (Error) -> Bool + + static let fileSave = RetryPolicy(maxAttempts: 4, baseDelay: 0.25, maxDelay: 4.0) { _ in true } + static let network = RetryPolicy(maxAttempts: 3, baseDelay: 0.5, maxDelay: 8.0) { error in + let retryable: Set = [NSURLErrorTimedOut, NSURLErrorNetworkConnectionLost, + NSURLErrorNotConnectedToInternet, NSURLErrorCannotConnectToHost] + return retryable.contains((error as NSError).code) + } +} + +func withRetry(policy: RetryPolicy, operation: @escaping () async throws -> T) async throws -> T { + var lastError: Error? + var delay = policy.baseDelay + for attempt in 1...policy.maxAttempts { + do { return try await operation() } + catch { + lastError = error + guard policy.shouldRetry(error), attempt < policy.maxAttempts else { break } + let jitter = Double.random(in: 0...0.3) * delay + try? await Task.sleep(for: .seconds(min(delay + jitter, policy.maxDelay))) + delay = min(delay * 2, policy.maxDelay) + } + } + throw lastError ?? URLError(.unknown) +} diff --git a/Validation/UnsavedChangesGuard.swift b/Validation/UnsavedChangesGuard.swift new file mode 100644 index 0000000..5a63c77 --- /dev/null +++ b/Validation/UnsavedChangesGuard.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct RequestTabCloseAction { + let perform: (UUID) -> Void + func callAsFunction(_ id: UUID) { perform(id) } +} +private struct RequestTabCloseKey: EnvironmentKey { + static let defaultValue = RequestTabCloseAction { _ in } +} +extension EnvironmentValues { + var requestTabClose: RequestTabCloseAction { + get { self[RequestTabCloseKey.self] } + set { self[RequestTabCloseKey.self] = newValue } + } +} + +struct UnsavedChangesAlert: ViewModifier { + @ObservedObject var editorState: EditorState + let buildService: BuildService + @State private var pendingTabId: UUID? + @State private var showAlert = false + + func body(content: Content) -> some View { + content + .environment(\.requestTabClose, RequestTabCloseAction { tabId in + if let tab = editorState.tabs.first(where: { $0.id == tabId }), tab.isDirty { + pendingTabId = tabId; showAlert = true + } else { + editorState.closeTab(id: tabId) + } + }) + .alert("Unsaved Changes", isPresented: $showAlert) { + Button("Save & Close") { + if let id = pendingTabId, + let tab = editorState.tabs.first(where: { $0.id == id }) { + Task { + await buildService.saveFile(path: tab.path, content: tab.content) + editorState.closeTab(id: id) + } + } + } + Button("Discard", role: .destructive) { + if let id = pendingTabId { editorState.closeTab(id: id) } + } + Button("Cancel", role: .cancel) { pendingTabId = nil } + } message: { + if let id = pendingTabId, + let name = editorState.tabs.first(where: { $0.id == id })?.fileName { + Text("\"\(name)\" has unsaved changes.") + } + } + } +} + +extension View { + func unsavedChangesGuard(editorState: EditorState, buildService: BuildService) -> some View { + modifier(UnsavedChangesAlert(editorState: editorState, buildService: buildService)) + } +} diff --git a/Validation/WorkingCopyGuard.swift b/Validation/WorkingCopyGuard.swift new file mode 100644 index 0000000..dcff117 --- /dev/null +++ b/Validation/WorkingCopyGuard.swift @@ -0,0 +1,80 @@ +import UIKit +import SwiftUI +import Foundation + +enum WorkingCopyGuard { + enum GuardError: LocalizedError { + case notInstalled + case apiKeyMissing + case repoNameMissing(String) + + var errorDescription: String? { + switch self { + case .notInstalled: return "Working Copy is not installed." + case .apiKeyMissing: return "Working Copy API key not set. Go to Settings → Working Copy." + case .repoNameMissing(let p): return "No Working Copy repo name set for '\(p)'." + } + } + var actionLabel: String { + switch self { + case .notInstalled: return "App Store" + default: return "Open Settings" + } + } + } + + static func validate(projectName: String, requiresApiKey: Bool = true) throws { + guard UIApplication.shared.canOpenURL(URL(string: "working-copy://")!) else { + throw GuardError.notInstalled + } + if requiresApiKey { + let key = UserDefaults.standard.string(forKey: "workingCopyAPIKey") ?? "" + guard !key.trimmingCharacters(in: .whitespaces).isEmpty else { + throw GuardError.apiKeyMissing + } + } + let repoName = UserDefaults.standard.string(forKey: "wc_repo_for_\(projectName)") ?? "" + guard !repoName.trimmingCharacters(in: .whitespaces).isEmpty else { + throw GuardError.repoNameMissing(projectName) + } + } +} + +struct WorkingCopyErrorBanner: ViewModifier { + @Binding var error: WorkingCopyGuard.GuardError? + func body(content: Content) -> some View { + content.overlay(alignment: .top) { + if let err = error { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + Text(err.localizedDescription ?? "").font(.subheadline).lineLimit(2) + Spacer() + Button(err.actionLabel) { handleAction(for: err) }.buttonStyle(.bordered).controlSize(.small) + Button { withAnimation { self.error = nil } } + label: { Image(systemName: "xmark").font(.caption.bold()) } + .buttonStyle(.plain).foregroundStyle(.secondary) + } + .padding(.horizontal, 14).padding(.vertical, 10) + .background(.regularMaterial) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.spring(response: 0.3), value: error != nil) + } + + private func handleAction(for err: WorkingCopyGuard.GuardError) { + switch err { + case .notInstalled: + UIApplication.shared.open(URL(string: "https://apps.apple.com/app/working-copy-git-client/id896694807")!) + default: + NotificationCenter.default.post(name: .openSettings, object: nil) + } + self.error = nil + } +} + +extension View { + func workingCopyErrorBanner(_ error: Binding) -> some View { + modifier(WorkingCopyErrorBanner(error: error)) + } +} diff --git a/generate.sh b/generate.sh new file mode 100755 index 0000000..a8d58f4 --- /dev/null +++ b/generate.sh @@ -0,0 +1,27 @@ +#!/bin/zsh +set -euo pipefail + +BOLD='\033[1m'; GREEN='\033[0;32m'; BLUE='\033[0;34m'; RED='\033[0;31m'; NC='\033[0m' + +echo -e "\n${BOLD}PadXcode — Generating Xcode project${NC}\n" + +if ! command -v xcodegen &>/dev/null; then + echo -e "${RED}✗${NC} XcodeGen not found. brew install xcodegen" + exit 1 +fi + +SCRIPT_DIR="${0:A:h}" +cd "$SCRIPT_DIR" + +[ -d "PadXcode.xcodeproj" ] && rm -rf PadXcode.xcodeproj + +echo -e "${BLUE}▸${NC} Generating PadXcode.xcodeproj..." +xcodegen generate --spec project.yml + +echo -e "\n${GREEN}✓${NC} ${BOLD}Done.${NC}" +echo "" +echo " open PadXcode.xcodeproj" +echo " Signing & Capabilities → set your Development Team" +echo " Destination: your iPad" +echo " ⌘R to install" +echo "" diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..51d512b --- /dev/null +++ b/project.yml @@ -0,0 +1,98 @@ +name: PadXcode +options: + bundleIdPrefix: ca.dallasgroot + deploymentTarget: + iOS: "17.0" + xcodeVersion: "15.0" + createIntermediateGroups: true + generateEmptyDirectories: true + +settings: + base: + SWIFT_VERSION: "5.9" + IPHONEOS_DEPLOYMENT_TARGET: "17.0" + PRODUCT_BUNDLE_IDENTIFIER: ca.dallasgroot.PadXcode + PRODUCT_NAME: PadXcode + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "" + TARGETED_DEVICE_FAMILY: "2" + SUPPORTS_MAC_DESIGNED_FOR_IPAD: NO + SWIFT_OPTIMIZATION_LEVEL: "-Onone" + DEBUG_INFORMATION_FORMAT: dwarf + +packages: + Runestone: + url: https://github.com/simonbs/Runestone.git + from: "0.5.1" + TreeSitterLanguages: + url: https://github.com/simonbs/TreeSitterLanguages.git + from: "0.1.10" + +targets: + PadXcode: + type: application + platform: iOS + deploymentTarget: "17.0" + + sources: + - path: PadXcodeApp.swift + - path: SceneDelegate.swift + - path: Shared + createIntermediateGroups: true + - path: Config + createIntermediateGroups: true + - path: Network + createIntermediateGroups: true + - path: Git + createIntermediateGroups: true + - path: Editor + createIntermediateGroups: true + - path: Theme + createIntermediateGroups: true + - path: Diagnostics + createIntermediateGroups: true + - path: Navigator + createIntermediateGroups: true + - path: Layout + createIntermediateGroups: true + - path: UI + createIntermediateGroups: true + - path: Onboarding + createIntermediateGroups: true + - path: Settings + createIntermediateGroups: true + - path: Build + createIntermediateGroups: true + - path: Validation + createIntermediateGroups: true + + settings: + base: + INFOPLIST_FILE: Info.plist + CODE_SIGN_ENTITLEMENTS: PadXcode.entitlements + LD_RUNPATH_SEARCH_PATHS: + - "$(inherited)" + - "@executable_path/Frameworks" + + dependencies: + - package: Runestone + product: Runestone + - package: TreeSitterLanguages + product: TreeSitterSwiftRunestone + - package: TreeSitterLanguages + product: TreeSitterJSONRunestone + - package: TreeSitterLanguages + product: TreeSitterMarkdownRunestone + - package: TreeSitterLanguages + product: TreeSitterBashRunestone + + preBuildScripts: + - name: "SwiftLint (optional)" + script: | + if which swiftlint > /dev/null; then + swiftlint + fi + basedOnDependencyAnalysis: false + showEnvVars: false