Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ jobs:
fi
fi

- name: Validate Release reload artifact discovery
run: ./tests/test_reloadp_programa_artifact.sh

- name: Run bundled Ghostty theme picker helper regression
run: |
set -euo pipefail
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- Whole-codebase restructuring pass (internal, no behavior change): the remote-daemon stack moved out of `Workspace.swift`, browser data-import out of `BrowserPanel.swift`, v2 browser automation out of `TerminalController.swift`, UI-test harnesses out of `AppDelegate.swift`, and `TabManager`/`GhosttyNSView`/`ContentView` split into per-concern files — the largest source files shrank by 3,000–5,000 lines each, cutting incremental build times. The copy-pasted v1 telemetry-handler skeleton, agent-wrapper commands (Go and Swift), and boilerplate settings accessors were each collapsed onto single shared implementations.

### Fixed
- Remote-workspace localhost pages now use one browser/proxy alias contract (while accepting the legacy Programa alias), settings files keep applying valid sibling fields when one enum or numeric value is malformed, and browser suggestions contact only the search provider the user selected.
- JSON-RPC now rejects non-object `params` and boolean, fractional, or overflowing integer arguments instead of silently coercing them in workspace, surface, and pane operations. Session autosave change detection now derives from the exact snapshot being written, so same-count metadata and panel-title changes are not skipped.
- Port telemetry from shells and agents (`report_ports`, `clear_ports`) no longer blocks on the app's main thread, so a busy UI can't stall the socket.
- Notifications in multi-window sessions now respect which window owns the tab: the tab in front of you no longer fires an external banner, and background tabs in other windows are no longer misjudged as focused.
- Closing a pane now cleans up everything closing a single surface does — stale unread badges and leaked per-panel state are gone.
Expand Down
74 changes: 19 additions & 55 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) {
launchServicesRegistrationQueue.async(execute: work)
}
private var lastSessionAutosaveFingerprint: Int?
private var lastSessionAutosaveFingerprint: Data?
private var lastSessionAutosavePersistedAt: Date = .distantPast
private var lastTypingActivityAt: TimeInterval = 0
private var didHandleExplicitOpenIntentAtStartup = false
Expand Down Expand Up @@ -2166,42 +2166,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
didDisableSuddenTermination = false
}

private func sessionAutosaveFingerprint(includeScrollback: Bool) -> Int? {
guard !includeScrollback else { return nil }

var hasher = Hasher()
let contexts = mainWindowContexts.values.sorted { lhs, rhs in
lhs.windowId.uuidString < rhs.windowId.uuidString
}
hasher.combine(contexts.count)

for context in contexts.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) {
hasher.combine(context.windowId)
hasher.combine(context.tabManager.sessionAutosaveFingerprint())
hasher.combine(context.sidebarState.isVisible)
hasher.combine(
Int(SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)).rounded())
)

switch context.sidebarSelectionState.selection {
case .tabs:
hasher.combine(0)
case .notifications:
hasher.combine(1)
}

if let window = context.window ?? windowForMainWindowId(context.windowId) {
Self.hashFrame(window.frame, into: &hasher)
} else {
hasher.combine(-1)
}
}

return hasher.finalize()
}

