diff --git a/AGENTS.md b/AGENTS.md index 043b6d9..73714ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,6 +177,14 @@ brew upgrade --cask prompt ## Gotchas +- **`@Published` publishes on every assignment, even when the value is + unchanged.** The 5-second sync poll used to assign `lastError = nil` + unconditionally; `PanelView` observes `SyncService`, so the entire panel + (rows, overlays, composer) rebuilt every 5 seconds, forever. Over five days + of uptime that was ~185 CPU-minutes and ~190 MB of footprint, and every + click started to lag. Poll state now goes through `setError`, which no-ops + when equal, and the header's sync dot is its own view so sync publishes + cannot invalidate the list. - **Never assign `NSApp.mainMenu`.** SwiftUI installs a menu whose Edit items are how AppKit routes Cmd+C/V/X/A/Z; replacing it with a hand-built menu silently breaks pasting, copying and select-all in the composer (this shipped diff --git a/Sources/Prompt/ComposerView.swift b/Sources/Prompt/ComposerView.swift index fb81e09..f5f43ad 100644 --- a/Sources/Prompt/ComposerView.swift +++ b/Sources/Prompt/ComposerView.swift @@ -67,7 +67,9 @@ struct ComposerView: NSViewRepresentable { textView.string = text updateHeight(textView) } - textView.placeholderText = placeholder + if textView.placeholderText != placeholder { + textView.placeholderText = placeholder + } if isFocused, scrollView.window?.firstResponder != textView { scrollView.window?.makeFirstResponder(textView) } diff --git a/Sources/Prompt/PanelView.swift b/Sources/Prompt/PanelView.swift index 022987a..ea316a0 100644 --- a/Sources/Prompt/PanelView.swift +++ b/Sources/Prompt/PanelView.swift @@ -5,7 +5,6 @@ import UniformTypeIdentifiers struct PanelView: View { @ObservedObject var store: Store @ObservedObject private var sync = SyncService.shared - @State private var composer = "" @State private var composerFocused = false @State private var composerHeight: CGFloat = ComposerView.oneLineHeight @@ -84,8 +83,7 @@ struct PanelView: View { Spacer() Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: { - Image(systemName: "arrow.triangle.2.circlepath") - .foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor) + SyncDot() } .buttonStyle(.plain).help("Sync") Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") } @@ -881,3 +879,15 @@ struct PanelView: View { return (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string) } } + +/// Sync status lives in its own view so a `SyncService` publish cannot +/// invalidate the panel's list. Watching it from `PanelView`'s body meant every +/// sync event — including the 5-second poll — re-rendered every row. +private struct SyncDot: View { + @ObservedObject private var sync = SyncService.shared + + var body: some View { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor) + } +} diff --git a/Sources/Prompt/SyncService.swift b/Sources/Prompt/SyncService.swift index 50f3397..199873e 100644 --- a/Sources/Prompt/SyncService.swift +++ b/Sources/Prompt/SyncService.swift @@ -193,13 +193,20 @@ final class SyncService: ObservableObject { private func poll() { guard !isSyncing, let code, let versionURL = endpoint("/api/sync/\(code)/version") else { return } + // A cached /version would keep reporting the version the client already + // has, so polls must always hit the network. The responses are a few + // bytes; letting CFNetwork write them to the disk cache every 5 s only + // adds churn. + var versionRequest = URLRequest(url: versionURL) + versionRequest.cachePolicy = .reloadIgnoringLocalCacheData + versionRequest.timeoutInterval = 15 Task { do { // Cheap poll first: the full payload can be megabytes of // base64 images, so only download it when the version moved. - let (versionData, versionResponse) = try await URLSession.shared.data(from: versionURL) + let (versionData, versionResponse) = try await URLSession.shared.data(for: versionRequest) if (versionResponse as? HTTPURLResponse)?.statusCode == 404 { - lastError = "Sync group no longer exists on the server" + setError("Sync group no longer exists on the server") return } guard let versionHTTP = versionResponse as? HTTPURLResponse, versionHTTP.statusCode == 200 else { @@ -208,12 +215,15 @@ final class SyncService: ObservableObject { } let remoteVersion = try JSONDecoder().decode([String: Int64].self, from: versionData)["version"] ?? 0 guard remoteVersion > lastVersion else { - lastError = nil + setError(nil) return } syncLog("poll remote v\(remoteVersion) local v\(lastVersion) — fetching payload") guard let payloadURL = endpoint("/api/sync/\(code)") else { return } - let (data, response) = try await URLSession.shared.data(from: payloadURL) + var payloadRequest = URLRequest(url: payloadURL) + payloadRequest.cachePolicy = .reloadIgnoringLocalCacheData + payloadRequest.timeoutInterval = 30 + let (data, response) = try await URLSession.shared.data(for: payloadRequest) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return } let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data) if let payload = remote.data { @@ -221,15 +231,25 @@ final class SyncService: ObservableObject { } else { lastVersion = remote.version } - lastError = nil + setError(nil) } catch { syncLog("poll failed: \(error.localizedDescription)") // Polling errors are transient; stay quiet unless nothing ever worked. - if lastSyncedAt == nil { lastError = "Cannot reach sync server" } + if lastSyncedAt == nil { setError("Cannot reach sync server") } } } } + /// `@Published` fires on every assignment, even when the value is + /// unchanged. The 5-second poll would otherwise republish `lastError` + /// forever, and since `PanelView` observes this object, the whole panel + /// (list, rows, overlays) re-rendered every 5 seconds — the app grew to + /// ~190 MB and ~90 ms of layout per poll over five days of uptime. + private func setError(_ message: String?) { + guard lastError != message else { return } + lastError = message + } + private func restartPolling() { pollTimer?.invalidate() pollTimer = nil