diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 2b8c2234b327..172593e99431 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -2779,6 +2779,7 @@ jobs: CmuxNotifications CmuxSettings CmuxSettingsUI + CmuxSidebarGit CmuxSurfaceCatalogModel CmuxSudoBroker CmuxSudoBrokerUI diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetectionService.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetectionService.swift new file mode 100644 index 000000000000..2bb5af23b9ec --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetectionService.swift @@ -0,0 +1,40 @@ +internal import Dispatch + +/// Coalesces browser discovery and isolates synchronous system and profile reads. +public actor BrowserInstalledBrowserDetectionService { + private let scan: @Sendable () -> [InstalledBrowserCandidate] + // Foundation/Launch Services discovery has no asynchronous API. This serial + // I/O lane prevents a blocked scan from occupying the UI or cooperative pool. + private let queue = DispatchQueue(label: "com.cmux.browser-discovery", qos: .utility) + private var inFlight: Task<[InstalledBrowserCandidate], Never>? + + /// Creates a discovery owner. + /// + /// - Parameter scan: Synchronous discovery performed on the I/O lane. Tests + /// provide a fixture-backed scanner; the default scans the current Mac. + public init(scan: @escaping @Sendable () -> [InstalledBrowserCandidate] = { + BrowserInstalledBrowserDetector().detectInstalledBrowsers() + }) { + self.scan = scan + } + + /// Returns a fresh discovery snapshot, sharing any scan already in progress. + /// + /// Cancellation does not interrupt an in-progress Foundation call. The one + /// scan remains owned until it completes; callers discard canceled results. + /// - Returns: Ranked installed browsers and their importable profiles. + public func detectInstalledBrowsers() async -> [InstalledBrowserCandidate] { + if let inFlight { return await inFlight.value } + let scan = scan + let queue = queue + let task = Task { + await withCheckedContinuation { continuation in + queue.async { continuation.resume(returning: scan()) } + } + } + inFlight = task + let result = await task.value + inFlight = nil + return result + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Values/BrowserImportDestinationResolver.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Values/BrowserImportDestinationResolver.swift new file mode 100644 index 000000000000..eb94aa9867bc --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Values/BrowserImportDestinationResolver.swift @@ -0,0 +1,63 @@ +public import Foundation + +/// Resolves browser-import destination selectors without touching app storage. +public struct BrowserImportDestinationResolver: Sendable { + /// The result of resolving a destination selector. + public enum Resolution: Equatable, Sendable { + /// No destination selector was supplied. + case none + /// An existing profile was selected by its stable identifier. + case matched(UUID) + /// More than one profile matched the display name or slug. + case ambiguous([BrowserProfileDefinition]) + /// No profile matched and creation was not requested. + case notFound(String) + /// The UUID-only selector was malformed or unknown. + case invalidIdentifier(String) + /// The caller requested creation after no existing profile matched. + case create(String) + } + + /// Creates a stateless destination resolver. + public init() {} + + /// Resolves an optional UUID or name/slug selector against profiles. + /// + /// - Parameters: + /// - rawSelector: A display name or slug, when supplied. + /// - rawIdentifier: The UUID-only selector, when supplied. + /// - createIfMissing: Whether a missing name may create a profile. + /// - profiles: The current profile definitions. + /// - Returns: A deterministic resolution result; no app state is mutated. + public func resolve( + rawSelector: String?, + rawIdentifier: String?, + createIfMissing: Bool, + profiles: [BrowserProfileDefinition] + ) -> Resolution { + let identifier = rawIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + if let identifier, !identifier.isEmpty { + guard let id = UUID(uuidString: identifier), + profiles.contains(where: { $0.id == id }) else { + return .invalidIdentifier(identifier) + } + return .matched(id) + } + + guard let rawSelector else { return .none } + let selector = rawSelector.trimmingCharacters(in: .whitespacesAndNewlines) + guard !selector.isEmpty else { return .notFound(selector) } + if let id = UUID(uuidString: selector) { + return profiles.contains(where: { $0.id == id }) + ? .matched(id) + : .invalidIdentifier(selector) + } + let matches = profiles.filter { + $0.displayName.localizedCaseInsensitiveCompare(selector) == .orderedSame + || $0.slug.localizedCaseInsensitiveCompare(selector) == .orderedSame + } + if matches.count == 1 { return .matched(matches[0].id) } + if matches.count > 1 { return .ambiguous(matches) } + return createIfMissing ? .create(selector) : .notFound(selector) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Import/BrowserImportDestinationResolverTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Import/BrowserImportDestinationResolverTests.swift new file mode 100644 index 000000000000..8ae2ca500ad3 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Import/BrowserImportDestinationResolverTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite("Browser import destination resolver") +struct BrowserImportDestinationResolverTests { + @Test func UUIDSelectionWinsAndAmbiguousNamesFailClosed() throws { + let profiles = [ + BrowserProfileDefinition(id: UUID(), displayName: "Shared", createdAt: .distantPast, isBuiltInDefault: false), + BrowserProfileDefinition(id: UUID(), displayName: "shared", createdAt: .distantPast, isBuiltInDefault: false), + ] + let resolver = BrowserImportDestinationResolver() + #expect(resolver.resolve(rawSelector: "SHARED", rawIdentifier: nil, createIfMissing: false, profiles: profiles) == .ambiguous(profiles)) + #expect(resolver.resolve(rawSelector: "Shared", rawIdentifier: profiles[0].id.uuidString, createIfMissing: false, profiles: profiles) == .matched(profiles[0].id)) + #expect(resolver.resolve(rawSelector: "new", rawIdentifier: nil, createIfMissing: true, profiles: profiles) == .create("new")) + } + + @Test func blankIdentifierFallsBackToNameSelector() { + let profile = BrowserProfileDefinition( + id: UUID(), displayName: "Work", createdAt: .distantPast, isBuiltInDefault: false + ) + let resolver = BrowserImportDestinationResolver() + + #expect(resolver.resolve( + rawSelector: "work", + rawIdentifier: " ", + createIfMissing: false, + profiles: [profile] + ) == .matched(profile.id)) + #expect(resolver.resolve( + rawSelector: nil, + rawIdentifier: "\t", + createIfMissing: false, + profiles: [profile] + ) == .none) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Import/BrowserInstalledBrowserDetectorAsyncTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Import/BrowserInstalledBrowserDetectorAsyncTests.swift new file mode 100644 index 000000000000..97cd2e4236ef --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Import/BrowserInstalledBrowserDetectorAsyncTests.swift @@ -0,0 +1,61 @@ +import Darwin +import Dispatch +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite("Browser discovery executor") +struct BrowserInstalledBrowserDetectorAsyncTests { + @MainActor + @Test("real profile discovery leaves the UI actor and preserves injected paths") + func offMainDetectionPreservesResults() async throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-browser-import-async-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let profile = home.appendingPathComponent( + "Library/Application Support/Google/Chrome/Default", isDirectory: true + ) + try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) + let marker = Data(UUID().uuidString.utf8) + try marker.write(to: profile.appendingPathComponent("History")) + + let service = BrowserInstalledBrowserDetectionService { + #expect(pthread_main_np() == 0) + return BrowserInstalledBrowserDetector( + homeDirectoryURL: home, + bundleLookup: { _ in + #expect(pthread_main_np() == 0) + return nil + }, + applicationSearchDirectories: [] + ).detectInstalledBrowsers() + } + let candidates = await service.detectInstalledBrowsers() + let chrome = try #require(candidates.first { $0.id == "google-chrome" }) + let foundProfile = try #require(chrome.profiles.first) + #expect(chrome.homeDirectoryURL == home) + #expect(foundProfile.rootURL.lastPathComponent == "Default") + #expect(try Data(contentsOf: foundProfile.rootURL.appendingPathComponent("History")) == marker) + } + + @MainActor + @Test("a blocked system scan leaves the main actor runnable") + func slowScanDoesNotBlockMainActor() async { + let (started, continuation) = AsyncStream.makeStream() + // This semaphore deliberately simulates a blocking system call on the + // I/O lane. It is never waited on by the test's main actor. + let release = DispatchSemaphore(value: 0) + defer { release.signal(); continuation.finish() } + let service = BrowserInstalledBrowserDetectionService { + continuation.yield(()) + release.wait() + return [] + } + let discovery = Task { await service.detectInstalledBrowsers() } + var iterator = started.makeAsyncIterator() + _ = await iterator.next() + #expect(pthread_main_np() != 0) + release.signal() + #expect(await discovery.value.isEmpty) + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileSystemEventReceiver.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileSystemEventReceiver.swift new file mode 100644 index 000000000000..14cbd58b800f --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileSystemEventReceiver.swift @@ -0,0 +1,28 @@ +import CoreServices +import Foundation + +/// Immutable callback context retained by FSEvents until queued teardown completes. +final class FileSystemEventReceiver: Sendable { + let onEvent: @Sendable (FileSystemEventBatch) -> Void + + init(onEvent: @escaping @Sendable (FileSystemEventBatch) -> Void) { + self.onEvent = onEvent + } + + func makeContext() -> FSEventStreamContext { + FSEventStreamContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: { pointer in + guard let pointer else { return nil } + _ = Unmanaged.fromOpaque(pointer).retain() + return pointer + }, + release: { pointer in + guard let pointer else { return } + Unmanaged.fromOpaque(pointer).release() + }, + copyDescription: nil + ) + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileSystemEventStream.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileSystemEventStream.swift index 69dfe5614baf..f212ba319f6e 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileSystemEventStream.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileSystemEventStream.swift @@ -17,39 +17,26 @@ struct FileSystemEventBatch: Sendable { /// (``RecursivePathWatcher``) observe events only via the watcher's /// `AsyncStream`. The stream is configured for file-level events. /// -/// **Threading.** Every instance shares one serial dispatch queue (rather than -/// one queue per stream) to bound thread usage when many workspaces are tracked. -/// All mutable state is touched only on that queue, which is why the type is -/// `@unchecked Sendable`. ``onEvent`` fires on the shared queue, so it MUST be -/// non-blocking — a slow sink would serialize behind every other stream's -/// teardown. The production sink only spawns a `Task` and returns. +/// Native registration and teardown run on a dedicated serial I/O queue. The +/// callback context is independently retained by FSEvents, so deallocation can +/// enqueue teardown without waiting for a daemon call or an in-flight callback. /// -/// **Context lifetime.** The stream is registered with FSEvents as its own -/// context `info` pointer, passed *unretained* (no `retain`/`release` callbacks). -/// That is safe because ``stop()`` invalidates the stream synchronously on the -/// shared queue before `deinit` returns: FSEvents delivers callbacks on that -/// same serial queue, so any in-flight or already-enqueued callback runs to -/// completion before the `queue.sync` teardown block, and none is delivered -/// after `FSEventStreamInvalidate`. No callback ever touches a freed instance, -/// so a separately retained context box is unnecessary. +/// The native pointer is immutable after construction; it crosses the queue +/// only for its single ownership transfer into teardown. All native lifecycle +/// calls and callbacks execute on that queue. final class FileSystemEventStream: @unchecked Sendable { - private static let queueSpecificKey = DispatchSpecificKey() - private static let queue: DispatchQueue = { - let queue = DispatchQueue(label: "com.cmux.recursive-path-watcher", qos: .utility) - queue.setSpecific(key: queueSpecificKey, value: 1) - return queue - }() + private static let queue = DispatchQueue( + label: "com.cmux.recursive-path-watcher", qos: .utility + ) /// The C trampoline `FSEventStreamCreate` requires. /// /// It must be a context-free `@convention(c)` function pointer, so it cannot /// be an instance method (which would be curried over `self`). The owning - /// stream is recovered from the context's `info` pointer instead — passed - /// *unretained* (see the type's "Context lifetime" note), so this uses - /// `takeUnretainedValue()` and never adjusts the reference count. + /// callback receiver is recovered from the context retained by FSEvents. private static let callback: FSEventStreamCallback = { _, info, eventCount, eventPaths, eventFlags, _ in guard let info else { return } - let owner = Unmanaged.fromOpaque(info).takeUnretainedValue() + let owner = Unmanaged.fromOpaque(info).takeUnretainedValue() owner.onEvent(FileSystemEventBatch( paths: paths(from: eventPaths, count: min(eventCount, maximumPathsPerBatch)), requiresFullRescan: eventCount > maximumPathsPerBatch @@ -57,10 +44,8 @@ final class FileSystemEventStream: @unchecked Sendable { )) } - /// The non-blocking sink invoked on the shared queue for each coalesced batch - /// of filesystem events. - private let onEvent: @Sendable (FileSystemEventBatch) -> Void - private var stream: FSEventStreamRef? + private let stream: FSEventStreamRef + private let lifecycleQueue: DispatchQueue /// Bounds native-to-Swift path copying for a single callback. A larger batch /// is represented as a full-rescan marker instead of allocating without limit. @@ -97,75 +82,62 @@ final class FileSystemEventStream: @unchecked Sendable { return false } - /// Creates and starts a stream for `paths`. - /// - /// - Parameters: - /// - paths: The files and directories to watch. Must be non-empty. - /// - latency: The FSEvents coalescing latency in seconds. - /// - onEvent: A non-blocking sink invoked on the shared queue for each - /// coalesced batch of filesystem events. - /// - Returns: `nil` if `paths` is empty or the underlying `FSEventStream` - /// could not be created or started. - init?( + /// Suspends while the native stream registers on the blocking-I/O lane. + static func start( paths: [String], latency: TimeInterval, - onEvent: @escaping @Sendable (FileSystemEventBatch) -> Void + onEvent: @escaping @Sendable (FileSystemEventBatch) -> Void, + queue: DispatchQueue = FileSystemEventStream.queue + ) async -> FileSystemEventStream? { + await withCheckedContinuation { continuation in + queue.async { + continuation.resume(returning: FileSystemEventStream( + paths: paths, latency: latency, onEvent: onEvent, queue: queue + )) + } + } + } + + private init?( + paths: [String], + latency: TimeInterval, + onEvent: @escaping @Sendable (FileSystemEventBatch) -> Void, + queue: DispatchQueue ) { guard !paths.isEmpty else { return nil } - self.onEvent = onEvent - self.stream = nil - - var context = FSEventStreamContext( - version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) + let receiver = FileSystemEventReceiver(onEvent: onEvent) + var context = receiver.makeContext() let flags = FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents) - guard let stream = FSEventStreamCreate( - nil, - Self.callback, - &context, - paths as CFArray, - FSEventStreamEventId(kFSEventStreamEventIdSinceNow), - latency, - flags - ) else { - return nil + let createdStream = withExtendedLifetime(receiver) { + FSEventStreamCreate( + nil, + Self.callback, + &context, + paths as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + latency, + flags + ) } - self.stream = stream - FSEventStreamSetDispatchQueue(stream, Self.queue) + guard let stream = createdStream else { return nil } + FSEventStreamSetDispatchQueue(stream, queue) guard FSEventStreamStart(stream) else { - stop() + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) return nil } - } - - /// Stops and tears down the stream. Idempotent. - /// - /// Teardown runs synchronously on the shared queue so it completes before the - /// caller continues — critically, before `deinit` returns. An async hop could - /// let the instance deallocate before the stream is invalidated, leaking the - /// `FSEventStream`. The `getSpecific` check tears down inline when already on - /// the queue, avoiding a deadlock. - func stop() { - if DispatchQueue.getSpecific(key: Self.queueSpecificKey) != nil { - stopOnQueue() - } else { - Self.queue.sync { stopOnQueue() } - } - } - - private func stopOnQueue() { - guard let stream else { return } - FSEventStreamStop(stream) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - self.stream = nil + self.stream = stream + self.lifecycleQueue = queue } deinit { - stop() + // The C pointer's only remaining owner is this queued cleanup. Its + // retained receiver stays alive until FSEventStreamRelease runs. + nonisolated(unsafe) let retiredStream = stream + lifecycleQueue.async { + FSEventStreamStop(retiredStream) + FSEventStreamInvalidate(retiredStream) + FSEventStreamRelease(retiredStream) + } } } diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileWatchClock.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileWatchClock.swift index 9657524562f7..917eb1de7fd4 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileWatchClock.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileWatchClock.swift @@ -13,7 +13,7 @@ import Foundation /// deterministically with no real waiting. /// /// ```swift -/// let watcher = RecursivePathWatcher(paths: paths, clock: SystemFileWatchClock()) +/// let watcher = await RecursivePathWatcher(paths: paths, clock: SystemFileWatchClock()) /// ``` public protocol FileWatchClock: Sendable { /// Suspends for `duration`, throwing `CancellationError` if the surrounding diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/RecursivePathWatcher.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/RecursivePathWatcher.swift index b5448ae1756c..d3a61a4ebe10 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/RecursivePathWatcher.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/RecursivePathWatcher.swift @@ -6,7 +6,7 @@ import Foundation /// for its domain) and consume ``events`` to react to changes: /// /// ```swift -/// guard let watcher = RecursivePathWatcher(paths: paths) else { return } +/// guard let watcher = await RecursivePathWatcher(paths: paths) else { return } /// let task = Task { @MainActor in /// for await _ in watcher.events { reload() } /// } @@ -22,14 +22,14 @@ import Foundation /// wait for changes to stop, which keeps reactions responsive without per-event /// churn. /// -/// **Construction.** The `FSEventStream` is created synchronously in ``init``, -/// so the watcher is already listening when it returns (nothing is missed in the -/// gap a deferred start would open) and ``init`` fails (`nil`) if the stream -/// cannot be created. The stream's `@Sendable` sink forwards into a private -/// raw-event `AsyncStream` rather than capturing the actor, which is what lets -/// creation happen in-`init`; a single actor-isolated pump drains that raw stream -/// and applies the throttle. The pump's lifetime is the raw stream's: ``stop()`` -/// and `deinit` finish it. +/// **Construction.** The initializer awaits native registration on a dedicated +/// I/O queue, so the watcher is already listening when it returns. A failed +/// registration returns `nil`. Neither the caller's actor nor Swift's +/// cooperative executor performs the blocking daemon registration. The stream's +/// `@Sendable` sink forwards into a private raw-event +/// `AsyncStream` rather than capturing the actor; a single actor-isolated pump +/// drains that raw stream and applies the throttle. The pump's lifetime is the +/// raw stream's: ``stop()`` and `deinit` finish it. public struct RecursivePathChange: Equatable, Sendable { /// Absolute paths reported during one bounded coalescing window. public let paths: [String] @@ -73,7 +73,7 @@ public actor RecursivePathWatcher { private let eventFilter: @Sendable (RecursivePathChange) -> Bool // nil only for the injected coalescer initializer, whose event source // drives ``receive(_:)`` without a real FSEventStream. - private let eventStream: FileSystemEventStream? + private var eventStream: FileSystemEventStream? // Finishing this ends the pump task (see init); raw FS events flow through it. private let rawContinuation: AsyncStream.Continuation private var pendingPaths: Set = [] @@ -86,7 +86,7 @@ public actor RecursivePathWatcher { /// Bounds path accumulation across multiple callbacks in one window. private static let maximumPendingPathCount = 4_096 - /// Creates and starts a watcher for `paths`. + /// Creates and starts a watcher for `paths` without blocking the caller's executor. /// /// - Parameters: /// - paths: The files and directories to watch. Must be non-empty. @@ -105,7 +105,7 @@ public actor RecursivePathWatcher { clock: any FileWatchClock = SystemFileWatchClock(), throttleInterval: Duration = .milliseconds(250), eventFilter: @escaping @Sendable (RecursivePathChange) -> Bool = { _ in true } - ) { + ) async { guard !paths.isEmpty else { return nil } self.watchedPaths = paths self.clock = clock @@ -127,9 +127,9 @@ public actor RecursivePathWatcher { self.rawContinuation = rawContinuation // The sink captures `rawContinuation` (a Sendable value), not `self`, so - // the stream can be created synchronously here without escaping the - // actor mid-init. - guard let eventStream = FileSystemEventStream( + // native stream can be registered off-actor without escaping this + // watcher before its initialization completes. + guard let eventStream = await FileSystemEventStream.start( paths: paths, latency: Self.streamLatency, onEvent: { batch in @@ -189,21 +189,21 @@ public actor RecursivePathWatcher { } /// Stops the watcher, tears down the underlying stream, and finishes - /// ``events``. Idempotent. + /// ``events``. Idempotent; native teardown is queued on its I/O lane. public func stop() { isStopped = true throttleTask?.cancel() throttleTask = nil - eventStream?.stop() + eventStream = nil rawContinuation.finish() continuation.finish() pathContinuation.finish() } deinit { - // FSEventStream teardown is synchronous and thread-safe; finishing the - // continuations ends the pump and any consumer. - eventStream?.stop() + // Native cleanup is queued by the stream owner; no caller waits for + // FSEvents. Finishing the continuations ends the pump and consumers. + eventStream = nil throttleTask?.cancel() rawContinuation.finish() continuation.finish() diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/FileWatch/FileSystemEventStreamLifecycleTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/FileWatch/FileSystemEventStreamLifecycleTests.swift new file mode 100644 index 000000000000..c3627be5b6e6 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/FileWatch/FileSystemEventStreamLifecycleTests.swift @@ -0,0 +1,88 @@ +import Dispatch +import Foundation +import Testing +@testable import CmuxFoundation + +@Suite("FSEvents nonblocking lifecycle") +struct FileSystemEventStreamLifecycleTests { + private final class WeakReference { + weak var value: Value? + init(_ value: Value?) { self.value = value } + } + + private final class LifetimeMarker: Sendable { + let completion: AsyncStream.Continuation + + init(completion: AsyncStream.Continuation) { self.completion = completion } + deinit { completion.yield(()); completion.finish() } + } + + @MainActor + @Test func releasingStreamDoesNotWaitForEventQueue() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-stream-lifetime-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let queue = DispatchQueue(label: "cmux.test.stream-lifetime") + let (released, completion) = AsyncStream.makeStream() + var marker: LifetimeMarker? = LifetimeMarker(completion: completion) + let retainedMarker = WeakReference(marker) + var stream = await FileSystemEventStream.start( + paths: [directory.path], latency: 0, + onEvent: { [marker] _ in withExtendedLifetime(marker) {} }, queue: queue + ) + marker = nil + #expect(stream != nil) + let releasedStream = WeakReference(stream) + let (blocked, continuation) = AsyncStream.makeStream() + let release = DispatchSemaphore(value: 0) + defer { release.signal(); continuation.finish() } + queue.async { + continuation.yield(()) + release.wait() + } + var iterator = blocked.makeAsyncIterator() + _ = await iterator.next() + stream = nil + #expect(releasedStream.value == nil) + #expect(retainedMarker.value != nil, "FSEvents must retain its receiver until native teardown runs.") + release.signal() + // Native teardown is ahead of this fence on the same serial queue. + await withCheckedContinuation { continuation in + queue.async { continuation.resume() } + } + // FSEvents can release its context from an internal queue after our + // lifecycle block returns. Await that actual ownership event. + var releaseIterator = released.makeAsyncIterator() + _ = await releaseIterator.next() + #expect(retainedMarker.value == nil, "Native teardown must release the callback graph.") + } + + @MainActor + @Test func registrationWaitDoesNotOccupyMainActor() async throws { + let queue = DispatchQueue(label: "cmux.test.stream-start") + let release = DispatchSemaphore(value: 0) + let (entered, enteredContinuation) = AsyncStream.makeStream() + defer { + release.signal() + enteredContinuation.finish() + } + queue.async { + enteredContinuation.yield(()) + release.wait() + } + var enteredIterator = entered.makeAsyncIterator() + _ = await enteredIterator.next() + let (registrationEntered, registrationContinuation) = AsyncStream.makeStream() + defer { registrationContinuation.finish() } + let registration = Task { @MainActor in + registrationContinuation.yield(()) + return await FileSystemEventStream.start(paths: [], latency: 0, onEvent: { _ in }, queue: queue) + } + var registrationIterator = registrationEntered.makeAsyncIterator() + _ = await registrationIterator.next() + // The queue can be held while the caller continues to do UI work. + release.signal() + #expect(await registration.value == nil) + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/FileWatch/RecursivePathWatcherTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/FileWatch/RecursivePathWatcherTests.swift index ccb8e6445f8b..41592d98da37 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/FileWatch/RecursivePathWatcherTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/FileWatch/RecursivePathWatcherTests.swift @@ -76,18 +76,21 @@ extension RecursivePathWatcher { } @Suite struct RecursivePathWatcherTests { - @Test func emptyPathsFailsInitialization() { - let watcher = RecursivePathWatcher(paths: []) + @Test func emptyPathsFailsInitialization() async { + let watcher = await RecursivePathWatcher(paths: []) #expect(watcher == nil) } - @Test func realDirectoryStartsAndStops() async { + /// The production sidebar creates watchers from a main-actor service. Keep + /// this lifecycle check on that actor so the asynchronous initializer's + /// executor boundary remains part of the tested contract. + @Test @MainActor func realDirectoryStartsAndStops() async { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-file-watch-\(UUID().uuidString)", isDirectory: true) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } - let watcher = RecursivePathWatcher(paths: [directory.path]) + let watcher = await RecursivePathWatcher(paths: [directory.path]) #expect(watcher != nil) #expect(watcher?.watchedPaths == [directory.path]) await watcher?.stop() diff --git a/Packages/macOS/CmuxGit/README.md b/Packages/macOS/CmuxGit/README.md index 21525fba07da..f78d29084269 100644 --- a/Packages/macOS/CmuxGit/README.md +++ b/Packages/macOS/CmuxGit/README.md @@ -44,7 +44,7 @@ let meta = await git.workspaceMetadata(for: checkoutPath) if meta.isRepository, meta.isDirty { showDirtyDot() } if let paths = await git.watchedPaths(for: checkoutPath) { - let watcher = RecursivePathWatcher(paths: paths) // CmuxFileWatch + let watcher = await RecursivePathWatcher(paths: paths) // CmuxFileWatch } let slugs = await git.repositorySlugs(forDirectory: checkoutPath) diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Watchers.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Watchers.swift index 6e20093dcd05..0c663b86420f 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Watchers.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Watchers.swift @@ -42,124 +42,158 @@ extension SidebarGitMetadataService { ) workspaceGitMetadataWatcherDescriptorRequestsByKey[key] = request - Task { [weak self] in - guard let gitMetadataService = self?.gitMetadataService else { return } - let descriptor = await gitMetadataService.watchDescriptor(for: directory) - await MainActor.run { [weak self] in - self?.applyWorkspaceGitMetadataWatcherDescriptor( - descriptor, - for: key, - request: request - ) - } + workspaceGitMetadataWatcherTasksByKey[key]?.cancel() + let reader = gitMetadataService + let makeWatcher = makeWatcher + workspaceGitMetadataWatcherTasksByKey[key] = Task { [weak self] in + let descriptor = await reader.watchDescriptor(for: directory) + guard !Task.isCancelled, + self?.prepareWorkspaceGitMetadataWatcher(descriptor, for: key, request: request) == true, + let descriptor else { return } + let watcher = await makeWatcher(descriptor) + self?.installWorkspaceGitMetadataWatcher(watcher, descriptor: descriptor, for: key, request: request) } } - private func applyWorkspaceGitMetadataWatcherDescriptor( + /// Validates the requested generation before doing native registration. + private func prepareWorkspaceGitMetadataWatcher( _ descriptor: GitWorkspaceMetadataWatchDescriptor?, for key: WorkspaceGitProbeKey, request: WorkspaceGitMetadataWatcherDescriptorRequest - ) { - guard workspaceGitMetadataWatcherDescriptorRequestsByKey[key] == request else { - return - } - workspaceGitMetadataWatcherDescriptorRequestsByKey.removeValue(forKey: key) - - if workspaceGitMetadataWatcherDescriptorInvalidatedKeys.remove(key) != nil { - guard sidebarGitMetadataActivePollingEnabled, - workspaceGitTrackedDirectoryByKey[key] == request.directory else { + ) -> Bool { + guard acceptWorkspaceGitMetadataWatcherRequest(for: key, request: request) else { return false } + guard let descriptor else { + // A failed rescan cannot retire a working watcher for this same + // directory. A directory change still releases the old ownership. + let installedPathsKey = workspaceGitMetadataWatcherWatchedPathsKeyByProbeKey[key] + let hasInstalledWatcher = workspaceGitMetadataWatcherSourceDirectoryByKey[key] == request.directory + && installedPathsKey.flatMap { workspaceGitMetadataWatchersByWatchedPathsKey[$0] } != nil + if hasInstalledWatcher { + finishWorkspaceGitMetadataWatcherRequest(for: key) + } else { stopWorkspaceGitMetadataWatcher(for: key) - return } - updateWorkspaceGitMetadataWatcher( - for: key, - directory: request.directory, - forceDescriptorRefresh: true - ) - return + return false + } + let watchedPathsKey = watchedPathsKey(for: descriptor) + if workspaceGitMetadataWatchersByWatchedPathsKey[watchedPathsKey] != nil { + finishWorkspaceGitMetadataWatcherRequest(for: key) + setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: key) + moveWorkspaceGitSnapshotCacheEligibility(for: key, to: request.directory) + logWatcherDegradationIfNeeded(for: descriptor) + return false } + // Keep the old watcher live until the replacement has registered. + return true + } - guard sidebarGitMetadataActivePollingEnabled, - workspaceGitTrackedDirectoryByKey[key] == request.directory, - let descriptor else { - stopWorkspaceGitMetadataWatcher(for: key) + /// Runs without suspension so no older success or failure can retire newer state. + private func installWorkspaceGitMetadataWatcher( + _ watcher: RecursivePathWatcher?, + descriptor: GitWorkspaceMetadataWatchDescriptor, + for key: WorkspaceGitProbeKey, + request: WorkspaceGitMetadataWatcherDescriptorRequest + ) { + guard acceptWorkspaceGitMetadataWatcherRequest(for: key, request: request) else { return } + let watchedPathsKey = watchedPathsKey(for: descriptor) + if workspaceGitMetadataWatchersByWatchedPathsKey[watchedPathsKey] != nil { + // Another panel completed the same registration while this one was + // suspended. Preserve its one event consumer and discard our duplicate. + finishWorkspaceGitMetadataWatcherRequest(for: key) + setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: key) + moveWorkspaceGitSnapshotCacheEligibility(for: key, to: request.directory) return } - - if let degradation = descriptor.degradation, - workspaceGitMetadataDegradationLoggedRepositoryRoots.insert(descriptor.repositoryRoot).inserted { - let message = "workspace.gitWatch.degraded " + degradation.logDescription - debugLog(message) - Self.gitWatchDiagnosticsLogger.info("\(message, privacy: .public)") + guard let watcher else { + // A failed replacement must leave the currently installed watcher + // and its event consumer authoritative until a later registration + // succeeds. Only retire the pending request itself. + finishWorkspaceGitMetadataWatcherRequest(for: key) + return } + finishWorkspaceGitMetadataWatcherRequest(for: key) + workspaceGitMetadataWatchersByWatchedPathsKey[watchedPathsKey] = watcher + setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: key) + moveWorkspaceGitSnapshotCacheEligibility(for: key, to: request.directory) + logWatcherDegradationIfNeeded(for: descriptor) + consumeWorkspaceGitMetadataWatcherEvents(watcher, descriptor: descriptor, watchedPathsKey: watchedPathsKey) + } - let watchedPathsKey = WorkspaceGitMetadataWatchedPathsKey( + private func watchedPathsKey( + for descriptor: GitWorkspaceMetadataWatchDescriptor + ) -> WorkspaceGitMetadataWatchedPathsKey { + WorkspaceGitMetadataWatchedPathsKey( paths: descriptor.watchedPaths, eventFilterIdentity: descriptor.eventFilterIdentity, eventCoalescingInterval: descriptor.eventCoalescingInterval ) - if workspaceGitMetadataWatchersByWatchedPathsKey[watchedPathsKey] != nil { - setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: key) - moveWorkspaceGitSnapshotCacheEligibility(for: key, to: request.directory) - return + } + + private func finishWorkspaceGitMetadataWatcherRequest(for key: WorkspaceGitProbeKey) { + workspaceGitMetadataWatcherDescriptorRequestsByKey.removeValue(forKey: key) + workspaceGitMetadataWatcherTasksByKey.removeValue(forKey: key) + } + + private func acceptWorkspaceGitMetadataWatcherRequest( + for key: WorkspaceGitProbeKey, + request: WorkspaceGitMetadataWatcherDescriptorRequest + ) -> Bool { + guard workspaceGitMetadataWatcherDescriptorRequestsByKey[key] == request else { return false } + guard sidebarGitMetadataActivePollingEnabled, + workspaceGitTrackedDirectoryByKey[key] == request.directory else { + stopWorkspaceGitMetadataWatcher(for: key) + return false } + if workspaceGitMetadataWatcherDescriptorInvalidatedKeys.remove(key) != nil { + finishWorkspaceGitMetadataWatcherRequest(for: key) + updateWorkspaceGitMetadataWatcher(for: key, directory: request.directory, forceDescriptorRefresh: true) + return false + } + return true + } - stopWorkspaceGitMetadataWatcher(for: key) - if let watcher = RecursivePathWatcher( - paths: descriptor.watchedPaths, - throttleInterval: descriptor.eventCoalescingInterval, - eventFilter: { descriptor.containsRelevantChange( - paths: $0.paths, - requiresFullRescan: $0.requiresFullRescan - ) } - ) { - workspaceGitMetadataWatchersByWatchedPathsKey[watchedPathsKey] = watcher - setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: key) - moveWorkspaceGitSnapshotCacheEligibility(for: key, to: request.directory) - let events = watcher.pathEvents - workspaceGitMetadataWatcherRefreshTasksByWatchedPathsKey[watchedPathsKey] = Task { @MainActor [weak self] in - for await change in events { - guard let self else { break } - // The watcher key includes the immutable filter identity, - // watched roots, and throttle. Its event filter has already - // evaluated this batch once, so every attached probe shares - // the same relevance result. - let keys = Array( - self.workspaceGitMetadataWatcherProbeKeysByWatchedPathsKey[watchedPathsKey] ?? [] + private func consumeWorkspaceGitMetadataWatcherEvents( + _ watcher: RecursivePathWatcher, + descriptor: GitWorkspaceMetadataWatchDescriptor, + watchedPathsKey: WorkspaceGitMetadataWatchedPathsKey + ) { + let events = watcher.pathEvents + workspaceGitMetadataWatcherRefreshTasksByWatchedPathsKey[watchedPathsKey] = Task { @MainActor [weak self] in + for await change in events { + guard let self, !Task.isCancelled else { break } + let keys = Array(workspaceGitMetadataWatcherProbeKeysByWatchedPathsKey[watchedPathsKey] ?? []) + guard !keys.isEmpty else { continue } + recordWorkspaceGitMetadataFilesystemEvent(for: keys) + for key in keys { + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: key.workspaceId, panelId: key.panelId, reason: "filesystemEvent" ) - guard !keys.isEmpty else { continue } - self.recordWorkspaceGitMetadataFilesystemEvent(for: keys) - for key in keys { - self.scheduleWorkspaceGitMetadataRefreshIfPossible( - workspaceId: key.workspaceId, - panelId: key.panelId, - reason: "filesystemEvent" - ) - } - guard descriptor.containsGitMetadataChange( - paths: change.paths, - requiresFullRescan: change.requiresFullRescan - ) else { - continue - } - for key in keys { - guard let directory = self.workspaceGitMetadataWatcherSourceDirectoryByKey[key] else { - continue - } - self.updateWorkspaceGitMetadataWatcher( - for: key, - directory: directory, - forceDescriptorRefresh: true - ) - } + } + guard descriptor.containsGitMetadataChange( + paths: change.paths, requiresFullRescan: change.requiresFullRescan + ) else { continue } + for key in keys { + guard let directory = workspaceGitMetadataWatcherSourceDirectoryByKey[key] else { continue } + updateWorkspaceGitMetadataWatcher(for: key, directory: directory, forceDescriptorRefresh: true) } } - } else { - setWorkspaceGitMetadataWatcherSourceDirectory(request.directory, for: key) - setWorkspaceGitMetadataWatcherWatchedPathsKey(nil, for: key) } } + private func logWatcherDegradationIfNeeded( + for descriptor: GitWorkspaceMetadataWatchDescriptor + ) { + guard let degradation = descriptor.degradation, + workspaceGitMetadataDegradationLoggedRepositoryRoots + .insert(descriptor.repositoryRoot) + .inserted else { + return + } + let message = "workspace.gitWatch.degraded " + degradation.logDescription + debugLog(message) + Self.gitWatchDiagnosticsLogger.info("\(message, privacy: .public)") + } + func workspaceGitSnapshotCacheGeneration(directory: String) -> UInt64? { workspaceGitSnapshotCacheGenerationByDirectory[directory] } @@ -209,7 +243,7 @@ extension SidebarGitMetadataService { workspaceGitMetadataWatcherRefreshTasksByWatchedPathsKey .removeValue(forKey: previousWatchedPathsKey)? .cancel() - // Dropping the last watcher reference invalidates the FSEventStream. + // Dropping the last reference queues native cleanup without blocking this actor. workspaceGitMetadataWatchersByWatchedPathsKey.removeValue(forKey: previousWatchedPathsKey) } } @@ -274,6 +308,7 @@ extension SidebarGitMetadataService { } func stopWorkspaceGitMetadataWatcher(for key: WorkspaceGitProbeKey) { + workspaceGitMetadataWatcherTasksByKey.removeValue(forKey: key)?.cancel() let stoppedDirectory = workspaceGitMetadataWatcherSourceDirectoryByKey[key] workspaceGitMetadataWatcherDescriptorRequestsByKey.removeValue(forKey: key) workspaceGitMetadataWatcherDescriptorInvalidatedKeys.remove(key) @@ -292,12 +327,13 @@ extension SidebarGitMetadataService { } func stopAllWorkspaceGitMetadataWatchers() { + for task in workspaceGitMetadataWatcherTasksByKey.values { task.cancel() } + workspaceGitMetadataWatcherTasksByKey.removeAll() for task in workspaceGitMetadataWatcherRefreshTasksByWatchedPathsKey.values { task.cancel() } workspaceGitMetadataWatcherRefreshTasksByWatchedPathsKey.removeAll() - // Dropping the references runs each watcher's deinit synchronously, - // invalidating its FSEventStream. + // Dropping references queues native cleanup without blocking this actor. workspaceGitMetadataWatchersByWatchedPathsKey.removeAll() workspaceGitMetadataWatcherSourceDirectoryByKey.removeAll() workspaceGitMetadataWatcherKeysBySourceDirectory.removeAll() diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService.swift index c0ec106258ae..6a8905494c24 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService.swift @@ -51,6 +51,7 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { let mobileHostDeferral: MobileHostDeferralPolicy // Debug diagnostics sink (the app injects its debug logger in DEBUG). let debugLog: @Sendable (String) -> Void + let makeWatcher: @Sendable (GitWorkspaceMetadataWatchDescriptor) async -> RecursivePathWatcher? // The window-side seam; set once via attach(host:). Weak: the host owns // this service. private(set) weak var host: (any SidebarGitHosting)? @@ -72,6 +73,7 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { var workspaceGitMetadataWatcherDescriptorRequestsByKey: [WorkspaceGitProbeKey: WorkspaceGitMetadataWatcherDescriptorRequest] = [:] var workspaceGitMetadataWatcherDescriptorInvalidatedKeys: Set = [] var workspaceGitMetadataDegradationLoggedRepositoryRoots: Set = [] + var workspaceGitMetadataWatcherTasksByKey: [WorkspaceGitProbeKey: Task] = [:] var workspaceGitMetadataWatcherDescriptorGeneration: UInt64 = 0 var workspaceGitMetadataFilesystemEventGeneration: UInt64 = 0 let workspaceGitSnapshotCacheNamespace = UUID() @@ -93,7 +95,7 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { /// - clock: Initial-probe retry clock; tests inject virtual time. /// - mobileHostDeferral: Mobile-host deferral intervals. /// - debugLog: Diagnostics sink; defaults to a no-op. - public init( + public convenience init( workspaceGitMetadataReader: any WorkspaceGitMetadataReading, gitMetadataService: any GitMetadataWatchDescriptorReading, pullRequestProbing: any PullRequestProbing, @@ -101,6 +103,36 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { clock: any GitPollClock = SystemGitPollClock(), mobileHostDeferral: MobileHostDeferralPolicy = .standard, debugLog: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.init( + workspaceGitMetadataReader: workspaceGitMetadataReader, + gitMetadataService: gitMetadataService, + pullRequestProbing: pullRequestProbing, + probeLimiter: probeLimiter, + clock: clock, + mobileHostDeferral: mobileHostDeferral, + debugLog: debugLog, + makeWatcher: { descriptor in + await RecursivePathWatcher( + paths: descriptor.watchedPaths, + throttleInterval: descriptor.eventCoalescingInterval, + eventFilter: { descriptor.containsRelevantChange( + paths: $0.paths, requiresFullRescan: $0.requiresFullRescan + ) } + ) + } + ) + } + + init( + workspaceGitMetadataReader: any WorkspaceGitMetadataReading, + gitMetadataService: any GitMetadataWatchDescriptorReading, + pullRequestProbing: any PullRequestProbing, + probeLimiter: WorkspaceGitMetadataProbeLimiter, + clock: any GitPollClock, + mobileHostDeferral: MobileHostDeferralPolicy = .standard, + debugLog: @escaping @Sendable (String) -> Void = { _ in }, + makeWatcher: @escaping @Sendable (GitWorkspaceMetadataWatchDescriptor) async -> RecursivePathWatcher? ) { self.workspaceGitMetadataReader = workspaceGitMetadataReader self.gitMetadataService = gitMetadataService @@ -109,9 +141,12 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { self.clock = clock self.mobileHostDeferral = mobileHostDeferral self.debugLog = debugLog + self.makeWatcher = makeWatcher } deinit { + for task in workspaceGitMetadataWatcherTasksByKey.values { task.cancel() } + for task in workspaceGitMetadataWatcherRefreshTasksByWatchedPathsKey.values { task.cancel() } for task in workspaceGitProbeTasksByKey.values { task.cancel() } diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests.swift index 4b3680040119..efefb3ebe6f8 100644 --- a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests.swift +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests.swift @@ -118,10 +118,13 @@ import CmuxGit #expect(pullRequestProbing.scheduledRefreshes.isEmpty) } - @Test func remotePwdPreservesMetadataReportedBeforeFirstTrustedDirectory() async throws { + /// Metadata received before directory provenance is established cannot be + /// attributed to the first trusted path (including a restored local fallback). + @Test(arguments: [nil, "/local/fallback"] as [String?]) + func firstTrustedRemoteDirectoryClearsUnboundMetadata(previousDirectory: String?) async throws { let host = RecordingSidebarGitHost() host.pollingEnabled = true - let (workspaceId, panelId) = host.addWorkspace(panelDirectory: nil) + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: previousDirectory) host.workspaces[0].state.isRemote = true host.workspaces[0].state.panels[panelId]?.isRemoteTerminal = true let clock = ManualGitPollClock() @@ -154,14 +157,11 @@ import CmuxGit displayLabel: nil ) - #expect(host.workspaces[0].state.panels[panelId]?.branch == SidebarPanelGitBranch( - branch: "remote-main", - isDirty: false - )) - #expect(host.workspaces[0].state.panels[panelId]?.badge == badge) + #expect(host.workspaces[0].state.panels[panelId]?.branch == nil) + #expect(host.workspaces[0].state.panels[panelId]?.badge == nil) #expect(pullRequestProbing.scheduledRefreshes.isEmpty) - #expect(!host.events.contains(.clearGitBranch(workspaceId, panelId))) - #expect(!host.events.contains(.clearPullRequestBadge(workspaceId, panelId))) + #expect(host.events.contains(.clearGitBranch(workspaceId, panelId))) + #expect(host.events.contains(.clearPullRequestBadge(workspaceId, panelId))) } @Test func trustedRemoteDirectoryChangeClearsStaleMetadataWithoutLocalProbe() async throws { diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/RemoteDirectoryProvenanceTests.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/RemoteDirectoryProvenanceTests.swift new file mode 100644 index 000000000000..7c29ade7d0f5 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/RemoteDirectoryProvenanceTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +import CmuxGit +@testable import CmuxSidebarGit + +@MainActor +@Suite struct RemoteDirectoryProvenanceTests { + @Test func repeatedTrustedDirectoryPreservesMetadataUntilPathChanges() async { + let host = RecordingSidebarGitHost() + host.pollingEnabled = true + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: nil) + host.workspaces[0].state.isRemote = true + host.workspaces[0].state.panels[panelId]?.isRemoteTerminal = true + let reader = GatedMetadataReader(metadata: .repository(branch: "local-main")) + let clock = ManualGitPollClock() + let pullRequestProbing = RecordingPullRequestProbing() + let service = SidebarGitMetadataService( + workspaceGitMetadataReader: reader, + gitMetadataService: GitMetadataService(), + pullRequestProbing: pullRequestProbing, + probeLimiter: WorkspaceGitMetadataProbeLimiter(limit: 2), + clock: clock + ) + service.attach(host: host) + service.updateRemoteSurfaceDirectory( + workspaceId: workspaceId, panelId: panelId, + directory: "/srv/project", displayLabel: nil + ) + service.updateSurfaceGitBranch( + workspaceId: workspaceId, panelId: panelId, + branch: "remote-main", isDirty: false + ) + let badge = SidebarPullRequestBadge( + number: 7277, label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/7277")!, + status: .open, branch: "remote-main" + ) + host.updatePanelPullRequest(workspaceId: workspaceId, panelId: panelId, badge: badge) + + service.updateRemoteSurfaceDirectory( + workspaceId: workspaceId, panelId: panelId, + directory: "/srv/project", displayLabel: nil + ) + #expect(host.workspaces[0].state.panels[panelId]?.branch?.branch == "remote-main") + #expect(host.workspaces[0].state.panels[panelId]?.badge == badge) + #expect(!host.events.contains(.clearGitBranch(workspaceId, panelId))) + #expect(!host.events.contains(.clearPullRequestBadge(workspaceId, panelId))) + + service.updateRemoteSurfaceDirectory( + workspaceId: workspaceId, panelId: panelId, + directory: "/srv/other", displayLabel: nil + ) + #expect(host.workspaces[0].state.panels[panelId]?.branch == nil) + #expect(host.workspaces[0].state.panels[panelId]?.badge == nil) + #expect(host.events.contains(.clearGitBranch(workspaceId, panelId))) + #expect(host.events.contains(.clearPullRequestBadge(workspaceId, panelId))) + #expect(pullRequestProbing.scheduledRefreshes.isEmpty) + #expect(await clock.recordedDurations.isEmpty) + #expect(await reader.probedDirectories.isEmpty) + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/WatcherRegistrationGate.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/WatcherRegistrationGate.swift new file mode 100644 index 000000000000..cc4059011e9a --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/WatcherRegistrationGate.swift @@ -0,0 +1,32 @@ +import CmuxFoundation +import CmuxGit + +/// Parks native registration completions so tests can interleave owner changes. +actor WatcherRegistrationGate { + private let arrivals: AsyncStream + private let arrivalContinuation: AsyncStream.Continuation + private var nextID = 0 + private var pending: [Int: CheckedContinuation] = [:] + + init() { + (arrivals, arrivalContinuation) = AsyncStream.makeStream() + } + + func register(_ descriptor: GitWorkspaceMetadataWatchDescriptor) async -> RecursivePathWatcher? { + let id = nextID + nextID += 1 + return await withCheckedContinuation { continuation in + pending[id] = continuation + arrivalContinuation.yield(id) + } + } + + func nextArrival() async -> Int? { + var iterator = arrivals.makeAsyncIterator() + return await iterator.next() + } + + func complete(_ id: Int, with watcher: RecursivePathWatcher?) { + pending.removeValue(forKey: id)?.resume(returning: watcher) + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/WatcherRegistrationLifecycleTests.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/WatcherRegistrationLifecycleTests.swift new file mode 100644 index 000000000000..2217d7232b18 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/WatcherRegistrationLifecycleTests.swift @@ -0,0 +1,205 @@ +import Foundation +import CmuxFoundation +import CmuxGit +import Testing +@testable import CmuxSidebarGit + +@MainActor +@Suite("Sidebar watcher registration", .timeLimit(.minutes(1))) +struct WatcherRegistrationLifecycleTests { + private func service( + host: RecordingSidebarGitHost, + reader: GatedWatchDescriptorReader, + gate: WatcherRegistrationGate + ) -> SidebarGitMetadataService { + let service = SidebarGitMetadataService( + workspaceGitMetadataReader: GatedMetadataReader(metadata: .repository(branch: "main")), + gitMetadataService: reader, + pullRequestProbing: RecordingPullRequestProbing(), + probeLimiter: WorkspaceGitMetadataProbeLimiter(limit: 1), + clock: ManualGitPollClock(), + makeWatcher: { await gate.register($0) } + ) + service.attach(host: host) + return service + } + + private func descriptor(_ root: String, identity: String = "index") -> GitWorkspaceMetadataWatchDescriptor { + GitWorkspaceMetadataWatchDescriptor( + repositoryRoot: root, + watchedPaths: [root], + gitMetadataPaths: [root + "/.git/index"], + trackedEntryPaths: [], + acceptsAllWorkTreeEvents: false, + eventCoalescingInterval: .milliseconds(250), + eventFilterIdentity: identity + ) + } + + @Test func staleFailureCannotRemoveNewerDirectoryRequest() async throws { + let host = RecordingSidebarGitHost() + let (workspace, panel) = host.addWorkspace(panelDirectory: "/old") + let key = WorkspaceGitProbeKey(workspaceId: workspace, panelId: panel) + let reader = GatedWatchDescriptorReader() + let gate = WatcherRegistrationGate() + let service = service(host: host, reader: reader, gate: gate) + defer { service.resetAllWorkspaceGitProbeTracking() } + service.workspaceGitTrackedDirectoryByKey[key] = "/old" + service.updateWorkspaceGitMetadataWatcher(for: key, directory: "/old") + let oldTask = try #require(service.workspaceGitMetadataWatcherTasksByKey[key]) + #expect(await reader.nextRequestedDirectory() == "/old") + await reader.resumeNext(with: descriptor("/old")) + let oldID = try #require(await gate.nextArrival()) + + service.workspaceGitTrackedDirectoryByKey[key] = "/new" + service.updateWorkspaceGitMetadataWatcher(for: key, directory: "/new") + let newTask = try #require(service.workspaceGitMetadataWatcherTasksByKey[key]) + #expect(await reader.nextRequestedDirectory() == "/new") + await reader.resumeNext(with: descriptor("/new")) + let newID = try #require(await gate.nextArrival()) + let newRequest = service.workspaceGitMetadataWatcherDescriptorRequestsByKey[key] + + await gate.complete(oldID, with: nil) + await oldTask.value + #expect(service.workspaceGitMetadataWatcherDescriptorRequestsByKey[key] == newRequest) + #expect(service.workspaceGitMetadataWatcherSourceDirectoryByKey[key] != "/old") + await gate.complete(newID, with: nil) + await newTask.value + #expect(service.workspaceGitMetadataWatcherSourceDirectoryByKey[key] == nil) + } + + @Test func closedWorkspaceCannotBeResurrectedByRegistrationFailure() async throws { + let host = RecordingSidebarGitHost() + let (workspace, panel) = host.addWorkspace(panelDirectory: "/closed") + let key = WorkspaceGitProbeKey(workspaceId: workspace, panelId: panel) + let reader = GatedWatchDescriptorReader() + let gate = WatcherRegistrationGate() + let service = service(host: host, reader: reader, gate: gate) + service.workspaceGitTrackedDirectoryByKey[key] = "/closed" + service.updateWorkspaceGitMetadataWatcher(for: key, directory: "/closed") + let task = try #require(service.workspaceGitMetadataWatcherTasksByKey[key]) + _ = await reader.nextRequestedDirectory() + await reader.resumeNext(with: descriptor("/closed")) + let registration = try #require(await gate.nextArrival()) + service.clearWorkspaceGitProbes(workspaceId: workspace) + await gate.complete(registration, with: nil) + await task.value + #expect(task.isCancelled) + #expect(service.workspaceGitMetadataWatcherSourceDirectoryByKey.isEmpty) + #expect(service.workspaceGitMetadataWatcherDescriptorRequestsByKey.isEmpty) + #expect(service.workspaceGitMetadataWatcherTasksByKey.isEmpty) + } + + @Test(arguments: [false, true]) + func failedForcedReplacementPreservesInstalledWatcherUntilLaterSuccess(descriptorFailure: Bool) async throws { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-sidebar-watcher-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directoryURL) } + let path = directoryURL.path + let host = RecordingSidebarGitHost() + let (workspace, panel) = host.addWorkspace(panelDirectory: path) + let key = WorkspaceGitProbeKey(workspaceId: workspace, panelId: panel) + let reader = GatedWatchDescriptorReader() + let gate = WatcherRegistrationGate() + let service = service(host: host, reader: reader, gate: gate) + defer { service.resetAllWorkspaceGitProbeTracking() } + service.workspaceGitTrackedDirectoryByKey[key] = path + + service.updateWorkspaceGitMetadataWatcher(for: key, directory: path) + let initialTask = try #require(service.workspaceGitMetadataWatcherTasksByKey[key]) + _ = await reader.nextRequestedDirectory() + await reader.resumeNext(with: descriptor(path, identity: "initial")) + let initialRegistration = try #require(await gate.nextArrival()) + let installed = try #require(await RecursivePathWatcher(paths: [path])) + defer { Task { await installed.stop() } } + await gate.complete(initialRegistration, with: installed) + await initialTask.value + let initialPathsKey = try #require( + service.workspaceGitMetadataWatcherWatchedPathsKeyByProbeKey[key] + ) + let initialConsumer = try #require( + service.workspaceGitMetadataWatcherRefreshTasksByWatchedPathsKey[initialPathsKey] + ) + + service.updateWorkspaceGitMetadataWatcher( + for: key, directory: path, forceDescriptorRefresh: true + ) + let failedTask = try #require(service.workspaceGitMetadataWatcherTasksByKey[key]) + _ = await reader.nextRequestedDirectory() + if descriptorFailure { + await reader.resumeNext(with: nil) + } else { + await reader.resumeNext(with: descriptor(path, identity: "replacement")) + let failedRegistration = try #require(await gate.nextArrival()) + await gate.complete(failedRegistration, with: nil) + } + await failedTask.value + + #expect(service.workspaceGitMetadataWatchersByWatchedPathsKey[initialPathsKey] === installed) + #expect(service.workspaceGitMetadataWatcherWatchedPathsKeyByProbeKey[key] == initialPathsKey) + #expect(service.workspaceGitMetadataWatcherSourceDirectoryByKey[key] == path) + #expect(!initialConsumer.isCancelled) + + service.updateWorkspaceGitMetadataWatcher( + for: key, directory: path, forceDescriptorRefresh: true + ) + let replacementTask = try #require(service.workspaceGitMetadataWatcherTasksByKey[key]) + _ = await reader.nextRequestedDirectory() + await reader.resumeNext(with: descriptor(path, identity: "replacement")) + let replacementRegistration = try #require(await gate.nextArrival()) + let replacement = try #require(await RecursivePathWatcher(paths: [path])) + defer { Task { await replacement.stop() } } + await gate.complete(replacementRegistration, with: replacement) + await replacementTask.value + + let replacementPathsKey = try #require( + service.workspaceGitMetadataWatcherWatchedPathsKeyByProbeKey[key] + ) + #expect(replacementPathsKey != initialPathsKey) + #expect(service.workspaceGitMetadataWatchersByWatchedPathsKey[replacementPathsKey] === replacement) + #expect(service.workspaceGitMetadataWatchersByWatchedPathsKey[initialPathsKey] == nil) + #expect(initialConsumer.isCancelled) + } + + @Test func simultaneousPanelsPreserveOneInstalledWatcherAndConsumer() async throws { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-sidebar-watcher-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directoryURL) } + let path = directoryURL.path + let host = RecordingSidebarGitHost() + let first = host.addWorkspace(panelDirectory: path) + let second = host.addWorkspace(panelDirectory: path) + let keys = [first, second].map { WorkspaceGitProbeKey(workspaceId: $0.0, panelId: $0.1) } + let reader = GatedWatchDescriptorReader() + let gate = WatcherRegistrationGate() + let service = service(host: host, reader: reader, gate: gate) + defer { service.resetAllWorkspaceGitProbeTracking() } + var registrations: [(id: Int, task: Task)] = [] + for key in keys { + service.workspaceGitTrackedDirectoryByKey[key] = path + service.updateWorkspaceGitMetadataWatcher(for: key, directory: path) + let task = try #require(service.workspaceGitMetadataWatcherTasksByKey[key]) + _ = await reader.nextRequestedDirectory() + await reader.resumeNext(with: descriptor(path)) + let id = try #require(await gate.nextArrival()) + registrations.append((id, task)) + } + let installed = try #require(await RecursivePathWatcher(paths: [path])) + let duplicate = try #require(await RecursivePathWatcher(paths: [path])) + defer { + Task { await installed.stop(); await duplicate.stop() } + } + await gate.complete(registrations[0].id, with: installed) + await registrations[0].task.value + let watchedKey = try #require(service.workspaceGitMetadataWatcherWatchedPathsKeyByProbeKey[keys[0]]) + let consumer = try #require(service.workspaceGitMetadataWatcherRefreshTasksByWatchedPathsKey[watchedKey]) + await gate.complete(registrations[1].id, with: duplicate) + await registrations[1].task.value + #expect(service.workspaceGitMetadataWatchersByWatchedPathsKey.count == 1) + #expect(service.workspaceGitMetadataWatchersByWatchedPathsKey[watchedKey] === installed) + #expect(service.workspaceGitMetadataWatcherProbeKeysByWatchedPathsKey[watchedKey] == Set(keys)) + #expect(!consumer.isCancelled) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e8f0da3d11c0..70f8a18b2869 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -60871,7 +60871,136 @@ } } }, - "browser.import.error.title": { + "browser.import.error.scopeInvalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scope is invalid." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "scope が無効です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "scope가 유효하지 않습니다." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Параметр scope недійсний." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "scope 无效。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "scope ist ungültig." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "scope n’est pas valide." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "scope غير صالح." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "scope no es válido." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "scope 無效。" + } + } + } + }, + "browser.import.error.scopeRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scope must be a non-empty string." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "scope は空でない文字列である必要があります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "scope는 비어 있지 않은 문자열이어야 합니다." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Параметр scope має бути непорожнім рядком." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "scope 必须是非空字符串。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "scope muss eine nicht leere Zeichenfolge sein." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "scope doit être une chaîne non vide." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يجب أن يكون scope سلسلة غير فارغة." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "scope debe ser una cadena no vacía." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "scope 必須是非空字串。" + } + } + } + }, "browser.import.error.title": { "extractionState": "manual", "localizations": { "en": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e9ba34ea7315..966087933f7d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -845,15 +845,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// Combine subscriptions that publish workspace.updated to mobile clients. private var mobileWorkspaceListObservers: [ObjectIdentifier: MobileWorkspaceListObserver] = [:] private let agentChatTranscriptService = AgentChatTranscriptService() - /// The app's settings dependency container, handed over by `cmuxApp` via - /// `configure(...)` before any main window is created. AppKit builds the - /// main window's `NSHostingView` itself, so it injects this into the - /// `ContentView` environment so `@LiveSetting` can resolve the stores it - /// observes inside the sidebar. var settingsRuntime: SettingsRuntime? /// Injected before the coordinator is used; the managed-policy extension /// re-applies `DisableComputerUse` through it. var computerUseRuntimeService: ComputerUseRuntimeService? + private(set) var browserDataImportCoordinator: BrowserDataImportCoordinator? weak var fileExplorerState: FileExplorerState? weak var fullscreenControlsViewModel: TitlebarControlsViewModel? weak var sidebarSelectionState: SidebarSelectionState? @@ -1920,7 +1916,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if env["CMUX_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - BrowserDataImportCoordinator.shared.presentImportDialog() + self.browserDataImportCoordinator?.presentImportDialog() } } } @@ -2438,6 +2434,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent cloudWorkspaceOperationController: CloudWorkspaceOperationController, newMachineSheetPresenter: any NewMachineSheetPresenting, automationEngine: AutomationEngine, + browserDataImportCoordinator: BrowserDataImportCoordinator, computerUseRuntimeService: ComputerUseRuntimeService, devicesRegistry: DeviceSurfaceProviderRegistry? = nil, computersService: HiveComputersService? = nil @@ -2464,6 +2461,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent operations: cloudWorkspaceOperationController, catalog: .shared ) self.newMachineSheetPresenter = newMachineSheetPresenter + self.browserDataImportCoordinator = browserDataImportCoordinator self.computerUseRuntimeService = computerUseRuntimeService let cloudUploader = CloudTelemetryUploader( auth: auth.coordinator, baseURL: CloudTelemetryUploader.telemetryBaseURL, client: .current() @@ -2564,6 +2562,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // entry). No-op on Release / when the flag is off. MacPairedMacBackupPublisher.shared.configure(auth: auth.coordinator) TerminalController.shared.attachAuth(coordinator: auth.coordinator, accountFlow: auth.accountFlow) + TerminalController.shared.attachBrowserDataImportCoordinator(browserDataImportCoordinator) TerminalController.shared.attachCaffeineController(caffeineController) TerminalController.shared.agentChatTranscriptService = agentChatTranscriptService TerminalController.shared.attachAutomationEngine(automationEngine) @@ -10167,6 +10166,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // optional, so a nil runtime just leaves reads at their seeded // catalog default. .environment(\.settingsRuntime, settingsRuntime) + .environment(browserDataImportCoordinator) .cmuxFontMagnificationEnvironment() // Use the current key window's size for new windows so Cmd+Shift+N diff --git a/Sources/Canvas/CanvasHostedPanelContentView.swift b/Sources/Canvas/CanvasHostedPanelContentView.swift index e1c5ba35d5af..0f3513412b04 100644 --- a/Sources/Canvas/CanvasHostedPanelContentView.swift +++ b/Sources/Canvas/CanvasHostedPanelContentView.swift @@ -19,6 +19,7 @@ struct CanvasHostedPanelContentView: View { let appearance: PanelAppearance let windowAppearance: WindowAppearanceSnapshot let settingsRuntime: SettingsRuntime? + let browserDataImportCoordinator: BrowserDataImportCoordinator? let customSidebarTabManager: TabManager? let onRequestPanelFocus: () -> Void let onRequestDeferredBrowserMaterialization: () -> Void @@ -48,6 +49,7 @@ struct CanvasHostedPanelContentView: View { onRequestDeferredBrowserMaterialization: onRequestDeferredBrowserMaterialization ) .environment(\.settingsRuntime, settingsRuntime) + .environment(browserDataImportCoordinator) .environment(\.workspaceAttentionColor, presentation.workspaceAttentionColor) // Window-portal content (webviews) floats above the pane's layer // border; this inset keeps the focus ring visible around it. diff --git a/Sources/Canvas/WorkspaceCanvasHostView.swift b/Sources/Canvas/WorkspaceCanvasHostView.swift index db094460905c..203cc02fb425 100644 --- a/Sources/Canvas/WorkspaceCanvasHostView.swift +++ b/Sources/Canvas/WorkspaceCanvasHostView.swift @@ -22,6 +22,7 @@ struct WorkspaceCanvasHostView: View { let appearance: PanelAppearance let windowAppearance: WindowAppearanceSnapshot @Environment(\.settingsRuntime) private var settingsRuntime + @Environment(BrowserDataImportCoordinator.self) private var browserDataImportCoordinator: BrowserDataImportCoordinator? @Environment(\.workspaceAttentionColor) private var workspaceAttentionColor @AppStorage(SessionContentWidthSettings.maxWidthKey) private var storedSessionContentMaximumWidth = SessionContentWidthSettings.noMaximumWidth @@ -71,6 +72,7 @@ struct WorkspaceCanvasHostView: View { appearance: appearance, windowAppearance: windowAppearance, settingsRuntime: settingsRuntime, + browserDataImportCoordinator: browserDataImportCoordinator, workspaceAttentionColor: workspaceAttentionColor, sessionContentWidthPresentation: sessionContentWidthPresentation ), @@ -130,6 +132,7 @@ struct WorkspaceCanvasHostView: View { appearance: PanelAppearance, windowAppearance: WindowAppearanceSnapshot, settingsRuntime: SettingsRuntime?, + browserDataImportCoordinator: BrowserDataImportCoordinator?, workspaceAttentionColor: WorkspaceAttentionColor, sessionContentWidthPresentation: SessionContentWidthPresentation ) -> CanvasPaneContent { @@ -154,6 +157,7 @@ struct WorkspaceCanvasHostView: View { appearance: appearance, windowAppearance: windowAppearance, settingsRuntime: settingsRuntime, + browserDataImportCoordinator: browserDataImportCoordinator, customSidebarTabManager: workspace?.owningTabManager, onRequestPanelFocus: { [weak workspace] in workspace?.focusPanel(panel.id) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 395a34d7ddff..25dd540c3f10 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11288,7 +11288,11 @@ struct VerticalTabsSidebar: View, Equatable { // boundary. @State private var bonsplitWorkspaceDropTargetBridge = SidebarBonsplitTabWorkspaceDropOverlay.TargetBridge() @State private var workspaceReorderDropTargetBridge = SidebarWorkspaceReorderDropOverlay.TargetBridge() - @State private var appKitRowSnapshotCache = SidebarRowSnapshotCache() + // Parent-owned immutable workspace projections. Workspace publishers and + // async observation streams terminate here, above the LazyVStack; rows + // receive only values and action closures. This is the ownership boundary + // that prevents layout/realization from publishing row state (#6707). + @State var workspaceSnapshotCache = SidebarRowSnapshotCache() /// Bumped once per interactive-resize end: an apply during the drag /// is deferred by the AppKit controller. The bump projects one final /// authoritative snapshot after mouse-up so state that changed mid-drag @@ -11307,11 +11311,6 @@ struct VerticalTabsSidebar: View, Equatable { // publisher bursts cross into SwiftUI once per run-loop batch instead of // invalidating the full parent projection once per emitting workspace. @State private var workspaceSnapshotRefreshCoalescer = SidebarWorkspaceSnapshotRefreshCoalescer() - // Parent-owned immutable workspace projections. Workspace publishers and - // async observation streams terminate here, above the LazyVStack; rows - // receive only values and action closures. This is the ownership boundary - // that prevents layout/realization from publishing row state (#6707). - @State private var workspaceSnapshotsById: [UUID: SidebarWorkspaceSnapshotBuilder.Snapshot] = [:] @State private var extensionSidebarUpdateToken: UInt64 = 0 // Stable, memoized merged observation publishers for the extension // sidebar's `.onReceive` handlers. Rebuilding them inline each body pass @@ -11685,8 +11684,7 @@ struct VerticalTabsSidebar: View, Equatable { } private func deactivateSidebarInteractions() { - appKitRowSnapshotCache.prune(keeping: []) - if !workspaceSnapshotsById.isEmpty { workspaceSnapshotsById = [:] } + workspaceSnapshotCache.prune(keeping: []) if pointerInteractionMonitor.isActive { pointerInteractionMonitor.stop() } @@ -11926,11 +11924,8 @@ struct VerticalTabsSidebar: View, Equatable { } } // Workspace publisher observations and the snapshot refresh feed BOTH - // list implementations, so they live on the shared parent. They - // previously hung off the legacy subtree only, which the AppKit flag - // unmounts — leaving workspaceSnapshotsById permanently empty, so - // renames, colors, pins, and descriptions never invalidated the - // sidebar and only painted when an unrelated change rebuilt the rows. + // list implementations, so they live on the shared parent. Both use one snapshot cache; material updates publish a generation, + // never a second retained copy of every workspace snapshot. .sidebarProcessTitleObservations( ids: renderContext.workspaceIds, models: renderContext.tabs.map(\.sidebarProcessTitleObservation) @@ -11948,36 +11943,35 @@ struct VerticalTabsSidebar: View, Equatable { .sidebarWorkspaceObservations( ids: renderContext.workspaceIds, workspaces: renderContext.tabs, - debouncedInterval: Self.extensionSidebarObservationCoalesceInterval, - deliverInitialValue: !featureFlags.isAppKitSidebarListEnabled + debouncedInterval: Self.extensionSidebarObservationCoalesceInterval ) { workspaceId in guard isPresented else { return } scheduleWorkspaceSnapshotRefresh(workspaceId: workspaceId) } .onAppear { - if isPresented, !featureFlags.isAppKitSidebarListEnabled { + if isPresented { refreshWorkspaceSnapshots() } } .onChange(of: isPresented) { _, presented in if !presented { workspaceSnapshotRefreshCoalescer.cancel() - } else if !featureFlags.isAppKitSidebarListEnabled { + } else { refreshWorkspaceSnapshots() } } .onChange(of: renderContext.workspaceIds) { _, _ in - if isPresented, !featureFlags.isAppKitSidebarListEnabled { + if isPresented { refreshWorkspaceSnapshots() } } .onChange(of: renderContext.tabItemSettings) { _, _ in - if isPresented, !featureFlags.isAppKitSidebarListEnabled { + if isPresented { refreshWorkspaceSnapshots() } } .onChange(of: renderContext.showsAgentActivity) { _, _ in - if isPresented, !featureFlags.isAppKitSidebarListEnabled { + if isPresented { refreshWorkspaceSnapshots() } } @@ -12197,7 +12191,6 @@ struct VerticalTabsSidebar: View, Equatable { rows: appKitWorkspaceTableRows(renderContext: renderContext), actions: workspaceTableActions(renderContext: renderContext) ) - appKitRowSnapshotCache.prune(keeping: Set(renderContext.workspaceIds)) } let selectedWorkspaceId = isPresented ? tabManager.selectedTabId : nil let selectedScrollTargetWorkspaceId: UUID? = selectedWorkspaceId.map { selectedId in @@ -12312,7 +12305,6 @@ struct VerticalTabsSidebar: View, Equatable { private func appKitWorkspaceTableRows( renderContext: WorkspaceListRenderContext ) -> [SidebarWorkspaceTableRowConfiguration] { - appKitRowSnapshotCache.resetIfSettingsChanged(renderContext.tabItemSettings) #if DEBUG // One line per full row-projection rebuild: the countable signal for // whether a change class re-renders the sidebar subtree or skips it. @@ -12325,15 +12317,13 @@ struct VerticalTabsSidebar: View, Equatable { let notificationIndex = SidebarWorkspaceNotificationIndex( notifications: notificationStore.notifications ) - let workspaceRowInputsById = Dictionary(uniqueKeysWithValues: renderContext.tabs.map { workspace in - ( - workspace.id, - workspaceRowInput( - workspace, - renderContext: renderContext, - unreadSummariesByWorkspaceId: unreadSummariesByWorkspaceId - ) - ) + let workspaceRowInputsById = Dictionary(uniqueKeysWithValues: renderContext.tabs.compactMap { workspace -> (UUID, SidebarWorkspaceRowInput)? in + guard let input = workspaceRowInput( + workspace, + renderContext: renderContext, + unreadSummariesByWorkspaceId: unreadSummariesByWorkspaceId + ) else { return nil } + return (workspace.id, input) }) let groupRowSnapshotsById = Dictionary(uniqueKeysWithValues: renderContext.workspaceGroups.map { group in ( @@ -12979,53 +12969,33 @@ struct VerticalTabsSidebar: View, Equatable { let settings = tabItemSettingsStore.snapshot let showsAgentActivity = settings.details.showAgentActivity && CmuxFeatureFlags.shared.isSidebarWorkspaceAgentSpinnerEnabled - var next = workspaceSnapshotsById - var changed = false - for workspaceId in workspaceIds { - guard let workspace = workspaceById[workspaceId] else { - changed = next.removeValue(forKey: workspaceId) != nil || changed - continue - } - let snapshot = makeWorkspaceSnapshot( - workspace: workspace, - settings: settings, - showsAgentActivity: showsAgentActivity + workspaceSnapshotCache.refresh(workspaceIds: workspaceIds) { workspaceId in + guard let workspace = workspaceById[workspaceId] else { return nil } + return makeWorkspaceSnapshot( + workspace: workspace, settings: settings, showsAgentActivity: showsAgentActivity ) - if featureFlags.isAppKitSidebarListEnabled { - guard appKitRowSnapshotCache.value(for: workspaceId) != snapshot else { - continue - } - appKitRowSnapshotCache.store(snapshot, for: workspaceId) - } - guard next[workspaceId] != snapshot else { continue } - next[workspaceId] = snapshot - changed = true } - guard changed else { return } -#if DEBUG - cmuxDebugLog("sidebar.snapshot.refresh requested=\(workspaceIds.count)") -#endif - workspaceSnapshotsById = next } private func refreshWorkspaceSnapshots() { - workspaceSnapshotRefreshCoalescer.cancel() let tabs = tabManager.tabs - let liveIds = Set(tabs.map(\.id)) + let workspaceById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) let settings = tabItemSettingsStore.snapshot let showsAgentActivity = settings.details.showAgentActivity && CmuxFeatureFlags.shared.isSidebarWorkspaceAgentSpinnerEnabled - var next: [UUID: SidebarWorkspaceSnapshotBuilder.Snapshot] = [:] - next.reserveCapacity(tabs.count) - for workspace in tabs { - next[workspace.id] = makeWorkspaceSnapshot( + workspaceSnapshotCache.reconcile( + workspaceIds: Set(workspaceById.keys), + presentationKey: SidebarWorkspaceSnapshotFactory.presentationKey( + settings: settings, showsAgentActivity: showsAgentActivity + ) + ) { workspaceId in + guard let workspace = workspaceById[workspaceId] else { return nil } + return makeWorkspaceSnapshot( workspace: workspace, settings: settings, showsAgentActivity: showsAgentActivity ) } - guard next != workspaceSnapshotsById || Set(workspaceSnapshotsById.keys) != liveIds else { return } - workspaceSnapshotsById = next } private func makeWorkspaceSnapshot( @@ -14076,15 +14046,13 @@ struct VerticalTabsSidebar: View, Equatable { let notificationIndex = SidebarWorkspaceNotificationIndex( notifications: notificationStore.notifications ) - let workspaceRowInputsById = Dictionary(uniqueKeysWithValues: renderContext.tabs.map { workspace in - ( - workspace.id, - workspaceRowInput( - workspace, - renderContext: renderContext, - unreadSummariesByWorkspaceId: unreadSummariesByWorkspaceId - ) - ) + let workspaceRowInputsById = Dictionary(uniqueKeysWithValues: renderContext.tabs.compactMap { workspace -> (UUID, SidebarWorkspaceRowInput)? in + guard let input = workspaceRowInput( + workspace, + renderContext: renderContext, + unreadSummariesByWorkspaceId: unreadSummariesByWorkspaceId + ) else { return nil } + return (workspace.id, input) }) let _ = anchorCwdRevision let groupRowSnapshotsById = Dictionary(uniqueKeysWithValues: renderContext.workspaceGroups.map { group in @@ -14900,7 +14868,10 @@ struct VerticalTabsSidebar: View, Equatable { _ tab: Workspace, renderContext: WorkspaceListRenderContext, unreadSummariesByWorkspaceId: [UUID: SidebarWorkspaceUnreadSummary] - ) -> SidebarWorkspaceRowInput { + ) -> SidebarWorkspaceRowInput? { + // Lifecycle/event handlers build snapshots. A cache miss must never + // subscribe this render pass to live pane bookkeeping through the factory. + guard let workspaceSnapshot = workspaceSnapshotCache.value(for: tab.id) else { return nil } #if DEBUG sidebarLazyContractProbe.workspaceRowInputProjection?() #endif @@ -14954,28 +14925,6 @@ struct VerticalTabsSidebar: View, Equatable { indicatorScope: dragState.dropIndicatorScope ) let settings = renderContext.tabItemSettings - let expectedPresentationKey = SidebarWorkspaceSnapshotFactory.presentationKey( - settings: settings, - showsAgentActivity: renderContext.showsAgentActivity - ) - let cachedWorkspaceSnapshot = featureFlags.isAppKitSidebarListEnabled - ? appKitRowSnapshotCache.value(for: tab.id) - : workspaceSnapshotsById[tab.id] - let workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot - if let cachedWorkspaceSnapshot, - cachedWorkspaceSnapshot.presentationKey == expectedPresentationKey { - workspaceSnapshot = cachedWorkspaceSnapshot - } else { - workspaceSnapshot = makeWorkspaceSnapshot( - workspace: tab, - settings: settings, - showsAgentActivity: renderContext.showsAgentActivity - ) - if featureFlags.isAppKitSidebarListEnabled { - appKitRowSnapshotCache.store(workspaceSnapshot, for: tab.id) - } - } - let result = SidebarWorkspaceRowInput( workspaceId: tab.id, groupId: renderContext.workspaceGroupIdByWorkspaceId[tab.id] ?? nil, @@ -15487,6 +15436,7 @@ private struct SidebarHelpMenuButton: View { private var debugIconWeight = SidebarFooterHelpIconDebugSettings.defaultWeight.rawValue #endif @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + @Environment(BrowserDataImportCoordinator.self) private var browserDataImportCoordinator: BrowserDataImportCoordinator? let onSendFeedback: () -> Void @@ -15698,7 +15648,7 @@ private struct SidebarHelpMenuButton: View { case .importBrowserData: isPopoverPresented = false DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() + browserDataImportCoordinator?.presentImportDialog() } case .keyboardShortcuts: isPopoverPresented = false diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index 4d25d340c467..d535426e5ed7 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -13,15 +13,12 @@ import SwiftUI nonisolated private let hostSettingsLogger = Logger(subsystem: "com.cmuxterm.app", category: "Settings") -/// App-side implementation of the package's `SettingsHostActions` -/// protocol. Routes UI-triggered actions to the existing host -/// services (`BrowserHistoryStore`, `BrowserDataImportCoordinator`, -/// `TerminalNotificationStore`, etc.) so the package doesn't need to -/// depend on them directly. +/// Routes Settings actions to app-owned services, keeping the package independent. @MainActor final class HostSettingsActions: SettingsHostActions { let computersActions: ComputersSettingsActions private let configFileURL: URL + private let browserDataImportCoordinator: BrowserDataImportCoordinator private let automationConfigStore: AutomationConfigStore private let openAutomationRulesFile: @MainActor (URL) -> Void private let reportAutomationRulesError: @MainActor (Error) -> Void @@ -61,6 +58,7 @@ final class HostSettingsActions: SettingsHostActions { init( configFileURL: URL, computerUseRuntimeService: ComputerUseRuntimeService, + browserDataImportCoordinator: BrowserDataImportCoordinator, automationConfigStore: AutomationConfigStore = AutomationConfigStore(), openAutomationRulesFile: @escaping @MainActor (URL) -> Void = { PreferredEditorService(defaults: .standard).open($0) @@ -87,6 +85,7 @@ final class HostSettingsActions: SettingsHostActions { self.openAutomationRulesFile = openAutomationRulesFile self.reportAutomationRulesError = reportAutomationRulesError self.computerUseRuntimeService = computerUseRuntimeService + self.browserDataImportCoordinator = browserDataImportCoordinator self.runComputerUseOnboardingAction = runComputerUseOnboardingAction startObservingAppIconMode() } @@ -481,7 +480,7 @@ final class HostSettingsActions: SettingsHostActions { } func openBrowserImportFlow() { - BrowserDataImportCoordinator.shared.presentImportDialog() + browserDataImportCoordinator.presentImportDialog() } func requestNotificationAuthorization() { diff --git a/Sources/Panels/BrowserAutomation.swift b/Sources/Panels/BrowserAutomation.swift index 9b2b2bb34eb3..fff77fe52a65 100644 --- a/Sources/Panels/BrowserAutomation.swift +++ b/Sources/Panels/BrowserAutomation.swift @@ -198,23 +198,6 @@ enum BrowserProfileAutomationError: LocalizedError, CustomStringConvertible { } } -private func browserAutomationBoolParam(_ params: [String: Any], keys: [String]) -> Bool { - for key in keys { - if let value = params[key] as? Bool { - return value - } - if let value = params[key] as? String { - switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "1", "true", "yes", "on": - return true - default: - continue - } - } - } - return false -} - enum BrowserProfileAutomation { static func list(params _: [String: Any]) async throws -> [String: Any] { await MainActor.run { @@ -263,7 +246,7 @@ enum BrowserProfileAutomation { @MainActor static func clear(params: [String: Any]) async throws -> [String: Any] { let targets = try targetProfiles(params: params, allowAll: true) - let force = browserAutomationBoolParam(params, keys: ["force"]) + let force = BrowserAutomationParameters(values: params).bool(keys: ["force"]) if !force { for profile in targets { let livePanelCount = liveBrowserPanelCount(profileID: profile.id) @@ -328,7 +311,7 @@ enum BrowserProfileAutomation { @MainActor private static func targetProfiles(params: [String: Any], allowAll: Bool) throws -> [BrowserProfileDefinition] { let store = BrowserProfileStore.shared - if allowAll, browserAutomationBoolParam(params, keys: ["all", "all_profiles"]) { + if allowAll, BrowserAutomationParameters(values: params).bool(keys: ["all", "all_profiles"]) { return store.profiles } let query = try requiredString(params, keys: ["profile", "id", "name"]) @@ -407,187 +390,3 @@ enum BrowserProfileAutomation { return workspaceCount + dockCount } } - -enum BrowserImportAutomation { - static func importCookies(params: [String: Any]) async throws -> BrowserImportOutcome { - let browsers = BrowserInstalledBrowserDetector().detectInstalledBrowsers() - guard !browsers.isEmpty else { - throw BrowserImportAutomationError.noBrowsers - } - - let browser = try selectedBrowser(from: browsers, params: params) - let sourceProfiles = try selectedSourceProfiles(from: browser, params: params) - let domainFilters = BrowserDataImporter.parseDomainFilters(domainFilterText(from: params)) - - let realizedPlan: RealizedBrowserImportExecutionPlan = try await MainActor.run { - let destinationProfiles = BrowserProfileStore.shared.profiles - let preferredDestinationProfileID = try resolvedDestinationProfileID( - params: params, - destinationProfiles: destinationProfiles - ) - - let plan: BrowserImportExecutionPlan - if let preferredDestinationProfileID { - let mode: BrowserImportDestinationMode = sourceProfiles.count > 1 ? .mergeIntoOne : .singleDestination - plan = BrowserImportExecutionPlan( - mode: mode, - entries: [ - BrowserImportExecutionEntry( - sourceProfiles: sourceProfiles, - destination: .existing(preferredDestinationProfileID) - ) - ] - ) - } else { - plan = BrowserImportPlanResolver.defaultPlan( - selectedSourceProfiles: sourceProfiles, - destinationProfiles: destinationProfiles, - preferredSingleDestinationProfileID: BrowserProfileStore.shared.effectiveLastUsedProfileID - ) - } - - return try BrowserImportPlanResolver.realize(plan: plan) - } - - return await BrowserDataImporter.importData( - from: browser, - plan: realizedPlan, - scope: .cookiesOnly, - domainFilters: domainFilters - ) - } - - private static func selectedBrowser( - from browsers: [InstalledBrowserCandidate], - params: [String: Any] - ) throws -> InstalledBrowserCandidate { - guard let query = stringParam(params, keys: ["browser", "from", "source"]) else { - let sortedBrowsers = browsers.sorted { lhs, rhs in - if lhs.detectionScore != rhs.detectionScore { - return lhs.detectionScore > rhs.detectionScore - } - return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending - } - guard let browser = sortedBrowsers.first else { - throw BrowserImportAutomationError.noBrowsers - } - return browser - } - - guard let browser = browsers.first(where: { matchesBrowser($0, query: query) }) else { - throw BrowserImportAutomationError.browserNotFound(query) - } - return browser - } - - private static func selectedSourceProfiles( - from browser: InstalledBrowserCandidate, - params: [String: Any] - ) throws -> [InstalledBrowserProfile] { - guard !browser.profiles.isEmpty else { - throw BrowserImportAutomationError.noProfiles(browser.displayName) - } - - if browserAutomationBoolParam(params, keys: ["all_profiles", "all_source_profiles"]) { - return browser.profiles - } - - let queries = stringListParam(params, keys: ["profile", "source_profile", "source_profiles"]) - guard !queries.isEmpty else { - if let defaultProfile = browser.profiles.first(where: \.isDefault) { - return [defaultProfile] - } - return [browser.profiles[0]] - } - - var result: [InstalledBrowserProfile] = [] - var seen = Set() - for query in queries { - guard let profile = browser.profiles.first(where: { matchesProfile($0, query: query) }) else { - throw BrowserImportAutomationError.sourceProfileNotFound(query) - } - guard seen.insert(profile.id).inserted else { continue } - result.append(profile) - } - return result - } - - @MainActor - private static func resolvedDestinationProfileID( - params: [String: Any], - destinationProfiles: [BrowserProfileDefinition] - ) throws -> UUID? { - guard let query = stringParam(params, keys: ["destination_profile", "to_profile", "to"]) else { - return nil - } - - if let uuid = UUID(uuidString: query), - destinationProfiles.contains(where: { $0.id == uuid }) { - return uuid - } - - if let profile = destinationProfiles.first(where: { - $0.displayName.localizedCaseInsensitiveCompare(query) == .orderedSame || - $0.slug.localizedCaseInsensitiveCompare(query) == .orderedSame - }) { - return profile.id - } - - guard browserAutomationBoolParam(params, keys: ["create_destination_profile", "create_profile"]) else { - throw BrowserImportAutomationError.destinationProfileNotFound(query) - } - - guard let profile = BrowserProfileStore.shared.createProfile(named: query) else { - throw BrowserImportAutomationError.destinationProfileCreationFailed(query) - } - return profile.id - } - - private static func matchesBrowser(_ browser: InstalledBrowserCandidate, query: String) -> Bool { - browser.matchesLookupQuery(query) - } - - private static func matchesProfile(_ profile: InstalledBrowserProfile, query: String) -> Bool { - let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !normalized.isEmpty else { return false } - if profile.id.lowercased() == normalized { return true } - if profile.displayName.lowercased() == normalized { return true } - if profile.rootURL.lastPathComponent.lowercased() == normalized { return true } - return false - } - - private static func domainFilterText(from params: [String: Any]) -> String { - stringListParam(params, keys: ["domain", "domains", "domain_filters"]) - .joined(separator: ",") - } - - private static func stringParam(_ params: [String: Any], keys: [String]) -> String? { - for key in keys { - if let value = params[key] as? String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - } - return nil - } - - private static func stringListParam(_ params: [String: Any], keys: [String]) -> [String] { - var result: [String] = [] - for key in keys { - if let value = params[key] as? String { - let parsed = value - .components(separatedBy: CharacterSet(charactersIn: ",;\n\r\t")) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - result.append(contentsOf: parsed) - } else if let values = params[key] as? [String] { - result.append( - contentsOf: values - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - ) - } - } - return result - } -} diff --git a/Sources/Panels/BrowserAutomationParameters.swift b/Sources/Panels/BrowserAutomationParameters.swift new file mode 100644 index 000000000000..3c5f573f2c3c --- /dev/null +++ b/Sources/Panels/BrowserAutomationParameters.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Decodes the existing boolean aliases accepted by browser automation commands. +struct BrowserAutomationParameters { + let values: [String: Any] + + func bool(keys: [String]) -> Bool { + for key in keys { + if let value = values[key] as? Bool { + return value + } + if let value = values[key] as? String { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "on": + return true + default: + continue + } + } + } + return false + } +} diff --git a/Sources/Panels/BrowserDataImportCoordinator.swift b/Sources/Panels/BrowserDataImportCoordinator.swift new file mode 100644 index 000000000000..b2dd6be1cc0f --- /dev/null +++ b/Sources/Panels/BrowserDataImportCoordinator.swift @@ -0,0 +1,216 @@ +import AppKit +import CmuxBrowser +import Foundation +import Observation + +/// Owns admission, asynchronous discovery, and presentation for browser imports. +@MainActor +@Observable +final class BrowserDataImportCoordinator { + private let browserDetection: BrowserInstalledBrowserDetectionService + @ObservationIgnored private var presentationTask: Task? + + var isPresentationAvailable: Bool { presentationTask == nil } + + init(browserDetection: BrowserInstalledBrowserDetectionService = .init()) { + self.browserDetection = browserDetection + } + + deinit { presentationTask?.cancel() } + + @discardableResult + func presentImportDialog( + defaultDestinationProfileID: UUID? = nil, + defaultScope: BrowserImportScope? = nil + ) -> Bool { + guard presentationTask == nil else { return false } + presentationTask = Task { @MainActor [weak self] in + await self?.presentImportDialogAsync( + defaultDestinationProfileID: defaultDestinationProfileID, + defaultScope: defaultScope + ) + } + return true + } + + func detectInstalledBrowsers() async -> [InstalledBrowserCandidate] { + await browserDetection.detectInstalledBrowsers() + } + + struct ImportSelection { + let browser: InstalledBrowserCandidate + let executionPlan: BrowserImportExecutionPlan + let scope: BrowserImportScope + let domainFilters: [String] + } + + private func presentImportDialogAsync( + defaultDestinationProfileID: UUID?, + defaultScope: BrowserImportScope? + ) async { + defer { + presentationTask = nil + } +#if DEBUG + let environment = ProcessInfo.processInfo.environment + let fixtureBrowsers = BrowserImportUITestFixtureLoader.browsers(from: environment) + let fixtureDestinationProfiles = BrowserImportUITestFixtureLoader.destinationProfiles(from: environment) +#else + let fixtureBrowsers: [InstalledBrowserCandidate]? = nil + let fixtureDestinationProfiles: [BrowserProfileDefinition]? = nil +#endif + let browsers: [InstalledBrowserCandidate] + if let fixtureBrowsers { + browsers = fixtureBrowsers + } else { + browsers = await browserDetection.detectInstalledBrowsers() + } + guard !Task.isCancelled else { return } + guard !browsers.isEmpty else { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + localized: "browser.import.noBrowsers.title", + defaultValue: "No importable browsers found" + ) + alert.informativeText = String( + localized: "browser.import.noBrowsers.message", + defaultValue: "cmux could not find browser profiles to import from on this Mac." + ) + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + return + } + + guard let selection = promptForSelection( + browsers: browsers, + destinationProfiles: fixtureDestinationProfiles, + defaultDestinationProfileID: defaultDestinationProfileID, + defaultScope: defaultScope + ) else { + return + } + +#if DEBUG + if captureSelectionIfRequested(selection, destinationProfiles: fixtureDestinationProfiles) { + return + } +#endif + let realizedPlan: RealizedBrowserImportExecutionPlan + do { + realizedPlan = try BrowserImportPlanResolver.realize(plan: selection.executionPlan) + } catch { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String( + localized: "browser.import.error.title", + defaultValue: "Import could not start" + ) + alert.informativeText = error.localizedDescription + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.runModal() + return + } + + let progressWindow = showProgressWindow( + title: String( + localized: "browser.import.progress.title", + defaultValue: "Importing Browser Data" + ), + message: String( + format: String( + localized: "browser.import.progress.message", + defaultValue: "Importing %@ from %@…" + ), + selection.scope.displayName.lowercased(), + selection.browser.displayName + ) + ) + + let outcome = await Task.detached(priority: .userInitiated) { + await BrowserDataImporter.importData( + from: selection.browser, + plan: realizedPlan, + scope: selection.scope, + domainFilters: selection.domainFilters + ) + }.value + hideProgressWindow(progressWindow) + presentOutcome(outcome) + } + +#if DEBUG + private struct CapturedImportSelection: Encodable { + struct Entry: Encodable { + let sourceProfiles: [String] + let destinationKind: String + let destinationName: String + } + + let browserName: String + let mode: String + let scope: String + let domainFilters: [String] + let entries: [Entry] + } + + private func captureSelectionIfRequested( + _ selection: ImportSelection, + destinationProfiles: [BrowserProfileDefinition]? + ) -> Bool { + let environment = ProcessInfo.processInfo.environment + guard environment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] == "capture-only" else { return false } + guard let path = environment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"], !path.isEmpty else { + return true + } + + let availableDestinationProfiles = destinationProfiles ?? BrowserProfileStore.shared.profiles + let payload = CapturedImportSelection( + browserName: selection.browser.displayName, + mode: captureModeName(selection.executionPlan.mode), + scope: selection.scope.rawValue, + domainFilters: selection.domainFilters, + entries: selection.executionPlan.entries.map { entry in + let destinationKind: String + let destinationName: String + switch entry.destination { + case .existing(let id): + destinationKind = "existing" + destinationName = availableDestinationProfiles.first(where: { $0.id == id })?.displayName + ?? BrowserProfileStore.shared.displayName(for: id) + case .createNamed(let name): + destinationKind = "create" + destinationName = name + } + return CapturedImportSelection.Entry( + sourceProfiles: entry.sourceProfiles.map(\.displayName), + destinationKind: destinationKind, + destinationName: destinationName + ) + } + ) + + guard let data = try? JSONEncoder().encode(payload) else { return true } + let url = URL(fileURLWithPath: path) + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + try? data.write(to: url) + return true + } + + private func captureModeName(_ mode: BrowserImportDestinationMode) -> String { + switch mode { + case .singleDestination: + return "singleDestination" + case .separateProfiles: + return "separateProfiles" + case .mergeIntoOne: + return "mergeIntoOne" + } + } +#endif + +} diff --git a/Sources/Panels/BrowserImportAutomation.swift b/Sources/Panels/BrowserImportAutomation.swift new file mode 100644 index 000000000000..0d591e04ca84 --- /dev/null +++ b/Sources/Panels/BrowserImportAutomation.swift @@ -0,0 +1,158 @@ +import Foundation +import CmuxBrowser + +enum BrowserImportAutomation { + static func importCookies( + params: [String: Any], + coordinator: BrowserDataImportCoordinator + ) async throws -> BrowserImportOutcome { + let browsers = await coordinator.detectInstalledBrowsers() + guard !browsers.isEmpty else { + throw BrowserImportAutomationError.noBrowsers + } + + let browser = try selectedBrowser(from: browsers, params: params) + let sourceProfiles = try selectedSourceProfiles(from: browser, params: params) + let domainFilters = BrowserDataImporter.parseDomainFilters(domainFilterText(from: params)) + + let realizedPlan: RealizedBrowserImportExecutionPlan = try await MainActor.run { + let destinationProfiles = BrowserProfileStore.shared.profiles + let preferredDestinationProfileID = try BrowserImportDestinationResolver().resolve( + params: params, + destinationProfiles: destinationProfiles + ) + + let plan: BrowserImportExecutionPlan + if let preferredDestinationProfileID { + let mode: BrowserImportDestinationMode = sourceProfiles.count > 1 ? .mergeIntoOne : .singleDestination + plan = BrowserImportExecutionPlan( + mode: mode, + entries: [ + BrowserImportExecutionEntry( + sourceProfiles: sourceProfiles, + destination: .existing(preferredDestinationProfileID) + ) + ] + ) + } else { + plan = BrowserImportPlanResolver.defaultPlan( + selectedSourceProfiles: sourceProfiles, + destinationProfiles: destinationProfiles, + preferredSingleDestinationProfileID: BrowserProfileStore.shared.effectiveLastUsedProfileID + ) + } + + return try BrowserImportPlanResolver.realize(plan: plan) + } + + return await BrowserDataImporter.importData( + from: browser, + plan: realizedPlan, + scope: .cookiesOnly, + domainFilters: domainFilters + ) + } + + private static func selectedBrowser( + from browsers: [InstalledBrowserCandidate], + params: [String: Any] + ) throws -> InstalledBrowserCandidate { + guard let query = stringParam(params, keys: ["browser", "from", "source"]) else { + let sortedBrowsers = browsers.sorted { lhs, rhs in + if lhs.detectionScore != rhs.detectionScore { + return lhs.detectionScore > rhs.detectionScore + } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + guard let browser = sortedBrowsers.first else { + throw BrowserImportAutomationError.noBrowsers + } + return browser + } + + guard let browser = browsers.first(where: { matchesBrowser($0, query: query) }) else { + throw BrowserImportAutomationError.browserNotFound(query) + } + return browser + } + + private static func selectedSourceProfiles( + from browser: InstalledBrowserCandidate, + params: [String: Any] + ) throws -> [InstalledBrowserProfile] { + guard !browser.profiles.isEmpty else { + throw BrowserImportAutomationError.noProfiles(browser.displayName) + } + + if BrowserAutomationParameters(values: params).bool(keys: ["all_profiles", "all_source_profiles"]) { + return browser.profiles + } + + let queries = stringListParam(params, keys: ["profile", "source_profile", "source_profiles"]) + guard !queries.isEmpty else { + if let defaultProfile = browser.profiles.first(where: \.isDefault) { + return [defaultProfile] + } + return [browser.profiles[0]] + } + + var result: [InstalledBrowserProfile] = [] + var seen = Set() + for query in queries { + guard let profile = browser.profiles.first(where: { matchesProfile($0, query: query) }) else { + throw BrowserImportAutomationError.sourceProfileNotFound(query) + } + guard seen.insert(profile.id).inserted else { continue } + result.append(profile) + } + return result + } + + private static func matchesBrowser(_ browser: InstalledBrowserCandidate, query: String) -> Bool { + browser.matchesLookupQuery(query) + } + + private static func matchesProfile(_ profile: InstalledBrowserProfile, query: String) -> Bool { + let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { return false } + if profile.id.lowercased() == normalized { return true } + if profile.displayName.lowercased() == normalized { return true } + if profile.rootURL.lastPathComponent.lowercased() == normalized { return true } + return false + } + + private static func domainFilterText(from params: [String: Any]) -> String { + stringListParam(params, keys: ["domain", "domains", "domain_filters"]) + .joined(separator: ",") + } + + private static func stringParam(_ params: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = params[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + } + return nil + } + + private static func stringListParam(_ params: [String: Any], keys: [String]) -> [String] { + var result: [String] = [] + for key in keys { + if let value = params[key] as? String { + let parsed = value + .components(separatedBy: CharacterSet(charactersIn: ",;\n\r\t")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + result.append(contentsOf: parsed) + } else if let values = params[key] as? [String] { + result.append( + contentsOf: values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + ) + } + } + return result + } +} diff --git a/Sources/Panels/BrowserImportDestinationResolver.swift b/Sources/Panels/BrowserImportDestinationResolver.swift new file mode 100644 index 000000000000..2fcc09107d7d --- /dev/null +++ b/Sources/Panels/BrowserImportDestinationResolver.swift @@ -0,0 +1,40 @@ +import Foundation +import CmuxBrowser + +@MainActor +struct BrowserImportDestinationResolver { + private let packageResolver = CmuxBrowser.BrowserImportDestinationResolver() + + func resolve( + params: [String: Any], + destinationProfiles: [BrowserProfileDefinition] + ) throws -> UUID? { + let selector = ["destination_profile", "to_profile", "to"].lazy.compactMap { key in + (params[key] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + }.first { !$0.isEmpty } + let identifier = (params["destination_profile_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolution = packageResolver.resolve( + rawSelector: selector, + rawIdentifier: identifier, + createIfMissing: BrowserAutomationParameters(values: params).bool( + keys: ["create_destination_profile", "create_profile"] + ), + profiles: destinationProfiles + ) + switch resolution { + case .none: + return nil + case .matched(let id): + return id + case .ambiguous(let profiles): + throw BrowserProfileAutomationError.ambiguousProfile(selector ?? identifier ?? "", profiles) + case .notFound(let value), .invalidIdentifier(let value): + throw BrowserImportAutomationError.destinationProfileNotFound(value) + case .create(let name): + guard let profile = BrowserProfileStore.shared.createProfile(named: name) else { + throw BrowserImportAutomationError.destinationProfileCreationFailed(name) + } + return profile.id + } + } +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 05dcb5cf66d8..e1a8db610e69 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -10285,129 +10285,8 @@ enum BrowserImportUITestFixtureLoader { } #endif -@MainActor -final class BrowserDataImportCoordinator { - static let shared = BrowserDataImportCoordinator() - - private var importInProgress = false - - /// Held detector instance; the coordinator detects and summarizes installed - /// browsers through this rather than the former `BrowserInstalledBrowserDetector` - /// static namespace. - private let installedBrowserDetector = BrowserInstalledBrowserDetector() - - private init() {} - - func presentImportDialog( - defaultDestinationProfileID: UUID? = nil, - defaultScope: BrowserImportScope? = nil - ) { - presentImportDialog( - prefilledBrowsers: nil, - defaultDestinationProfileID: defaultDestinationProfileID, - defaultScope: defaultScope - ) - } - - private struct ImportSelection { - let browser: InstalledBrowserCandidate - let executionPlan: BrowserImportExecutionPlan - let scope: BrowserImportScope - let domainFilters: [String] - } - - private func presentImportDialog( - prefilledBrowsers: [InstalledBrowserCandidate]?, - defaultDestinationProfileID: UUID?, - defaultScope: BrowserImportScope? - ) { - guard !importInProgress else { return } -#if DEBUG - let environment = ProcessInfo.processInfo.environment - let fixtureBrowsers = BrowserImportUITestFixtureLoader.browsers(from: environment) - let fixtureDestinationProfiles = BrowserImportUITestFixtureLoader.destinationProfiles(from: environment) - let browsers = prefilledBrowsers ?? fixtureBrowsers ?? installedBrowserDetector.detectInstalledBrowsers() -#else - let fixtureDestinationProfiles: [BrowserProfileDefinition]? = nil - let browsers = prefilledBrowsers ?? installedBrowserDetector.detectInstalledBrowsers() -#endif - guard !browsers.isEmpty else { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String( - localized: "browser.import.noBrowsers.title", - defaultValue: "No importable browsers found" - ) - alert.informativeText = String( - localized: "browser.import.noBrowsers.message", - defaultValue: "cmux could not find browser profiles to import from on this Mac." - ) - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - alert.runModal() - return - } - - guard let selection = promptForSelection( - browsers: browsers, - destinationProfiles: fixtureDestinationProfiles, - defaultDestinationProfileID: defaultDestinationProfileID, - defaultScope: defaultScope - ) else { return } - -#if DEBUG - if captureSelectionIfRequested(selection, destinationProfiles: fixtureDestinationProfiles) { - return - } -#endif - let realizedPlan: RealizedBrowserImportExecutionPlan - do { - realizedPlan = try BrowserImportPlanResolver.realize(plan: selection.executionPlan) - } catch { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String( - localized: "browser.import.error.title", - defaultValue: "Import could not start" - ) - alert.informativeText = error.localizedDescription - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - alert.runModal() - return - } - importInProgress = true - - let progressWindow = showProgressWindow( - title: String( - localized: "browser.import.progress.title", - defaultValue: "Importing Browser Data" - ), - message: String( - format: String( - localized: "browser.import.progress.message", - defaultValue: "Importing %@ from %@…" - ), - selection.scope.displayName.lowercased(), - selection.browser.displayName - ) - ) - - Task.detached(priority: .userInitiated) { - let outcome = await BrowserDataImporter.importData( - from: selection.browser, - plan: realizedPlan, - scope: selection.scope, - domainFilters: selection.domainFilters - ) - - await MainActor.run { - self.hideProgressWindow(progressWindow) - self.presentOutcome(outcome) - self.importInProgress = false - } - } - } - - private func promptForSelection( +extension BrowserDataImportCoordinator { + func promptForSelection( browsers: [InstalledBrowserCandidate], destinationProfiles: [BrowserProfileDefinition]?, defaultDestinationProfileID: UUID?, @@ -10423,96 +10302,7 @@ final class BrowserDataImportCoordinator { return wizard.runModal() } -#if DEBUG - func debugMakeImportWizardWindow( - browsers: [InstalledBrowserCandidate], - destinationProfiles: [BrowserProfileDefinition]? = nil, - defaultDestinationProfileID: UUID? = nil, - defaultScope: BrowserImportScope? = nil - ) -> NSWindow { - let wizard = ImportWizardWindowController( - browsers: browsers, - destinationProfiles: destinationProfiles, - defaultDestinationProfileID: defaultDestinationProfileID, - defaultScope: defaultScope - ) - return wizard.debugPanelWindow - } -#endif - -#if DEBUG - private struct CapturedImportSelection: Encodable { - struct Entry: Encodable { - let sourceProfiles: [String] - let destinationKind: String - let destinationName: String - } - let browserName: String - let mode: String - let scope: String - let domainFilters: [String] - let entries: [Entry] - } - - private func captureSelectionIfRequested( - _ selection: ImportSelection, - destinationProfiles: [BrowserProfileDefinition]? - ) -> Bool { - let environment = ProcessInfo.processInfo.environment - guard environment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] == "capture-only" else { return false } - guard let path = environment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"], !path.isEmpty else { - return true - } - - let availableDestinationProfiles = destinationProfiles ?? BrowserProfileStore.shared.profiles - let payload = CapturedImportSelection( - browserName: selection.browser.displayName, - mode: captureModeName(selection.executionPlan.mode), - scope: selection.scope.rawValue, - domainFilters: selection.domainFilters, - entries: selection.executionPlan.entries.map { entry in - let destinationKind: String - let destinationName: String - switch entry.destination { - case .existing(let id): - destinationKind = "existing" - destinationName = availableDestinationProfiles.first(where: { $0.id == id })?.displayName - ?? BrowserProfileStore.shared.displayName(for: id) - case .createNamed(let name): - destinationKind = "create" - destinationName = name - } - return CapturedImportSelection.Entry( - sourceProfiles: entry.sourceProfiles.map(\.displayName), - destinationKind: destinationKind, - destinationName: destinationName - ) - } - ) - - guard let data = try? JSONEncoder().encode(payload) else { return true } - let url = URL(fileURLWithPath: path) - try? FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true, - attributes: nil - ) - try? data.write(to: url) - return true - } - - private func captureModeName(_ mode: BrowserImportDestinationMode) -> String { - switch mode { - case .singleDestination: - return "singleDestination" - case .separateProfiles: - return "separateProfiles" - case .mergeIntoOne: - return "mergeIntoOne" - } - } -#endif @MainActor private final class ImportWizardWindowController: NSObject, @preconcurrency NSWindowDelegate { @@ -11548,7 +11338,7 @@ final class BrowserDataImportCoordinator { } } - private func showProgressWindow(title: String, message: String) -> NSWindow { + func showProgressWindow(title: String, message: String) -> NSWindow { let window = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 420, height: 122), styleMask: [.titled], @@ -11599,7 +11389,7 @@ final class BrowserDataImportCoordinator { return window } - private func hideProgressWindow(_ window: NSWindow) { + func hideProgressWindow(_ window: NSWindow) { if let parent = window.sheetParent { parent.endSheet(window) } else { @@ -11607,7 +11397,7 @@ final class BrowserDataImportCoordinator { } } - private func presentOutcome(_ outcome: BrowserImportOutcome) { + func presentOutcome(_ outcome: BrowserImportOutcome) { let lines = outcome.formattedLines let alert = NSAlert() alert.alertStyle = .informational diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index fb9ad628ea65..3dcee7fe4e2f 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -277,6 +277,7 @@ struct BrowserPanelView: View { /// theme and is never used to resolve browser toolbar colors. private let inheritedColorScheme: ColorScheme @Environment(\.cmuxCanvasInlineBrowserHosting) private var canvasInlineBrowserHosting + @Environment(BrowserDataImportCoordinator.self) private var browserDataImportCoordinator: BrowserDataImportCoordinator? @Environment(\.paneDropZone) private var paneDropZone /// Held detector instance used to summarize installed browsers rather than /// the former `BrowserInstalledBrowserDetector` static namespace. @@ -2378,7 +2379,7 @@ struct BrowserPanelView: View { private func presentImportDialogFromHint() { isBrowserImportHintPopoverPresented = false DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog( + browserDataImportCoordinator?.presentImportDialog( defaultDestinationProfileID: panel.profileID ) } @@ -2387,7 +2388,7 @@ struct BrowserPanelView: View { private func presentImportDialogFromProfileMenu() { isBrowserProfileMenuPresented = false DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog( + browserDataImportCoordinator?.presentImportDialog( defaultDestinationProfileID: panel.profileID ) } @@ -2473,9 +2474,8 @@ struct BrowserPanelView: View { } tasks.replaceOnMainActor(.emptyStateImportBrowserRefresh) { - let browsers = await Task.detached(priority: .utility) { - BrowserInstalledBrowserDetector().detectInstalledBrowsers() - }.value + guard let browserDataImportCoordinator else { return } + let browsers = await browserDataImportCoordinator.detectInstalledBrowsers() guard !Task.isCancelled else { return } await MainActor.run { guard emptyStateImportBrowserRefreshGeneration == generation, diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift index 13481ddf49dd..cd0705a93f23 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift @@ -131,32 +131,3 @@ struct SidebarAppKitRowActions { let hideTodoStatus: () -> Void let commitRename: (String) -> Void } - - -/// Per-sidebar memo of workspace snapshots so container re-renders (divider -/// drags re-render every frame) reuse cached snapshots; only pump events and -/// settings changes recompute. Plain box, never observed. -@MainActor -final class SidebarRowSnapshotCache { - private var snapshotsById: [UUID: SidebarWorkspaceSnapshotBuilder.Snapshot] = [:] - private var settingsFingerprint: SidebarTabItemSettingsSnapshot? - - func resetIfSettingsChanged(_ settings: SidebarTabItemSettingsSnapshot) { - guard settingsFingerprint != settings else { return } - settingsFingerprint = settings - snapshotsById.removeAll(keepingCapacity: true) - } - - func value(for id: UUID) -> SidebarWorkspaceSnapshotBuilder.Snapshot? { - snapshotsById[id] - } - - func store(_ snapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, for id: UUID) { - snapshotsById[id] = snapshot - } - - func prune(keeping ids: Set) { - guard snapshotsById.count > ids.count else { return } - snapshotsById = snapshotsById.filter { ids.contains($0.key) } - } -} diff --git a/Sources/Sidebar/SidebarRowSnapshotCache.swift b/Sources/Sidebar/SidebarRowSnapshotCache.swift new file mode 100644 index 000000000000..5975bf8e05ec --- /dev/null +++ b/Sources/Sidebar/SidebarRowSnapshotCache.swift @@ -0,0 +1,65 @@ +import Foundation +import Observation + +/// The one snapshot owner shared by the native and legacy workspace sidebars. +/// Material event batches publish only a revision, avoiding copy-on-write copies +/// of the entire snapshot dictionary and retention of retired workspaces. +@MainActor +@Observable +final class SidebarRowSnapshotCache { + @ObservationIgnored private(set) var snapshotsById: [UUID: SidebarWorkspaceSnapshotBuilder.Snapshot] = [:] + private(set) var revision: UInt64 = 0 + + deinit {} + + func value(for id: UUID) -> SidebarWorkspaceSnapshotBuilder.Snapshot? { + _ = revision + return snapshotsById[id] + } + + /// Reconciles membership and settings from lifecycle events, outside rendering. + /// Reusing valid snapshots keeps membership changes scoped to new workspaces. + func reconcile( + workspaceIds: Set, + presentationKey: SidebarWorkspaceSnapshotBuilder.PresentationKey, + snapshot: (UUID) -> SidebarWorkspaceSnapshotBuilder.Snapshot? + ) { + var next: [UUID: SidebarWorkspaceSnapshotBuilder.Snapshot] = [:] + next.reserveCapacity(workspaceIds.count) + for id in workspaceIds { + if let cached = snapshotsById[id], cached.presentationKey == presentationKey { + next[id] = cached + } else { + next[id] = snapshot(id) + } + } + replace(with: next) + } + + /// Deactivation already removes the parent; cleanup publishes nothing. + func prune(keeping ids: Set) { + for id in snapshotsById.keys where !ids.contains(id) { + snapshotsById.removeValue(forKey: id) + } + } + + func refresh( + workspaceIds: Set, + snapshot: (UUID) -> SidebarWorkspaceSnapshotBuilder.Snapshot? + ) { + var changed = false + for id in workspaceIds { + let next = snapshot(id) + guard snapshotsById[id] != next else { continue } + snapshotsById[id] = next + changed = true + } + if changed { revision &+= 1 } + } + + func replace(with snapshots: [UUID: SidebarWorkspaceSnapshotBuilder.Snapshot]) { + guard snapshotsById != snapshots else { return } + snapshotsById = snapshots + revision &+= 1 + } +} diff --git a/Sources/TerminalController+BrowserImport.swift b/Sources/TerminalController+BrowserImport.swift new file mode 100644 index 000000000000..f2c76cebe974 --- /dev/null +++ b/Sources/TerminalController+BrowserImport.swift @@ -0,0 +1,71 @@ +import Foundation +import CmuxBrowser + +extension TerminalController { + func v2BrowserImportDialog(params: [String: Any]) -> V2CallResult { + guard let coordinator = browserDataImportCoordinator else { + return .err( + code: "not_ready", + message: String(localized: "browser.import.error.title", defaultValue: "Import could not start"), + data: nil + ) + } + guard coordinator.isPresentationAvailable else { + return .err( + code: "busy", + message: String(localized: "browser.import.error.title", defaultValue: "Import could not start"), + data: nil + ) + } + let scope: BrowserImportScope? + if params.keys.contains("scope") { + guard let raw = v2String(params, "scope")?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !raw.isEmpty else { + return .err( + code: "invalid_params", + message: String(localized: "browser.import.error.scopeRequired", defaultValue: "Scope must be a non-empty string."), + data: ["param": "scope"] + ) + } + switch raw { + case "cookie", "cookies", "cookiesonly", "cookies_only", "cookies-only": + scope = .cookiesOnly + case "history", "historyonly", "history_only", "history-only": + scope = .historyOnly + case "cookiesandhistory", "cookies_and_history", "cookies-and-history", "all-basic": + scope = .cookiesAndHistory + case "everything", "all": + scope = .everything + default: + return .err( + code: "invalid_params", + message: String(localized: "browser.import.error.scopeInvalid", defaultValue: "Scope is invalid."), + data: ["param": "scope"] + ) + } + } else { + scope = nil + } + + let defaultDestinationProfileID: UUID? + do { + defaultDestinationProfileID = try BrowserImportDestinationResolver().resolve( + params: params, + destinationProfiles: BrowserProfileStore.shared.profiles + ) + } catch { + return .err(code: "invalid_params", message: error.localizedDescription, data: nil) + } + guard coordinator.presentImportDialog( + defaultDestinationProfileID: defaultDestinationProfileID, + defaultScope: scope + ) else { + return .err(code: "busy", message: String(localized: "browser.import.error.title", defaultValue: "Import could not start"), data: nil) + } + return .ok([ + "opened": true, + "scope": scope.map { $0.rawValue as Any } ?? NSNull(), + ]) + } + +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index de8d46aa2ef7..c1065be7021e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -149,6 +149,7 @@ class TerminalController { /// App-lifetime automation engine, attached by the composition root after /// the initial TabManager and notification store are ready. @MainActor var automationEngine: AutomationEngine? + @MainActor private(set) var browserDataImportCoordinator: BrowserDataImportCoordinator? nonisolated let terminalArtifactAuthorizationStore: TerminalArtifactAuthorizationStore /// Main-actor grants for the file currently displayed by each mobile panel. /// The live panel inventory and artifact reads share this owner so a closed @@ -1050,6 +1051,12 @@ class TerminalController { self.accountFlow = accountFlow } + /// Injects the app-lifetime browser import coordinator before socket RPCs start. + @MainActor + func attachBrowserDataImportCoordinator(_ coordinator: BrowserDataImportCoordinator) { + browserDataImportCoordinator = coordinator + } + /// Inject the app-lifetime power controller before socket or mobile calls /// can reach the caffeine methods. @MainActor @@ -1716,7 +1723,12 @@ class TerminalController { } case "browser.import.cookies": return v2VmCall(id: request.id, timeoutSeconds: 10 * 60) { - let outcome = try await BrowserImportAutomation.importCookies(params: request.params) + guard let coordinator = await self.browserDataImportCoordinator else { + throw BrowserImportAutomationError.noBrowsers + } + let outcome = try await BrowserImportAutomation.importCookies( + params: request.params, coordinator: coordinator + ) return outcome.socketPayload } case "mobile.attach_ticket.create": @@ -10099,81 +10111,6 @@ class TerminalController { return event } - private func v2BrowserImportDialog(params: [String: Any]) -> V2CallResult { - let scope: BrowserImportScope? - if params.keys.contains("scope") { - guard let raw = v2String(params, "scope")?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - !raw.isEmpty else { - return .err(code: "invalid_params", message: "scope must be a non-empty string", data: ["param": "scope"]) - } - switch raw { - case "cookie", "cookies", "cookiesonly", "cookies_only", "cookies-only": - scope = .cookiesOnly - case "history", "historyonly", "history_only", "history-only": - scope = .historyOnly - case "cookiesandhistory", "cookies_and_history", "cookies-and-history", "all-basic": - scope = .cookiesAndHistory - case "everything", "all": - scope = .everything - default: - return .err(code: "invalid_params", message: "scope is invalid", data: ["param": "scope"]) - } - } else { - scope = nil - } - - let defaultDestinationProfileID: UUID? - if params.keys.contains("destination_profile") { - guard let query = v2String(params, "destination_profile")? - .trimmingCharacters(in: .whitespacesAndNewlines), - !query.isEmpty else { - return .err( - code: "invalid_params", - message: "destination_profile must be a non-empty string", - data: ["param": "destination_profile"] - ) - } - let profiles = BrowserProfileStore.shared.profiles - if let uuid = UUID(uuidString: query), - profiles.contains(where: { $0.id == uuid }) { - defaultDestinationProfileID = uuid - } else if let profile = profiles.first(where: { - $0.displayName.localizedCaseInsensitiveCompare(query) == .orderedSame || - $0.slug.localizedCaseInsensitiveCompare(query) == .orderedSame - }) { - defaultDestinationProfileID = profile.id - } else if v2Bool(params, "create_destination_profile") == true || - v2Bool(params, "create_profile") == true { - guard let createdProfileID = BrowserProfileStore.shared.createProfile(named: query)?.id else { - return .err( - code: "invalid_params", - message: "destination_profile could not be created", - data: ["param": "destination_profile"] - ) - } - defaultDestinationProfileID = createdProfileID - } else { - return .err( - code: "invalid_params", - message: "destination_profile does not match a cmux browser profile", - data: ["param": "destination_profile"] - ) - } - } else { - defaultDestinationProfileID = nil - } - Task { @MainActor in - BrowserDataImportCoordinator.shared.presentImportDialog( - defaultDestinationProfileID: defaultDestinationProfileID, - defaultScope: scope - ) - } - return .ok([ - "opened": true, - "scope": scope.map { $0.rawValue as Any } ?? NSNull(), - ]) - } - private nonisolated func v2BrowserCookieDict(_ cookie: HTTPCookie) -> [String: Any] { var out: [String: Any] = [ "name": cookie.name, diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index d91afe002890..b4047d94ad73 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -18,21 +18,17 @@ import UniformTypeIdentifiers import CmuxTerminal struct cmuxApp: App { - /// Dependency container for the new settings packages. Constructed - /// once at app launch and injected into the SwiftUI environment via - /// `.settingsRuntime(_:)`; descendant views resolve their settings - /// through it via the `@LiveSetting` property wrapper. + /// App-owned settings graph, injected into each SwiftUI hosting root. private let settingsRuntime: SettingsRuntime /// Single owner of the independently launched Computer Use helper daemon. private let computerUseRuntimeService: ComputerUseRuntimeService - /// The de-singletonized auth graph (shared AuthCoordinator + the macOS - /// hosted-browser sign-in flow). Constructed once at app launch and - /// injected into AppDelegate and the auth-consuming services. + /// App-owned auth graph injected into the delegate and auth consumers. private let authComposition: MacAuthComposition /// Composition-root owner for the config-backed automation bridge. private let automationEngine: AutomationEngine + private let browserDataImportCoordinator: BrowserDataImportCoordinator @StateObject private var tabManager: TabManager @StateObject private var notificationStore: TerminalNotificationStore @StateObject var closedItemHistoryStore: ClosedItemHistoryStore @@ -101,10 +97,12 @@ struct cmuxApp: App { backupTimestamp: secretMigrationTimestamp ) let authComposition = MacAuthComposition() + let browserDataImportCoordinator = BrowserDataImportCoordinator() let notificationStore = TerminalNotificationStore.shared let closedItemHistoryStore = ClosedItemHistoryStore.shared let sidebarState = SidebarState() self.authComposition = authComposition + self.browserDataImportCoordinator = browserDataImportCoordinator // If invoked with CLI-style arguments (e.g. `cmux hooks setup`), exec the // bundled CLI at Contents/Resources/bin/cmux. The GUI binary and the CLI @@ -199,6 +197,7 @@ struct cmuxApp: App { hostActions: HostSettingsActions( configFileURL: configFileURL, computerUseRuntimeService: computerUseRuntimeService, + browserDataImportCoordinator: browserDataImportCoordinator, computersActions: devices.settingsActions, runComputerUseOnboardingAction: { startingPoint in AppDelegate.shared?.computerUseUXCoordinator.presentOnboardingFromSettings( @@ -317,6 +316,7 @@ struct cmuxApp: App { cloudWorkspaceOperationController: cloudWorkspaceOperationController, newMachineSheetPresenter: NewMachineSheetPresenter.shared, automationEngine: automationEngine, + browserDataImportCoordinator: browserDataImportCoordinator, computerUseRuntimeService: computerUseRuntimeService, devicesRegistry: devicesRegistry, computersService: computersService @@ -1172,7 +1172,7 @@ struct cmuxApp: App { Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) { // Defer modal presentation until after AppKit finishes menu tracking. DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() + browserDataImportCoordinator.presentImportDialog() } } @@ -2358,7 +2358,7 @@ private struct BrowserImportHintDebugView: View { } Button("Open Import Dialog") { DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() + AppDelegate.shared?.browserDataImportCoordinator?.presentImportDialog() } } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 5f314ebb6b69..22aee63a44fe 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -374,6 +374,7 @@ D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */; }; AC7B24C10000000000000001 /* BrowserAuthCallbackNavigationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7B24C10000000000000002 /* BrowserAuthCallbackNavigationPolicy.swift */; }; B3770BA00000000000000001 /* BrowserAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3770BA00000000000000002 /* BrowserAutomation.swift */; }; + C12791C00000000000000006 /* BrowserAutomationParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791C00000000000000005 /* BrowserAutomationParameters.swift */; }; 8054B0030000000000000003 /* BrowserAutomationRecoveryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054B0040000000000000004 /* BrowserAutomationRecoveryLifecycleTests.swift */; }; 8054A0070000000000000007 /* BrowserAutomationSnapshotResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054A0080000000000000008 /* BrowserAutomationSnapshotResult.swift */; }; BF4CB5698F9E438FA81436E3 /* BrowserAvailabilityManagedPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 111A8E6117014894B1DB6998 /* BrowserAvailabilityManagedPolicy.swift */; }; @@ -383,6 +384,7 @@ D7032A050000000000000001 /* BrowserClientCertificateCredentialPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7032A050000000000000002 /* BrowserClientCertificateCredentialPicker.swift */; }; D7032A030000000000000001 /* BrowserClientCertificateCredentialPickerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7032A030000000000000002 /* BrowserClientCertificateCredentialPickerTests.swift */; }; E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; }; + C12791000000000000000001 /* BrowserDataImportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791000000000000000002 /* BrowserDataImportCoordinator.swift */; }; D35C00000000000000000003 /* BrowserDesignModeArtifactStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35D00000000000000000003 /* BrowserDesignModeArtifactStoreTests.swift */; }; D35C00000000000000000002 /* BrowserDesignModeComposerHostingViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35D00000000000000000002 /* BrowserDesignModeComposerHostingViewTests.swift */; }; D35A0000000000000000001C /* BrowserDesignModeController+Capture.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B0000000000000000001C /* BrowserDesignModeController+Capture.swift */; }; @@ -436,6 +438,8 @@ BABA25000000000000000001 /* BrowserHTTPBasicAuthPromptCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BABA25000000000000000002 /* BrowserHTTPBasicAuthPromptCoordinator.swift */; }; BABA25000000000000000005 /* BrowserHTTPBasicAuthPromptCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BABA25000000000000000006 /* BrowserHTTPBasicAuthPromptCoordinatorTests.swift */; }; BABA25000000000000000003 /* BrowserHTTPBasicAuthPromptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BABA25000000000000000004 /* BrowserHTTPBasicAuthPromptTests.swift */; }; + C12791C00000000000000002 /* BrowserImportAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791C00000000000000001 /* BrowserImportAutomation.swift */; }; + C12791C00000000000000008 /* BrowserImportDestinationResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791C00000000000000007 /* BrowserImportDestinationResolver.swift */; }; FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; }; B75040040000000000000001 /* BrowserInsecureHTTPAlertTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */; }; @@ -2984,6 +2988,8 @@ B804A03794950000000000B1 /* SidebarRowInlineRenameSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B03794950000000000B1 /* SidebarRowInlineRenameSession.swift */; }; 3D0EDA5CB6EA473DB4DB9464 /* SidebarRowPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59BA833A044542BD9A96F3E1 /* SidebarRowPaletteTests.swift */; }; B804A0370000000000000037 /* SidebarRowPressedDim.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0370000000000000037 /* SidebarRowPressedDim.swift */; }; + C12791000000000000000003 /* SidebarRowSnapshotCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791000000000000000004 /* SidebarRowSnapshotCache.swift */; }; + C12791000000000000000005 /* SidebarRowSnapshotCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791000000000000000006 /* SidebarRowSnapshotCacheTests.swift */; }; B804A0310000000000000031 /* SidebarRowSwiftUIPopoverPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0310000000000000031 /* SidebarRowSwiftUIPopoverPresenter.swift */; }; B804A0320000000000000032 /* SidebarRowTaskStatusGlyphButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0320000000000000032 /* SidebarRowTaskStatusGlyphButton.swift */; }; B804A03A000000000000003A /* SidebarRowTextAccessibilityLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B03A000000000000003A /* SidebarRowTextAccessibilityLink.swift */; }; @@ -2999,6 +3005,7 @@ B804A0280000000000000028 /* SidebarSelectionCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0280000000000000028 /* SidebarSelectionCoalescer.swift */; }; B804C0020000000000000002 /* SidebarSelectionCoalescerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804D0020000000000000002 /* SidebarSelectionCoalescerTests.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; + C12791000000000000000007 /* SidebarSnapshotOwnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791000000000000000008 /* SidebarSnapshotOwnerTests.swift */; }; F57072635F25EBCA741E125D /* SidebarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */; }; D7344F010000000000000001 /* SidebarTabDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7344F010000000000000002 /* SidebarTabDragPayload.swift */; }; D73440010000000000000001 /* SidebarTabDragPayloadProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73440010000000000000002 /* SidebarTabDragPayloadProviderTests.swift */; }; @@ -3366,6 +3373,7 @@ 8054A0030000000000000003 /* TerminalController+BrowserAutomationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054A0040000000000000004 /* TerminalController+BrowserAutomationRecovery.swift */; }; D35A00000000000000000011 /* TerminalController+BrowserDesignMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */; }; A12451000000000000000004 /* TerminalController+BrowserDownloadHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12451000000000000000003 /* TerminalController+BrowserDownloadHistory.swift */; }; + C12791C00000000000000004 /* TerminalController+BrowserImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791C00000000000000003 /* TerminalController+BrowserImport.swift */; }; 805600000000000000000005 /* TerminalController+BrowserViewport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805600000000000000000006 /* TerminalController+BrowserViewport.swift */; }; D35A00000000000000000013 /* TerminalController+BrowserWorkerSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B00000000000000000013 /* TerminalController+BrowserWorkerSupport.swift */; }; CAFE00000000000000000004 /* TerminalController+Caffeine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE00000000000000000003 /* TerminalController+Caffeine.swift */; }; @@ -4532,6 +4540,7 @@ D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserArrowKeyForwardingTests.swift; sourceTree = ""; }; AC7B24C10000000000000002 /* BrowserAuthCallbackNavigationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAuthCallbackNavigationPolicy.swift; sourceTree = ""; }; B3770BA00000000000000002 /* BrowserAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAutomation.swift; sourceTree = ""; }; + C12791C00000000000000005 /* BrowserAutomationParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAutomationParameters.swift; sourceTree = ""; }; 8054B0040000000000000004 /* BrowserAutomationRecoveryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserAutomationRecoveryLifecycleTests.swift; sourceTree = ""; }; 8054A0080000000000000008 /* BrowserAutomationSnapshotResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAutomationSnapshotResult.swift; sourceTree = ""; }; 111A8E6117014894B1DB6998 /* BrowserAvailabilityManagedPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserAvailabilityManagedPolicy.swift; sourceTree = ""; }; @@ -4541,6 +4550,7 @@ D7032A050000000000000002 /* BrowserClientCertificateCredentialPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserClientCertificateCredentialPicker.swift; sourceTree = ""; }; D7032A030000000000000002 /* BrowserClientCertificateCredentialPickerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserClientCertificateCredentialPickerTests.swift; sourceTree = ""; }; 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; + C12791000000000000000002 /* BrowserDataImportCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDataImportCoordinator.swift; sourceTree = ""; }; D35D00000000000000000003 /* BrowserDesignModeArtifactStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDesignModeArtifactStoreTests.swift; sourceTree = ""; }; D35D00000000000000000002 /* BrowserDesignModeComposerHostingViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDesignModeComposerHostingViewTests.swift; sourceTree = ""; }; D35B0000000000000000001C /* BrowserDesignModeController+Capture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserDesignModeController+Capture.swift"; sourceTree = ""; }; @@ -4594,6 +4604,8 @@ BABA25000000000000000002 /* BrowserHTTPBasicAuthPromptCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserHTTPBasicAuthPromptCoordinator.swift; sourceTree = ""; }; BABA25000000000000000006 /* BrowserHTTPBasicAuthPromptCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserHTTPBasicAuthPromptCoordinatorTests.swift; sourceTree = ""; }; BABA25000000000000000004 /* BrowserHTTPBasicAuthPromptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserHTTPBasicAuthPromptTests.swift; sourceTree = ""; }; + C12791C00000000000000001 /* BrowserImportAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserImportAutomation.swift"; sourceTree = ""; }; + C12791C00000000000000007 /* BrowserImportDestinationResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserImportDestinationResolver.swift; sourceTree = ""; }; FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = ""; }; B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserInsecureHTTPAlertTestSupport.swift; sourceTree = ""; }; @@ -6914,6 +6926,7 @@ D35B00000000000000000010 /* ShortcutRecorderRejectedAttempt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRecorderRejectedAttempt.swift; sourceTree = ""; }; B42A82C6AA614E74873D9A5F /* ShortcutRoutingSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/ShortcutRoutingSupport.swift; sourceTree = ""; }; F6001001A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutUnbindingTests.swift; sourceTree = ""; }; + C12791000000000000000004 /* Sidebar/SidebarRowSnapshotCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarRowSnapshotCache.swift; sourceTree = ""; }; 521EAC2D4A600581B92BC865 /* SidebarAccessibilityTreeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarAccessibilityTreeTests.swift"; sourceTree = ""; }; 3FAE8A4DD3DEE6A1ECEFB1C6 /* SidebarAccessibilityTreeWalk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarAccessibilityTreeWalk.swift"; sourceTree = ""; }; EC1E153B939944FC893F1EC2 /* SidebarAccountMenuButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarAccountMenuButtonStyle.swift"; sourceTree = ""; }; @@ -6990,6 +7003,7 @@ B804B03794950000000000B1 /* SidebarRowInlineRenameSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowInlineRenameSession.swift; sourceTree = ""; }; 59BA833A044542BD9A96F3E1 /* SidebarRowPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarRowPaletteTests.swift"; sourceTree = ""; }; B804B0370000000000000037 /* SidebarRowPressedDim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowPressedDim.swift; sourceTree = ""; }; + C12791000000000000000006 /* SidebarRowSnapshotCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarRowSnapshotCacheTests.swift; sourceTree = ""; }; B804B0310000000000000031 /* SidebarRowSwiftUIPopoverPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowSwiftUIPopoverPresenter.swift; sourceTree = ""; }; B804B0320000000000000032 /* SidebarRowTaskStatusGlyphButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowTaskStatusGlyphButton.swift; sourceTree = ""; }; B804B03A000000000000003A /* SidebarRowTextAccessibilityLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowTextAccessibilityLink.swift; sourceTree = ""; }; @@ -7005,6 +7019,7 @@ B804B0280000000000000028 /* SidebarSelectionCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarSelectionCoalescer.swift; sourceTree = ""; }; B804D0020000000000000002 /* SidebarSelectionCoalescerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionCoalescerTests.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; + C12791000000000000000008 /* SidebarSnapshotOwnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSnapshotOwnerTests.swift; sourceTree = ""; }; D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarState.swift; sourceTree = ""; }; D7344F010000000000000002 /* SidebarTabDragPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarTabDragPayload.swift; sourceTree = ""; }; D73440010000000000000002 /* SidebarTabDragPayloadProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarTabDragPayloadProviderTests.swift; sourceTree = ""; }; @@ -7364,6 +7379,7 @@ 8054A0040000000000000004 /* TerminalController+BrowserAutomationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserAutomationRecovery.swift"; sourceTree = ""; }; D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserDesignMode.swift"; sourceTree = ""; }; A12451000000000000000003 /* TerminalController+BrowserDownloadHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserDownloadHistory.swift"; sourceTree = ""; }; + C12791C00000000000000003 /* TerminalController+BrowserImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserImport.swift"; sourceTree = ""; }; 805600000000000000000006 /* TerminalController+BrowserViewport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserViewport.swift"; sourceTree = ""; }; D35B00000000000000000013 /* TerminalController+BrowserWorkerSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserWorkerSupport.swift"; sourceTree = ""; }; CAFE00000000000000000003 /* TerminalController+Caffeine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Caffeine.swift"; sourceTree = ""; }; @@ -9197,6 +9213,7 @@ A11C00010000000000000002 /* CmuxHostedSystemSymbolImage.swift */, C9A57212C9A57212C9A57212 /* SidebarWorkspaceRenderItemID.swift */, C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */, + C12791000000000000000004 /* Sidebar/SidebarRowSnapshotCache.swift */, C9A5760EC9A5760EC9A5760E /* SidebarWorkspaceReorderDropOverlay.swift */, C9A57608C9A57608C9A57608 /* SidebarWorkspaceReorderDropOverlayTarget.swift */, C9A5760AC9A5760AC9A5760A /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift */, @@ -10266,6 +10283,11 @@ C42660030000000000000002 /* BrowserDownloadDelegate+PDFPreviewData.swift */, C67540010000000000000002 /* BrowserSubframeDownloadIntentTracker.swift */, B3770BA00000000000000002 /* BrowserAutomation.swift */, + C12791000000000000000002 /* BrowserDataImportCoordinator.swift */, + C12791C00000000000000003 /* TerminalController+BrowserImport.swift */, + C12791C00000000000000001 /* BrowserImportAutomation.swift */, + C12791C00000000000000007 /* BrowserImportDestinationResolver.swift */, + C12791C00000000000000005 /* BrowserAutomationParameters.swift */, 4472B0014472B0014472B001 /* BrowserScreenshot.swift */, A9210001000000000000000A /* BrowserScreenshotAnimationFrameWaiter.swift */, A92100010000000000000004 /* BrowserScreenshotCaptureService.swift */, @@ -11718,6 +11740,8 @@ A6D1F0400000000000000001 /* ReleasingWindowControllerTests.swift */, CAFE00000000000000000005 /* CaffeineControllerTests.swift */, A7206E020000000000000001 /* AppIconAppearanceObserverTests.swift */, + C12791000000000000000006 /* SidebarRowSnapshotCacheTests.swift */, + C12791000000000000000008 /* SidebarSnapshotOwnerTests.swift */, A11EAA000000000000000001 /* AppearanceSettingsTests.swift */, A7206B020000000000000001 /* SystemAppearanceObserverTests.swift */, 6313FACE0000000000000001 /* PaneMemoryGuardrailTests.swift */, @@ -13017,6 +13041,7 @@ + A11CE0040000000000000001 /* AboutLicenseContent.swift in Sources */, D0B102030000000000000001 /* AboutPanelView.swift in Sources */, D0B102040000000000000001 /* AboutPropertyRow.swift in Sources */, @@ -13271,11 +13296,13 @@ B5A5E5570000000000000001 /* BrowserAppSessionWeakReference.swift in Sources */, AC7B24C10000000000000001 /* BrowserAuthCallbackNavigationPolicy.swift in Sources */, B3770BA00000000000000001 /* BrowserAutomation.swift in Sources */, + C12791C00000000000000006 /* BrowserAutomationParameters.swift in Sources */, 8054A0070000000000000007 /* BrowserAutomationSnapshotResult.swift in Sources */, BF4CB5698F9E438FA81436E3 /* BrowserAvailabilityManagedPolicy.swift in Sources */, BCBC0A0E0000000000000C01 /* BrowserChromeMetrics.swift in Sources */, D7032A020000000000000001 /* BrowserClientCertificateAuthenticationController.swift in Sources */, D7032A050000000000000001 /* BrowserClientCertificateCredentialPicker.swift in Sources */, + C12791000000000000000001 /* BrowserDataImportCoordinator.swift in Sources */, D35A0000000000000000001C /* BrowserDesignModeController+Capture.swift in Sources */, D35A00000000000000000020 /* BrowserDesignModeController+Handoff.swift in Sources */, D35A00000000000000000003 /* BrowserDesignModeController.swift in Sources */, @@ -13308,6 +13335,8 @@ 7490C0017490C0017490C001 /* BrowserHiddenWebViewMemoryPressureResponder.swift in Sources */, BABA25000000000000000007 /* BrowserHTTPBasicAuthPrompt.swift in Sources */, BABA25000000000000000001 /* BrowserHTTPBasicAuthPromptCoordinator.swift in Sources */, + C12791C00000000000000002 /* BrowserImportAutomation.swift in Sources */, + C12791C00000000000000008 /* BrowserImportDestinationResolver.swift in Sources */, B1F0C0010000000000000001 /* BrowserInspectorFocusHandoff.swift in Sources */, D7C0DE00000000000000A109 /* BrowserJavaScriptCompletionBridge.swift in Sources */, C85390070000000000000001 /* BrowserLocalFileFinderMenu.swift in Sources */, @@ -14658,6 +14687,7 @@ AABBCC00000000000000020A /* SidebarRowDragGate.swift in Sources */, B804A03794950000000000B1 /* SidebarRowInlineRenameSession.swift in Sources */, B804A0370000000000000037 /* SidebarRowPressedDim.swift in Sources */, + C12791000000000000000003 /* SidebarRowSnapshotCache.swift in Sources */, B804A0310000000000000031 /* SidebarRowSwiftUIPopoverPresenter.swift in Sources */, B804A0320000000000000032 /* SidebarRowTaskStatusGlyphButton.swift in Sources */, B804A03A000000000000003A /* SidebarRowTextAccessibilityLink.swift in Sources */, @@ -14929,6 +14959,7 @@ 8054A0030000000000000003 /* TerminalController+BrowserAutomationRecovery.swift in Sources */, D35A00000000000000000011 /* TerminalController+BrowserDesignMode.swift in Sources */, A12451000000000000000004 /* TerminalController+BrowserDownloadHistory.swift in Sources */, + C12791C00000000000000004 /* TerminalController+BrowserImport.swift in Sources */, 805600000000000000000005 /* TerminalController+BrowserViewport.swift in Sources */, D35A00000000000000000013 /* TerminalController+BrowserWorkerSupport.swift in Sources */, CAFE00000000000000000004 /* TerminalController+Caffeine.swift in Sources */, @@ -16652,9 +16683,11 @@ C0DE48000000000000000001 /* SidebarProviderMenuRegressionTests.swift in Sources */, 5B0C00010000000000000003 /* SidebarResizerOcclusionResolverTests.swift in Sources */, 3D0EDA5CB6EA473DB4DB9464 /* SidebarRowPaletteTests.swift in Sources */, + C12791000000000000000005 /* SidebarRowSnapshotCacheTests.swift in Sources */, C9A57513C9A57513C9A57513 /* SidebarScrollViewConfiguratorTests.swift in Sources */, F6528A82B7124269968F9A99 /* SidebarSearchFieldTests.swift in Sources */, B804C0020000000000000002 /* SidebarSelectionCoalescerTests.swift in Sources */, + C12791000000000000000007 /* SidebarSnapshotOwnerTests.swift in Sources */, D73440010000000000000001 /* SidebarTabDragPayloadProviderTests.swift in Sources */, CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */, 6707F0176707F0176707F017 /* SidebarWorkspaceContextMenuWindowTargetsTests.swift in Sources */, diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift index 9759b63d6fd9..0b24c37941d2 100644 --- a/cmuxTests/BrowserImportMappingTests.swift +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -52,6 +52,34 @@ final class BrowserImportMappingTests: XCTestCase { XCTAssertEqual(plan.entries[0].sourceProfiles.map(\.displayName), ["You"]) } + @MainActor + func testDestinationSelectorRejectsAmbiguousNamesAndAcceptsUUIDOnlySelector() throws { + let sharedName = [ + BrowserProfileDefinition( + id: UUID(), displayName: "Shared", createdAt: .distantPast, isBuiltInDefault: false + ), + BrowserProfileDefinition( + id: UUID(), displayName: "shared", createdAt: .distantPast, isBuiltInDefault: false + ), + ] + XCTAssertThrowsError( + try BrowserImportDestinationResolver().resolve( + params: ["destination_profile": "SHARED"], destinationProfiles: sharedName + ) + ) { error in + guard let automationError = error as? BrowserProfileAutomationError, + case .ambiguousProfile = automationError else { + return XCTFail("Expected ambiguous destination profile error") + } + } + + let selected = try BrowserImportDestinationResolver().resolve( + params: ["destination_profile_id": sharedName[0].id.uuidString], + destinationProfiles: sharedName + ) + XCTAssertEqual(selected, sharedName[0].id) + } + @MainActor func testSeparatePlanReusesExistingSameNamedDestinationProfiles() { let workID = UUID() @@ -285,39 +313,6 @@ final class BrowserImportMappingTests: XCTestCase { XCTAssertTrue(lines.contains("Created cmux profiles: You, austin")) } - @MainActor - func testImportWizardCanBeConstructedForSettingsChoosePath() { - let destinationProfiles = [ - BrowserProfileDefinition( - id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!, - displayName: "Default", - createdAt: .distantPast, - isBuiltInDefault: true - ) - ] - let browser = makeInstalledBrowserCandidate( - descriptorID: "google-chrome", - displayName: "Chrome", - profiles: [ - makeSourceProfile(displayName: "Default", path: "/tmp/browser-import-chrome-default", isDefault: true), - makeSourceProfile(displayName: "Profile 1", path: "/tmp/browser-import-chrome-profile-1", isDefault: false), - ] - ) - - let window = BrowserDataImportCoordinator.shared.debugMakeImportWizardWindow( - browsers: [browser], - destinationProfiles: destinationProfiles, - defaultDestinationProfileID: destinationProfiles[0].id - ) - defer { - window.orderOut(nil) - window.close() - } - - XCTAssertEqual(window.title, "Import Browser Data") - XCTAssertNotNil(window.contentView) - } - private func makeSourceProfile(displayName: String, path: String, isDefault: Bool) -> InstalledBrowserProfile { InstalledBrowserProfile( displayName: displayName, diff --git a/cmuxTests/ComputerUseUXTests.swift b/cmuxTests/ComputerUseUXTests.swift index 0ebe45c9698d..18ef3f2e6ff4 100644 --- a/cmuxTests/ComputerUseUXTests.swift +++ b/cmuxTests/ComputerUseUXTests.swift @@ -240,6 +240,7 @@ struct ComputerUseUXTests { configFileURL: FileManager.default.temporaryDirectory .appendingPathComponent("cmux-settings-\(UUID().uuidString).json"), computerUseRuntimeService: ComputerUseRuntimeService(), + browserDataImportCoordinator: BrowserDataImportCoordinator(), runComputerUseOnboardingAction: { startingPoint in presentations.append(startingPoint) } @@ -301,12 +302,11 @@ struct ComputerUseUXTests { ) defer { responder.stop() } - var presentations: [ - ComputerUseOnboardingWindowController.StartingPoint - ] = [] + var presentations: [ComputerUseOnboardingWindowController.StartingPoint] = [] let actions = HostSettingsActions( configFileURL: root.appendingPathComponent("cmux.json"), computerUseRuntimeService: runtime, + browserDataImportCoordinator: BrowserDataImportCoordinator(), runComputerUseOnboardingAction: { startingPoint in presentations.append(startingPoint) } diff --git a/cmuxTests/HostSettingsShortcutNotificationTests.swift b/cmuxTests/HostSettingsShortcutNotificationTests.swift index 993756bf66ad..8aa84f9af68d 100644 --- a/cmuxTests/HostSettingsShortcutNotificationTests.swift +++ b/cmuxTests/HostSettingsShortcutNotificationTests.swift @@ -61,6 +61,7 @@ struct HostSettingsShortcutNotificationTests { let host = HostSettingsActions( configFileURL: directoryURL.appendingPathComponent("cmux.json"), computerUseRuntimeService: ComputerUseRuntimeService(), + browserDataImportCoordinator: BrowserDataImportCoordinator(), automationConfigStore: store, runComputerUseOnboardingAction: { _ in } ) @@ -88,6 +89,7 @@ struct HostSettingsShortcutNotificationTests { let host = HostSettingsActions( configFileURL: directoryURL.appendingPathComponent("cmux.json"), computerUseRuntimeService: ComputerUseRuntimeService(), + browserDataImportCoordinator: BrowserDataImportCoordinator(), automationConfigStore: store, runComputerUseOnboardingAction: { _ in } ) @@ -115,6 +117,7 @@ struct HostSettingsShortcutNotificationTests { let host = HostSettingsActions( configFileURL: directoryURL.appendingPathComponent("cmux.json"), computerUseRuntimeService: ComputerUseRuntimeService(), + browserDataImportCoordinator: BrowserDataImportCoordinator(), automationConfigStore: AutomationConfigStore(fileURL: fileURL), openAutomationRulesFile: { opened.append($0) }, reportAutomationRulesError: { errors.append($0) }, @@ -169,6 +172,7 @@ struct HostSettingsShortcutNotificationTests { HostSettingsActions( configFileURL: settingsFileURL, computerUseRuntimeService: ComputerUseRuntimeService(), + browserDataImportCoordinator: BrowserDataImportCoordinator(), runComputerUseOnboardingAction: { _ in } ).notifyShortcutSettingsDidChange() diff --git a/cmuxTests/SidebarRowSnapshotCacheTests.swift b/cmuxTests/SidebarRowSnapshotCacheTests.swift new file mode 100644 index 000000000000..2b75b4ee0a25 --- /dev/null +++ b/cmuxTests/SidebarRowSnapshotCacheTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Sidebar snapshot lifetime") +struct SidebarRowSnapshotCacheTests { + @Test func replacingMembershipAtTheSameCountReleasesRetiredSnapshot() { + let cache = SidebarRowSnapshotCache() + let retiredID = UUID() + let replacementID = UUID() + let snapshot = SidebarWorkspaceRowSuspensionTests.makeModel().snapshot + cache.replace(with: [retiredID: snapshot]) + + // A restore/reorder can replace membership without changing its count. + cache.prune(keeping: [replacementID]) + #expect(cache.value(for: retiredID) == nil) + cache.replace(with: [replacementID: snapshot]) + #expect(cache.value(for: replacementID) == snapshot) + } + + @Test func repeatedWorkspaceReplacementDoesNotRetainHistoricalSnapshots() { + let cache = SidebarRowSnapshotCache() + let snapshot = SidebarWorkspaceRowSuspensionTests.makeModel().snapshot + var liveID = UUID() + cache.replace(with: [liveID: snapshot]) + var retiredIDs: [UUID] = [] + for _ in 0..<100 { + retiredIDs.append(liveID) + liveID = UUID() + cache.prune(keeping: [liveID]) + cache.replace(with: [liveID: snapshot]) + #expect(cache.value(for: liveID) == snapshot) + } + #expect(retiredIDs.allSatisfy { cache.value(for: $0) == nil }) + cache.prune(keeping: []) + #expect(cache.value(for: liveID) == nil) + } +} diff --git a/cmuxTests/SidebarSnapshotOwnerTests.swift b/cmuxTests/SidebarSnapshotOwnerTests.swift new file mode 100644 index 000000000000..53094e800dfc --- /dev/null +++ b/cmuxTests/SidebarSnapshotOwnerTests.swift @@ -0,0 +1,132 @@ +import AppKit +import CmuxNotifications +import CmuxUpdater +import Observation +import SwiftUI +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Sidebar snapshot ownership", .serialized) +struct SidebarSnapshotOwnerTests { + @Test func eventBatchesPublishOneRevisionAndProjectionDoesNotPublish() { + let cache = SidebarRowSnapshotCache() + let ids = Set((0..<100).map { _ in UUID() }) + let snapshot = SidebarWorkspaceRowSuspensionTests.makeModel().snapshot + cache.replace(with: Dictionary(uniqueKeysWithValues: ids.map { ($0, snapshot) })) + let initialRevision = cache.revision + for id in ids { _ = cache.value(for: id) } + #expect(cache.revision == initialRevision) + let changed = SidebarWorkspaceRowSuspensionTests.makeModel(customDescription: "Changed").snapshot + cache.refresh(workspaceIds: ids) { _ in changed } + #expect(cache.revision == initialRevision + 1) + cache.refresh(workspaceIds: ids) { _ in changed } + #expect(cache.revision == initialRevision + 1) + cache.prune(keeping: []) + #expect(cache.revision == initialRevision + 1) + #expect(cache.snapshotsById.isEmpty) + } + + @Test func membershipReconciliationBuildsOnlyNewWorkspaceSnapshots() throws { + let cache = SidebarRowSnapshotCache() + let snapshot = SidebarWorkspaceRowSuspensionTests.makeModel().snapshot + let ids = Set((0..<100).map { _ in UUID() }) + cache.reconcile(workspaceIds: ids, presentationKey: snapshot.presentationKey) { _ in snapshot } + let retired = try #require(ids.first) + let replacement = UUID() + let nextIds = ids.subtracting([retired]).union([replacement]) + var requested: Set = [] + cache.reconcile(workspaceIds: nextIds, presentationKey: snapshot.presentationKey) { id in + requested.insert(id) + return snapshot + } + #expect(requested == [replacement]) + #expect(cache.value(for: retired) == nil) + #expect(Set(cache.snapshotsById.keys) == nextIds) + let revision = cache.revision + cache.reconcile(workspaceIds: nextIds, presentationKey: snapshot.presentationKey) { _ in + Issue.record("Unchanged membership must reuse its presentation snapshots") + return snapshot + } + #expect(cache.revision == revision) + } + + @Test(arguments: [true, false]) + func mountedSidebarUsesOneOwnerAcrossUpdatesAndWorkspaceRemoval(appKit: Bool) async throws { + _ = NSApplication.shared + let suiteName = "SidebarSnapshotOwnerTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(CmuxExtensionSidebarSelection.defaultProviderId, forKey: CmuxExtensionSidebarSelection.defaultsKey) + let flags = CmuxFeatureFlags(defaults: defaults, remoteFlagValueProvider: { _ in nil }) + flags.setOverride(appKit, for: CmuxFeatureFlags.appKitSidebarListFlag) + let cache = SidebarRowSnapshotCache() + let manager = TabManager(autoWelcomeIfNeeded: false) + let initialCoalescing = WindowTerminalPortal.usesCoalescedAnchorFailsafe + defer { WindowTerminalPortal.usesCoalescedAnchorFailsafe = initialCoalescing } + let root = VerticalTabsSidebar( + updateViewModel: UpdateStateModel(), + fileExplorerState: FileExplorerState(), + featureFlags: flags, + sidebarUnread: SidebarUnreadModel(), + titlebarControlsLayoutModel: TitlebarControlsLayoutModel(), + windowId: UUID(), + onSendFeedback: {}, onToggleSidebar: {}, onNewTab: {}, + observedWindowReference: WeakWindowReference(), + chromeBackgroundColor: .black, + selection: .constant(.tabs), + selectedTabIds: .constant([]), + lastSidebarSelectionIndex: .constant(nil), + sidebarRenderWorkerClient: .constant(nil), + workspaceSnapshotCache: cache + ) + .environmentObject(manager) + .environmentObject(CmuxConfigStore()) + .environmentObject(TerminalNotificationStore.shared) + .environmentObject(SidebarState()) + .environmentObject(SidebarSelectionState()) + .defaultAppStorage(defaults) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 280, height: 640), + styleMask: [.titled, .closable], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: root) + defer { window.contentView = nil; window.close() } + #expect(await converge(window) { Set(cache.snapshotsById.keys) == Set(manager.tabs.map(\.id)) }) + for cycle in 0..<5 { + let workspace = manager.addWorkspace( + initialSurface: .cloudVMLoading, select: false, + autoWelcomeIfNeeded: false, autoRefreshMetadata: false + ) + #expect(await converge(window) { cache.value(for: workspace.id) != nil }) + workspace.setCustomTitle("Updated \(cycle)") + // Membership reconciliation must preserve an already queued title update. + let sibling = manager.addWorkspace( + initialSurface: .cloudVMLoading, select: false, + autoWelcomeIfNeeded: false, autoRefreshMetadata: false + ) + #expect(await converge(window) { cache.value(for: workspace.id)?.title == "Updated \(cycle)" }) + manager.closeWorkspace(sibling, recordHistory: false) + manager.closeWorkspace(workspace, recordHistory: false) + #expect(await converge(window) { cache.value(for: workspace.id) == nil }) + #expect(Set(cache.snapshotsById.keys) == Set(manager.tabs.map(\.id))) + } + print("SIDEBAR_SNAPSHOT_LIFETIME renderer=\(appKit ? "appkit" : "swiftui") cycles=5 retained=\(cache.snapshotsById.count) live=\(manager.tabs.count)") + } + + private func converge(_ window: NSWindow, until condition: () -> Bool) async -> Bool { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + repeat { + SidebarLazyLayoutScaleTests.turnMainRunLoopOnce(layingOut: window) + await Task.yield() + if condition() { return true } + } while .now < deadline + return false + } +}