@discardableResult
private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool {
private func saveSessionSnapshot(
includeScrollback: Bool,
removeWhenEmpty: Bool = false,
prebuiltSnapshot: AppSessionSnapshot? = nil
) -> Bool {
if Self.shouldSkipSessionSaveDuringStartupRestore(
isApplyingStartupSessionRestore: isApplyingStartupSessionRestore,
includeScrollback: includeScrollback
Expand All @@ -2227,7 +2197,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
#endif

guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else {
guard let snapshot = prebuiltSnapshot ?? buildSessionSnapshot(includeScrollback: includeScrollback) else {
persistSessionSnapshot(
nil,
removeWhenEmpty: removeWhenEmpty,
Expand Down Expand Up @@ -2346,7 +2316,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
#if DEBUG
let fingerprintStart = ProcessInfo.processInfo.systemUptime
#endif
let autosaveFingerprint = sessionAutosaveFingerprint(includeScrollback: false)
let autosaveSnapshot = buildSessionSnapshot(includeScrollback: false)
let autosaveFingerprint = autosaveSnapshot.flatMap {
SessionPersistenceStore.contentIdentity(for: $0)
}
#if DEBUG
fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0
#endif
Expand All @@ -2369,7 +2342,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
#if DEBUG
let saveStart = ProcessInfo.processInfo.systemUptime
#endif
_ = saveSessionSnapshot(includeScrollback: false)
_ = saveSessionSnapshot(
includeScrollback: false,
prebuiltSnapshot: autosaveSnapshot
)
#if DEBUG
saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0
#endif
Expand All @@ -2393,11 +2369,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
isTerminatingApp && includeScrollback
}

nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint(
nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint<Fingerprint: Equatable>(
isTerminatingApp: Bool,
includeScrollback: Bool,
previousFingerprint: Int?,
currentFingerprint: Int?,
previousFingerprint: Fingerprint?,
currentFingerprint: Fingerprint?,
lastPersistedAt: Date,
now: Date,
maximumAutosaveSkippableInterval: TimeInterval = 60
Expand All @@ -2416,24 +2392,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func updateSessionAutosaveSaveState(
includeScrollback: Bool,
persistedAt: Date,
fingerprint: Int?
fingerprint: Data?
) {
guard !isTerminatingApp, !includeScrollback else { return }
lastSessionAutosaveFingerprint = fingerprint
lastSessionAutosavePersistedAt = persistedAt
}

private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) {
let standardized = frame.standardized
let quantized = [
standardized.origin.x,
standardized.origin.y,
standardized.size.width,
standardized.size.height,
].map { Int(($0 * 2).rounded()) }
quantized.forEach { hasher.combine($0) }
}

private func persistSessionSnapshot(
_ snapshot: AppSessionSnapshot?,
removeWhenEmpty: Bool,
Expand Down Expand Up @@ -8971,4 +8936,3 @@ private extension AppDelegate {
}
}
}

43 changes: 13 additions & 30 deletions Sources/Panels/BrowserHistoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,18 @@ final class BrowserHistoryStore: ObservableObject {
actor BrowserSearchSuggestionService {
static let shared = BrowserSearchSuggestionService()

typealias RequestLoader = @Sendable (URLRequest) async throws -> (Data, URLResponse)

private let requestLoader: RequestLoader

init(
requestLoader: @escaping RequestLoader = { request in
try await URLSession.shared.data(for: request)
}
) {
self.requestLoader = requestLoader
}

func suggestions(engine: BrowserSearchEngine, query: String) async -> [String] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
Expand All @@ -666,38 +678,9 @@ actor BrowserSearchSuggestionService {
}
}

// Google's endpoint can intermittently throttle/block app-style traffic.
// Query fallbacks in parallel so we can show predictions quickly.
if engine == .google {
return await fetchRemoteSuggestionsWithGoogleFallbacks(query: trimmed)
}

return await fetchRemoteSuggestions(engine: engine, query: trimmed)
}

private func fetchRemoteSuggestionsWithGoogleFallbacks(query: String) async -> [String] {
await withTaskGroup(of: [String].self, returning: [String].self) { group in
group.addTask {
await self.fetchRemoteSuggestions(engine: .google, query: query)
}
group.addTask {
await self.fetchRemoteSuggestions(engine: .duckduckgo, query: query)
}
group.addTask {
await self.fetchRemoteSuggestions(engine: .bing, query: query)
}

while let result = await group.next() {
if !result.isEmpty {
group.cancelAll()
return result
}
}

return []
}
}

private func fetchRemoteSuggestions(engine: BrowserSearchEngine, query: String) async -> [String] {
let url: URL?
switch engine {
Expand Down Expand Up @@ -746,7 +729,7 @@ actor BrowserSearchSuggestionService {
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: req)
(data, response) = try await requestLoader(req)
} catch {
return []
}
Expand Down
29 changes: 6 additions & 23 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -657,14 +657,6 @@ final class BrowserPortalAnchorView: NSView {

@MainActor
final class BrowserPanel: Panel, ObservableObject {
private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me"
private static let remoteLoopbackHosts: Set<String> = [
"localhost",
"127.0.0.1",
"::1",
"0.0.0.0",
]

/// Shared process pool for cookie sharing across all browser panels
private static let sharedProcessPool = WKProcessPool()

Expand Down Expand Up @@ -2824,23 +2816,14 @@ final class BrowserPanel: Panel, ObservableObject {
}

private static func remoteProxyDisplayURL(for url: URL?) -> URL? {
guard let url else { return nil }
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url }
guard host == BrowserInsecureHTTPSettings.normalizeHost(remoteLoopbackProxyAliasHost) else { return url }

var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.host = "localhost"
return components?.url ?? url
WorkspaceRemoteLoopbackPolicy.displayURL(for: url)
}

private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? {
guard let scheme = url.scheme?.lowercased(), scheme == "http" else { return nil }
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil }
guard remoteLoopbackHosts.contains(host) else { return nil }

var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.host = remoteLoopbackProxyAliasHost
return components?.url
// Internal so the browser-to-proxy routing contract can be exercised as one
// behavioral path by the unit tests. Keep this as the production implementation,
// rather than duplicating the URL transformation in a test-only helper.
static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? {
WorkspaceRemoteLoopbackPolicy.browserAliasURL(for: url)
}

/// Navigate with smart URL/search detection
Expand Down
43 changes: 43 additions & 0 deletions Sources/Panels/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,49 @@ public enum PanelFocusIntent: Equatable {
case browser(BrowserPanelFocusIntent)
}

@MainActor
final class FocusTransitionCoordinator {
struct Owner: Equatable {
let workspaceID: UUID
let panelID: UUID
let intent: PanelFocusIntent
}

enum Reason: Equatable {
case workspaceSelection
case nonFocusSplit
}

struct Request: Equatable {
let generation: UInt64
let owner: Owner
let reason: Reason
}

private(set) var newestRequest: Request?
private(set) var committedOwner: Owner?
private var nextGeneration: UInt64 = 0

func beginTransition(to owner: Owner, reason: Reason) -> Request {
nextGeneration &+= 1
let request = Request(
generation: nextGeneration,
owner: owner,
reason: reason
)
newestRequest = request
return request
}

@discardableResult
func completeTransition(_ request: Request) -> Bool {
// Completion currently follows callback arrival order. The coordinator will
// become authoritative when focus ownership moves out of the individual layers.
committedOwner = request.owner
return true
}
}

public enum WorkspaceAttentionFlashReason: String, Equatable, Sendable {
case navigation
case notificationArrival
Expand Down
Loading
Loading