141 lines
5 KiB
Swift
141 lines
5 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 var 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"
|
|
/// Rewires to use the real environment GitStore (not the shadow created in ContentView.init).
|
|
/// Called from ContentView.initialLoad() after @EnvironmentObjects are available.
|
|
func rewireGitStore(_ gitStore: GitStore) {
|
|
self.gitStore = gitStore
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|