PadXcode-iPad/Network/FileSyncPipeline.swift
2026-04-12 00:46:30 -07:00

135 lines
4.8 KiB
Swift

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