initial commit
This commit is contained in:
commit
3310282d4b
50 changed files with 5360 additions and 0 deletions
220
Build/BuildHistoryStore.swift
Normal file
220
Build/BuildHistoryStore.swift
Normal file
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
136
Build/BuildPreflightChecker.swift
Normal file
136
Build/BuildPreflightChecker.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
79
Build/PreflightView.swift
Normal file
79
Build/PreflightView.swift
Normal file
|
|
@ -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: " · ")
|
||||
}
|
||||
}
|
||||
24
Config/DaemonConfiguration.swift
Normal file
24
Config/DaemonConfiguration.swift
Normal file
|
|
@ -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" }
|
||||
}
|
||||
114
Config/LocalGitConfiguration.swift
Normal file
114
Config/LocalGitConfiguration.swift
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
34
Config/ProjectStore.swift
Normal file
34
Config/ProjectStore.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
38
Diagnostics/BuildDiagnosticParser.swift
Normal file
38
Diagnostics/BuildDiagnosticParser.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
74
Diagnostics/CompletionPopover.swift
Normal file
74
Diagnostics/CompletionPopover.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
143
Editor/CodeEditorView.swift
Normal file
143
Editor/CodeEditorView.swift
Normal file
|
|
@ -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<String>, 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
Editor/CompletionController.swift
Normal file
80
Editor/CompletionController.swift
Normal file
|
|
@ -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<Void, Never>?
|
||||
private var lastId = 0
|
||||
private let autoTriggers: Set<Character> = [".", "(", ","]
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
123
Editor/EditorKeyboardToolbar.swift
Normal file
123
Editor/EditorKeyboardToolbar.swift
Normal file
|
|
@ -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) }
|
||||
}
|
||||
117
Editor/EditorState.swift
Normal file
117
Editor/EditorState.swift
Normal file
|
|
@ -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<Void, Never>?
|
||||
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 } }
|
||||
}
|
||||
71
Editor/EditorTabBar.swift
Normal file
71
Editor/EditorTabBar.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
98
Editor/FindAcrossFilesView.swift
Normal file
98
Editor/FindAcrossFilesView.swift
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
73
Editor/FindInFileView.swift
Normal file
73
Editor/FindInFileView.swift
Normal file
|
|
@ -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) }
|
||||
}
|
||||
124
Editor/GutterAnnotationView.swift
Normal file
124
Editor/GutterAnnotationView.swift
Normal file
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
155
Editor/HardwareKeyboardCommandHandler.swift
Normal file
155
Editor/HardwareKeyboardCommandHandler.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
31
Editor/JumpToLineView.swift
Normal file
31
Editor/JumpToLineView.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
108
Editor/RunestoneEditorView.swift
Normal file
108
Editor/RunestoneEditorView.swift
Normal file
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
74
Git/GitCallbackHandler.swift
Normal file
74
Git/GitCallbackHandler.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
262
Git/GitPanel.swift
Normal file
262
Git/GitPanel.swift
Normal file
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
117
Git/GitService.swift
Normal file
117
Git/GitService.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
76
Git/GitStore.swift
Normal file
76
Git/GitStore.swift
Normal file
|
|
@ -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://")!
|
||||
)
|
||||
}
|
||||
}
|
||||
51
Info.plist
Normal file
51
Info.plist
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>PadXcode</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ca.dallasgroot.PadXcode</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>ca.dallasgroot.PadXcode</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>padxcode</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>working-copy</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
103
Layout/BuildToolbar.swift
Normal file
103
Layout/BuildToolbar.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
358
Layout/ContentView.swift
Normal file
358
Layout/ContentView.swift
Normal file
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
134
Layout/EnhancedConsoleView.swift
Normal file
134
Layout/EnhancedConsoleView.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
61
Navigator/FileOperationSheets.swift
Normal file
61
Navigator/FileOperationSheets.swift
Normal file
|
|
@ -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)])
|
||||
}
|
||||
}
|
||||
127
Navigator/GitAwareFileNavigatorView.swift
Normal file
127
Navigator/GitAwareFileNavigatorView.swift
Normal file
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
281
Network/BuildService.swift
Normal file
281
Network/BuildService.swift
Normal file
|
|
@ -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 = [] }
|
||||
}
|
||||
141
Network/DaemonHealthMonitor.swift
Normal file
141
Network/DaemonHealthMonitor.swift
Normal file
|
|
@ -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<Void, Never>?
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
135
Network/FileSyncPipeline.swift
Normal file
135
Network/FileSyncPipeline.swift
Normal file
|
|
@ -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<Void, Never>] = [:]
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
326
Network/LSPClient.swift
Normal file
326
Network/LSPClient.swift
Normal file
|
|
@ -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<LSPResponse, Error>] = [:]
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
215
Onboarding/OnboardingView.swift
Normal file
215
Onboarding/OnboardingView.swift
Normal file
|
|
@ -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..<steps.count, id: \.self) { i in
|
||||
Circle().fill(i <= step ? Color.accentColor : Color.secondary.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
.animation(.spring, value: step)
|
||||
}
|
||||
}.padding(.top, 20)
|
||||
|
||||
// Content
|
||||
TabView(selection: $step) {
|
||||
welcomeStep.tag(0)
|
||||
daemonStep.tag(1)
|
||||
workingCopyStep.tag(2)
|
||||
readyStep.tag(3)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
|
||||
// Navigation
|
||||
HStack {
|
||||
if step > 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
|
||||
}
|
||||
}
|
||||
10
PadXcode.entitlements
Normal file
10
PadXcode.entitlements
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Allows REST calls and WebSocket connections to the Mac daemon over Tailscale -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
40
PadXcodeApp.swift
Normal file
40
PadXcodeApp.swift
Normal file
|
|
@ -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) { } }
|
||||
}
|
||||
}
|
||||
34
README.md
Normal file
34
README.md
Normal file
|
|
@ -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
|
||||
23
SPM-DEPENDENCIES.md
Normal file
23
SPM-DEPENDENCIES.md
Normal file
|
|
@ -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.
|
||||
10
SceneDelegate.swift
Normal file
10
SceneDelegate.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
176
Settings/SettingsView.swift
Normal file
176
Settings/SettingsView.swift
Normal file
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
174
Shared/SharedModels.swift
Normal file
174
Shared/SharedModels.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
17
Theme/LanguageDetector.swift
Normal file
17
Theme/LanguageDetector.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
151
Theme/PadXcodeTheme.swift
Normal file
151
Theme/PadXcodeTheme.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
77
UI/ToastSystem.swift
Normal file
77
UI/ToastSystem.swift
Normal file
|
|
@ -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)) }
|
||||
}
|
||||
50
Validation/FileOperationValidator.swift
Normal file
50
Validation/FileOperationValidator.swift
Normal file
|
|
@ -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<String> = [
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Validation/RetryPolicy.swift
Normal file
31
Validation/RetryPolicy.swift
Normal file
|
|
@ -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<Int> = [NSURLErrorTimedOut, NSURLErrorNetworkConnectionLost,
|
||||
NSURLErrorNotConnectedToInternet, NSURLErrorCannotConnectToHost]
|
||||
return retryable.contains((error as NSError).code)
|
||||
}
|
||||
}
|
||||
|
||||
func withRetry<T>(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)
|
||||
}
|
||||
59
Validation/UnsavedChangesGuard.swift
Normal file
59
Validation/UnsavedChangesGuard.swift
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
80
Validation/WorkingCopyGuard.swift
Normal file
80
Validation/WorkingCopyGuard.swift
Normal file
|
|
@ -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<WorkingCopyGuard.GuardError?>) -> some View {
|
||||
modifier(WorkingCopyErrorBanner(error: error))
|
||||
}
|
||||
}
|
||||
27
generate.sh
Executable file
27
generate.sh
Executable file
|
|
@ -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 ""
|
||||
98
project.yml
Normal file
98
project.yml
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue