import Foundation // MARK: - Sync Event enum SyncEvent { case savedToDaemon(path: String) case savedToWorkingCopy(path: String) case syncFailed(path: String, error: String) } // MARK: - FileSyncPipeline /// Coordinates saving a file to both the Mac daemon (for building) /// and Working Copy on the iPad (for git staging). /// /// Flow: /// Edit in editor /// └─► saveNow() / scheduleSave() /// └─► BuildService.saveFile() — REST PUT to Mac daemon /// └─► [if WC configured] /// GitService.writeFile() — working-copy://x-callback-url/write @MainActor final class FileSyncPipeline: ObservableObject { @Published var isSyncing: Bool = false @Published var lastEvent: SyncEvent? private let buildService: BuildService private let gitService: GitService private let gitStore: GitStore /// Keyed by file path — cancels previous debounce when a new edit arrives. private var debounceTimers: [String: Task] = [:] init( buildService: BuildService, gitService: GitService, gitStore: GitStore ) { self.buildService = buildService self.gitService = gitService self.gitStore = gitStore } // MARK: - Project Path → Working Copy Repo Mapping /// Call once per project when it loads. /// Stores a mapping from the Mac absolute root path to the WC repo name. /// projectRoot: "/Users/dallas/Dev/NavidromePlayer/NavidromePlayer" /// repoName: "NavidromePlayer" func registerProject(projectRoot: String, repoName: String) { UserDefaults.standard.set(repoName, forKey: wcKey(projectRoot)) } private func wcKey(_ root: String) -> String { "wc_repo_for_\(root)" } private func workingCopyMapping( for macPath: String ) -> (repo: String, relativePath: String)? { let allKeys = UserDefaults.standard .dictionaryRepresentation().keys .filter { $0.hasPrefix("wc_repo_for_") } for key in allKeys { let root = String(key.dropFirst("wc_repo_for_".count)) guard macPath.hasPrefix(root) else { continue } let repo = UserDefaults.standard.string(forKey: key) ?? "" let relative = String(macPath.dropFirst(root.count)) .trimmingCharacters(in: CharacterSet(charactersIn: "/")) return (repo: repo, relativePath: relative) } return nil } // MARK: - Debounced Save (called on every keystroke) /// Waits 1.5 s after the last edit then saves. /// Cancels any pending save for the same path when a new edit arrives. func scheduleSave(path: String, content: String) { debounceTimers[path]?.cancel() debounceTimers[path] = Task { [weak self] in try? await Task.sleep(for: .seconds(1.5)) guard !Task.isCancelled else { return } await self?.executeSave(path: path, content: content) } } // MARK: - Immediate Save (⌘S or tab switch) func saveNow(path: String, content: String) async { debounceTimers[path]?.cancel() await executeSave(path: path, content: content) } // MARK: - Execution private func executeSave(path: String, content: String) async { isSyncing = true defer { isSyncing = false } // ── Step 1: Save to Mac daemon ───────────────────────────────────── await buildService.saveFile(path: path, content: content) lastEvent = .savedToDaemon(path: path) // ── Step 2: Mirror to Working Copy ───────────────────────────────── // Only runs if: // • Working Copy is installed // • An active repo is set in GitStore // • A path mapping exists for this project guard gitStore.isWorkingCopyInstalled, !gitStore.activeRepo.isEmpty, let mapping = workingCopyMapping(for: path) else { return } // Validate WC can be reached before firing the URL scheme do { try WorkingCopyGuard.validate( projectName: gitStore.activeRepo, requiresApiKey: true ) } catch { // WC not fully configured yet — silently skip. // The daemon save already succeeded so nothing is lost. return } gitService.writeFile( repo: mapping.repo, path: mapping.relativePath, content: content, askCommit: false // Silent write — user commits manually from Git panel ) lastEvent = .savedToWorkingCopy(path: path) } }