diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge+Intents.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge+Intents.swift index 1502db43257f..9a7c4afa28d2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge+Intents.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge+Intents.swift @@ -184,6 +184,7 @@ extension SidebarBridge { func resync() { guard let state else { return } model.ungroupedFirst = !SidebarDocWiring.isActive && !usesMixedOrder + let shownAt = SidebarDocStore.current?.shownChanges var sections = Self.sections(services.machines, members: services.windows.registry.members(of: state.id), profile: state.profileID, hidesHome: Self.hidesHome(services.sidebarLayout.document), selection: state.selection, newTabPages: Self.newTabPages(services.agentTabs, in: services.machines.local.store), muted: services.notifications.preferences.mutedWorkspaces, @@ -192,7 +193,7 @@ extension SidebarBridge { .applying(to: sections) // A connecting SSH machine keeps its pending row through a resync (cx-gaq9). if !model.groupsByComputer { sections = SSHConnectingRows.adding(services.machines, to: sections) } - rows.show(sections) + rows.show(sections, shownAt: shownAt) model.profiles = SidebarSpaceBar.profiles(services.machines.local) groupFlow.openPendingEditor() } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift index 99db2d152102..84458b6b3879 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift @@ -155,13 +155,17 @@ final class SidebarBridge { groupConfig: services.settings?.snapshot.workspaceGroups ?? WorkspaceGroupConfig(), harnessIcons: { [weak services] in services?.harnessIconLookup ?? .none }) // Not the creates' rows (PendingWorkspaceRows): they draw from the press (its redraws below), and a walk reads them as it shows. - return ((PendingWorkspaceRows.shared.listing(listed, in: windowState.id, machines: machines), services.sidebarCustomization.rowDecorations), launching, failed, SidebarSpaceBar.profiles(machines.local), SidebarProfileKey(windowState.profileID.rawValue), + // The document projection these rows were built from (`SidebarRows.layoutCatchingUp`). + return ((PendingWorkspaceRows.shared.listing(listed, in: windowState.id, machines: machines), services.sidebarCustomization.rowDecorations, + SidebarDocStore.current?.shownChanges), launching, failed, SidebarSpaceBar.profiles(machines.local), SidebarProfileKey(windowState.profileID.rawValue), SidebarRows.visibleLayout(layout.document, window: windowState.id, profile: windowState.profileID, machines: machines, registry: registry.value, showAppStore: services.settings?.snapshot.previewFeatures == true)) }) { [weak self] value in - let ((sections, decorations), launching, failed, profiles, active, shown) = value + let ((sections, decorations, shownAt), launching, failed, profiles, active, shown) = value guard let self else { return } self.showProfiles(profiles, active: active, launching: launching) - self.show(SidebarSection.decorated(feed.fill(sections), decorations), layout: shown, launching: launching, failed: failed, creating: PendingWorkspaceRows.shared.rows(in: windowState.id)) + self.show(SidebarSection.decorated(feed.fill(sections), decorations), shownAt: shownAt, layout: shown, launching: launching, failed: failed, + creating: PendingWorkspaceRows.shared.rows(in: windowState.id)) + services.windows.retryDeferredPlacements() // before pending stand-ins are forgotten // A workspace the window lists shows its own row from here on (cx-9a7wx). No create waits: no walk of every row. guard !PendingWorkspaceRows.shared.rows.isEmpty else { return } PendingWorkspaceRows.shared.forget(in: windowState.id, listed: Set(sections.flatMap(\.workspaces).map(\.id.rawValue)), @@ -227,6 +231,7 @@ final class SidebarBridge { model.profiles = saved.sidebarProfiles model.activeProfileID = saved.sidebarActiveProfileID } + let shownAt = SidebarDocStore.current?.shownChanges let (sections, launching, failed) = Self.liveSections(services.machines, registry: windows.registry, window: state, creations: services.cloud.creations, hidesHome: Self.hidesHome(services.sidebarLayout.document), newTabPages: Self.newTabPages(services.agentTabs, in: services.machines.local.store), @@ -235,13 +240,13 @@ final class SidebarBridge { tabTitle: Self.tabTitle(services), groupConfig: services.settings?.snapshot.workspaceGroups ?? WorkspaceGroupConfig(), harnessIcons: { [weak services] in services?.harnessIconLookup ?? .none }) - show(sections, layout: SidebarRows.visibleLayout(services.sidebarLayout.document, window: state.id, profile: state.profileID, machines: services.machines, + show(sections, shownAt: shownAt, layout: SidebarRows.visibleLayout(services.sidebarLayout.document, window: state.id, profile: state.profileID, machines: services.machines, registry: windows.registry.value, showAppStore: services.settings?.snapshot.previewFeatures == true), launching: launching, failed: failed) } /// Shows `live` with loading sections filled from the seed, and the /// band's `layout` in the same turn, then saves it. - private func show(_ live: [SidebarRowSection], layout: SidebarLayoutDocument, launching: Bool, failed: Set, + private func show(_ live: [SidebarRowSection], shownAt: UInt64?, layout: SidebarLayoutDocument, launching: Bool, failed: Set, creating: [PendingWorkspaceRows.Row] = []) { var sections = seed.merge(live, launching: launching, failed: failed) // After the merge, so a reconnecting machine's saved rows stand in first (cx-gaq9). @@ -254,7 +259,7 @@ final class SidebarBridge { // A pass that lists what the rows show already (a daemon reorder the personal or document order hides, a store // snapshot laid over again) sets no sections: no arrange and compare of every row in the main thread's frame. let elsewhere = model.docDrag.flatMap { drag in SidebarDocStore.current?.shown?.bandGroupsListedElsewhere(drag: drag) } - rows.show(sections, creating: (creating, NewWorkspacePlacements.home(services.machines)), unlessShown: hadDocDrag == (model.docDrag != nil), + rows.show(sections, shownAt: shownAt, creating: (creating, NewWorkspacePlacements.home(services.machines)), unlessShown: hadDocDrag == (model.docDrag != nil), elsewhere: elsewhere, showAppStore: services.settings?.snapshot.previewFeatures == true) selectionHold.flush() organizationQueue.drain(loaded: usesPersonalOrganization, local: services.machines.local, run: handle, refuse: refuseOrganization) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift index dba70fe968be..4aeb9372ef85 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift @@ -15,6 +15,9 @@ import CmuxNextSidebar final class SidebarRows { private let model: SidebarModel private var live: [SidebarRowSection] = [] + /// The sidebar document's `shownChanges` `live` was built at (nil: not from its projection): the creates' + /// rows that wait for the window's current row wait while `live` is a projection behind (`layoutCatchingUp`). + private var liveShownAt: UInt64? private var pending = SidebarPendingEdits() /// A group's fold the user asked for that the home store has not shown /// yet (cx-qno.17): the newest click wins over a live recompute, so a @@ -98,19 +101,24 @@ final class SidebarRows { /// rows of the creates in flight (`creating`, when given). `unlessShown`: nothing to do when `live` and `creating` /// are what the rows show already (patches included), so a structural pass that lists nothing new sets no sections /// (sidebar churn: at 400 workspaces each set arranged and compared every row). A changed `elsewhere` shows again. - func show(_ live: [SidebarRowSection]? = nil, creating: (rows: [PendingWorkspaceRows.Row], home: String?)? = nil, unlessShown: Bool = false, - elsewhere: Set? = nil, showAppStore: Bool? = nil) { + /// `shownAt`: the sidebar document's `shownChanges` `live` was built at; a newer one with creates in flight shows + /// again, since a create waiting for the current row may draw now. + func show(_ live: [SidebarRowSection]? = nil, shownAt: UInt64? = nil, creating: (rows: [PendingWorkspaceRows.Row], home: String?)? = nil, + unlessShown: Bool = false, elsewhere: Set? = nil, showAppStore: Bool? = nil) { let sameVisibility = showAppStore == nil || showAppStore == self.showAppStore if let showAppStore { self.showAppStore = showAppStore } let sameElsewhere = elsewhere == nil || elsewhere == self.elsewhere if let elsewhere { self.elsewhere = elsewhere } - if unlessShown, let live, let creating, live == self.live, creating == self.creating, sameElsewhere, sameVisibility { return } + let reprojected = live != nil && shownAt != liveShownAt && !(creating ?? self.creating).rows.isEmpty + if live != nil { liveShownAt = shownAt } + if unlessShown, !reprojected, let live, let creating, live == self.live, creating == self.creating, sameElsewhere, sameVisibility { return } if let live { self.live = live } if let creating { self.creating = creating } #if DEBUG if let fixture { return model.setSections(fixture) } #endif - var sections = PendingWorkspaceRows.adding(self.creating.rows, to: pending.apply(to: self.live), home: self.creating.home) + var sections = PendingWorkspaceRows.adding(self.creating.rows, to: pending.apply(to: self.live), home: self.creating.home, + catchingUp: layoutCatchingUp) for (group, fold) in folds { guard let (s, n) = SidebarEdits.locateGroup(group, in: sections), case var .group(shown) = sections[s].nodes[n] else { continue } guard shown.isCollapsed != fold.collapsed else { @@ -125,6 +133,9 @@ final class SidebarRows { model.setSections(sections) } + /// The live rows are a projection behind the sidebar document (`NewWorkspaceDefaultSlot.layoutCatchingUp`). + var layoutCatchingUp: Bool { NewWorkspaceDefaultSlot.layoutCatchingUp(rowsAt: liveShownAt) } + /// Row content of listed workspaces changed (`SidebarRowFeed`): written in place, or shown again when it moves rows. func patch(_ changed: [SidebarWorkspace]) { live = SidebarSection.patching(changed, in: live) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index 714c31ae675c..38e245522901 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -25,10 +25,47 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { var placement: NewWorkspacePlacement /// The workspace the window showed when the new one was asked for. var current: String? + /// The machine of `current`, when it was known at the press. A cold + /// sidebar can briefly omit that row while its document projection catches + /// up; only an omitted row on this same machine needs to defer placement. + /// Nil when `current` shows in a band of the window's layout (pinned, the + /// top region, a custom section): the list leaves that row out on purpose, + /// so its absence is no stale layout and `afterCurrent` falls back to `top`. + var currentMachine: String? + + init(placement: NewWorkspacePlacement, current: String?, currentMachine: String? = nil) { + self.placement = placement + self.current = current + self.currentMachine = currentMachine + } + + /// The current row is expected in this machine section but is not in the + /// stale layout yet. Returning true keeps Cmd-N pending until the row is + /// realized, instead of resolving the slot against an empty layout. Only + /// while the window's rows are a projection behind the sidebar document + /// (`catchingUp`, `layoutCatchingUp(rowsAt:)`): once they have caught up, a row still missing was + /// left out on purpose (a filter, another space, a close) and the slot + /// falls back as documented. + func awaitsCurrentAnchor(section: SectionID, in sections: [SidebarRowSection], home: String? = nil, catchingUp: Bool) -> Bool { + guard catchingUp, placement == .afterCurrent, let current, let currentMachine, + current != home, + case let .machine(machine) = section, machine.rawValue == currentMachine, + !sections.flatMap(\.workspaces).contains(where: { $0.id.rawValue == current }) else { return false } + return true + } + + /// Whether a window's rows built at `rowsAt` (the document's `shownChanges` then; nil: not built from its + /// projection) are behind the sidebar document: its drawn projection is behind its version, or the window's + /// rows pass has not run since the projection changed (a cold or updated layout a hop before its rows show). + static func layoutCatchingUp(rowsAt: UInt64? = nil) -> Bool { + guard let store = SidebarDocStore.current, store.isActive else { return false } + return store.shown == nil || store.shownVersion != store.version || rowsAt != store.shownChanges + } /// The slot for new workspace `id` in `section` of a window's `sections`; - /// nil keeps the daemon's place. `home` is the home workspace's id. - func slot(for id: String, section: SectionID, in sections: [SidebarRowSection], home: String?) -> WorkspaceSlot? { + /// nil keeps the daemon's place. `home` is the home workspace's id. `catchingUp`: the window's rows are a + /// projection behind (`awaitsCurrentAnchor`). + func slot(for id: String, section: SectionID, in sections: [SidebarRowSection], home: String?, catchingUp: Bool = false) -> WorkspaceSlot? { switch placement { case .bottom: if let current, current != id, current != home, @@ -37,6 +74,7 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { } return nil case .afterCurrent: + if awaitsCurrentAnchor(section: section, in: sections, home: home, catchingUp: catchingUp) { return nil } if let current, current != id, current != home, WorkspaceSlot.below(current).position(moving: [id], section: section, in: sections) != nil { return .below(current) @@ -52,13 +90,13 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { } /// The move the app sends once the daemon reports new workspace `id`, or nil when the daemon's - /// create already put it at ``slot(for:section:in:home:)``: the daemon places every new workspace + /// create already put it at ``slot(for:section:in:home:catchingUp:)``: the daemon places every new workspace /// by `workspaces.newPlacement` in the commit that creates it (first below Home under `top`, /// last under `bottom`), so a second write would be a second state change and a second render /// for the same order (plans/cmux-next/sidebar-ordering-DESIGN.md). Only a slot the daemon does /// not know is moved to: inside the current workspace's group, after the window's current one. - func move(for id: String, section: SectionID, in sections: [SidebarRowSection], home: String?) -> WorkspaceSlot? { - guard let slot = slot(for: id, section: section, in: sections, home: home) else { return nil } + func move(for id: String, section: SectionID, in sections: [SidebarRowSection], home: String?, catchingUp: Bool = false) -> WorkspaceSlot? { + guard let slot = slot(for: id, section: section, in: sections, home: home, catchingUp: catchingUp) else { return nil } if slot == Self.top(for: id, section: section, in: sections, home: home) { return nil } return slot } @@ -102,9 +140,39 @@ enum NewWorkspacePlacements { static func rule(for windowID: String?, in windows: WindowManager) -> NewWorkspaceDefaultSlot { let placement = windows.services.settings?.snapshot.newWorkspacePlacement ?? CmuxConfigSnapshot.newWorkspacePlacementFallback let window = windowID ?? windows.registry.value.mostRecentOpen() - return NewWorkspaceDefaultSlot(placement: placement, current: window.flatMap { windows.states[$0]?.workspaceID }) + let current = window.flatMap { windows.states[$0]?.workspaceID } + let model = window.flatMap { windows.controller(for: $0)?.sidebar.model } + let currentMachine = current.flatMap { id in + model.map { shownInBand(id, layout: $0.layout, room: $0.activeProfileID?.rawValue, + legacy: windows.services.sidebarLayout.unavailableReason != nil, machines: windows.services.machines) } == true + ? nil : windows.services.machines.daemon(forWorkspace: id)?.machineID + } + return NewWorkspaceDefaultSlot(placement: placement, current: current, currentMachine: currentMachine) + } + + /// Whether workspace `id` shows outside the window's workspace list: an item of a section of + /// `layout` that shows in the window's space `room` (pinned, the top region, a custom section), or, while + /// the sidebar layout is unavailable (`legacy`), a pin (`workspace-pin-v1`, the Pinned list section). + static func shownInBand(_ id: String, layout: SidebarLayoutDocument, room: String?, legacy: Bool, machines: MachineRegistry) -> Bool { + // The list's own projection (`SidebarTopProjection`): the pin flag only while pins are not layout tiles. + if legacy { return machines.workspace(id: id)?.0.pinned == true } + guard let ref = WorkspaceLayoutRefs(machines: machines).ref(forWorkspace: id) else { return false } + return layout.sections.contains { $0.isVisible(inRoom: room) && $0.items.contains { $0.ref == ref } } } + /// Whether window `windowID` lists workspace `id` once its rows catch up: no other window owns it and the + /// window's space shows it. A cold Cmd-N waits only for such a row. + static func lists(_ id: String, in windowID: String, windows: WindowManager) -> Bool { + if let owner = windows.registry.value.owner(of: id), owner != windowID { return false } + guard let profile = windows.controller(for: windowID)?.state.profileID else { return true } + let machines = windows.services.machines + return WindowProfiles.shows(id, profile: profile, machines: machines, membership: WindowProfiles.membership(machines)) + } + + /// How long a placement waits for the window's current row (`awaitsCurrentAnchor`) before it resolves by the + /// fallback: a cold layout shows its rows well within it; a row that never comes cannot hold the placement. + static let anchorWaitLimit: Duration = .seconds(3) + /// Places workspace `id` at `slot`, else by `byDefault`, in window /// `windowID` (nil: the window that lists it): now when it is mirrored, /// else once the daemon reports it. `then` runs after, with the sidebar. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift index a98aaee1ea33..fe5050f9e3ba 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift @@ -35,6 +35,8 @@ final class PendingWorkspaceRows { /// Each window's sidebar, redrawn in the turn a row comes or goes: the row draws over the rows the /// sidebar shows already, not after a rows pass walks every workspace on a later hop. let redraws = SidebarRedraws() + /// `WindowManager.applyPendingPlacements` is running (it refreshes a rows pass that retries placements). + @ObservationIgnored var placing = false func add(_ row: Row, name: String) { rows.append(row) @@ -121,16 +123,33 @@ final class PendingWorkspaceRows { /// `sections` with each pending row the window does not list yet, at /// its slot (`NewWorkspaceDefaultSlot`), in press order. `home` is the - /// home workspace's id. - static func adding(_ rows: [Row], to sections: [SidebarRowSection], home: String?) -> [SidebarRowSection] { + /// home workspace's id. `catchingUp`: the window's `sections` are a projection behind the sidebar document + /// (`SidebarRows.layoutCatchingUp`). + static func adding(_ rows: [Row], to sections: [SidebarRowSection], home: String?, catchingUp: Bool) -> [SidebarRowSection] { guard !rows.isEmpty else { return sections } var sections = sections let listed = Set(sections.flatMap(\.workspaces).map(\.id.rawValue)) for row in rows where !listed.contains(row.id) { let section = SectionID.machine(row.machine) - // No slot keeps the daemon's place: the end of the loose rows. - let slot = row.slot ?? row.byDefault?.slot(for: row.id, section: section, in: sections, home: home) ?? .bottom(anchor: nil) - guard let position = slot.position(moving: [row.id], section: section, in: sections) else { continue } + // An explicit slot is ready at the press. A default slot whose + // current anchor is missing belongs to the stale cold layout and + // must wait for that anchor, rather than drawing a bottom frame. + let slot: WorkspaceSlot? + if let explicit = row.slot { + slot = explicit + } else if let byDefault = row.byDefault { + if let resolved = byDefault.slot(for: row.id, section: section, in: sections, home: home, catchingUp: catchingUp) { + slot = resolved + } else if byDefault.awaitsCurrentAnchor(section: section, in: sections, home: home, catchingUp: catchingUp) { + continue + } else { + slot = .bottom(anchor: nil) + } + } else { + // No slot keeps the daemon's place: the end of the loose rows. + slot = .bottom(anchor: nil) + } + guard let slot, let position = slot.position(moving: [row.id], section: section, in: sections) else { continue } SidebarEdits.insert([row.face], at: position, in: §ions) } return sections diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index afbd93468748..4c17f46fd08c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -1,6 +1,7 @@ import CmuxNextActions import CmuxNextBridge import CmuxNextSidebar +import CmuxNextWakeups /// A new workspace's sidebar slot, kept until the daemon reports it. struct PendingPlacement { @@ -14,6 +15,9 @@ struct PendingPlacement { /// The action run that asked for the placement: `then` runs later, /// outside its task, and keeps its view-change permission. var run: ActionRunScope? = ActionRunScope.current + /// When the placement first waited for the window's current row (`awaitsCurrentAnchor`): it waits at most + /// `NewWorkspacePlacements.anchorWaitLimit`, then resolves by the fallback. + var anchorWaitSince: ContinuousClock.Instant? } // Placing new workspaces (New Workspace Above/Below/at Top/Like This, a @@ -24,25 +28,75 @@ struct PendingPlacement { // personal order in the home session, else `move-workspace-to-group` on the // owning daemon. extension WindowManager { + /// Retries the placements a cold or updated layout deferred (`awaitsCurrentAnchor`), once a + /// sidebar rows pass has realized the fresh rows; nothing to do while none waits. + func retryDeferredPlacements() { + guard !pendingPlacements.isEmpty else { return } + applyPendingPlacements(live: Set(pendingPlacements.keys)) + } + + /// One retry once a deferred placement's wait runs out, so it resolves even when no rows pass comes + /// (`anchorRetry`, a one-shot deadline; a pass that finds one still waiting sets the next). + private func retryDeferredPlacements(after delay: Duration) { + anchorRetry.scheduleIfIdle(after: delay) { @MainActor [weak self] in self?.retryDeferredPlacements() } + } + func applyPendingPlacements(live: Set) { + // A placement's rows refresh runs the sidebar's rows pass, whose callback retries deferred + // placements: a nested call returns at once (no re-entry). + let rows = PendingWorkspaceRows.shared + guard !rows.placing else { return } + rows.placing = true + defer { rows.placing = false } + // A placement that waited for its current row was for a workspace the daemon had: once that one is gone (closed, + // its create undone), nothing will place it. + for (id, pending) in pendingPlacements where pending.anchorWaitSince != nil && services.machines.workspace(id: id) == nil { + pendingPlacements[id] = nil + } // A stand-in Cmd-N shows waits: a placement moves a workspace the daemon has. let ready = pendingPlacementOrder.filter { live.contains($0) && services.machines.workspace(id: $0)?.0.isProvisional == false } - let consumed = Set(ready) - pendingPlacementOrder.removeAll { consumed.contains($0) || pendingPlacements[$0] == nil } + // A placement waiting on its current row keeps its entry and its place in the order. + defer { pendingPlacementOrder.removeAll { pendingPlacements[$0] == nil } } var refreshed: Set = [] for id in ready { - guard let pending = pendingPlacements.removeValue(forKey: id) else { continue } - guard let window = pending.window ?? registry.value.owner(of: id), let bridge = controller(for: window)?.sidebar, - let daemon = services.machines.daemon(forWorkspace: id) else { continue } + guard let pending = pendingPlacements[id] else { continue } + // A rows-pass retry can run before membership names the owner: keep the entry for that pass. + guard let window = pending.window ?? registry.value.owner(of: id) else { continue } + // A window the registry still lists whose controller a reconcile has not made yet keeps its entry too. + guard let bridge = controller(for: window)?.sidebar, let daemon = services.machines.daemon(forWorkspace: id) else { + if registry.value.window(window) == nil { pendingPlacements[id] = nil } + continue + } + pendingPlacements[id] = nil // One store-backed rows pass per window; each following placement // reads the model with the preceding optimistic moves applied. let sections = Self.placementSections(in: bridge, refresh: refreshed.insert(window).inserted) let section = SectionID.machine(MachineID(daemon.machineID)) let home = NewWorkspacePlacements.home(services.machines) + let catchingUp = bridge.rows.layoutCatchingUp + // Waits for a current row this window's cold rows omit. One closed or pinned since the press (it shows in + // the band, not the list), or one this window does not list (another window's, another space's), has no + // row to wait for: the rule resolves as if it never had one. So does a wait past its limit. + var byDefault = pending.byDefault + if byDefault?.awaitsCurrentAnchor(section: section, in: sections, home: home, catchingUp: catchingUp) == true, let current = byDefault?.current { + let since = pending.anchorWaitSince ?? .now + if services.machines.workspace(id: current) != nil, + !NewWorkspacePlacements.shownInBand(current, layout: bridge.model.layout, room: bridge.model.activeProfileID?.rawValue, + legacy: services.sidebarLayout.unavailableReason != nil, machines: services.machines), + NewWorkspacePlacements.lists(current, in: window, windows: self), + ContinuousClock.now < since + NewWorkspacePlacements.anchorWaitLimit { + var waiting = pending + waiting.anchorWaitSince = since + pendingPlacements[id] = waiting + retryDeferredPlacements(after: since + NewWorkspacePlacements.anchorWaitLimit - .now) + continue + } + byDefault?.currentMachine = nil + } // This Mac's daemon placed the workspace by the setting in its create: no second write for that // slot. Another machine's create writes no row in this Mac's order, so the app places it. - let slot = pending.slot ?? (daemon.isLocal ? pending.byDefault?.move(for: id, section: section, in: sections, home: home) - : pending.byDefault?.slot(for: id, section: section, in: sections, home: home)) + let slot = pending.slot ?? (daemon.isLocal ? byDefault?.move(for: id, section: section, in: sections, home: home, catchingUp: catchingUp) + : byDefault?.slot(for: id, section: section, in: sections, home: home, catchingUp: catchingUp)) if let position = slot?.position(moving: [id], section: section, in: sections) { bridge.place([SidebarWorkspaceID(id)], at: position, in: sections) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift index 858fa8e02405..a21f09fed53c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift @@ -57,6 +57,8 @@ final class WindowManager { var pendingPlacements: [String: PendingPlacement] = [:] /// Registration order survives a daemon snapshot that reports several creates together. var pendingPlacementOrder: [String] = [] + /// Retries the placements whose wait for the window's current row ran out (`applyPendingPlacements`). + let anchorRetry = DemandTimer(owner: "WindowManager.anchorRetry") /// Windows none of whose workspaces a machine reports yet (just created, /// or saved with workspaces that are gone or on a Cloud machine still /// connecting): kept off screen until their content is installed, so no diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarDocStore.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarDocStore.swift index db5a11a33d78..ddcd00da159d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarDocStore.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarDocStore.swift @@ -31,7 +31,10 @@ public final class SidebarDocStore { /// it. False keeps the older path. public private(set) var isActive = false /// What the sidebar draws from the document (`show(live:)`); nil while inactive. - public internal(set) var shown: SidebarDocProjection? + public internal(set) var shown: SidebarDocProjection? { didSet { shownChanges &+= 1 } } + /// Counts the changes of `shown`. A window records the count its rows were built at: its rows pass runs a hop + /// after `show`, so a caught-up document says nothing about one window's rows. + @ObservationIgnored public private(set) var shownChanges: UInt64 = 0 /// The `version` `shown` was built at: behind `version` while an applied change (a Cmd-N move) waits for the next /// `show`, a hop later. @ObservationIgnored public internal(set) var shownVersion: UInt64 = 0 diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChiefWorkspaceRowsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChiefWorkspaceRowsTests.swift index 7778da74687d..d44a2e949ef0 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChiefWorkspaceRowsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChiefWorkspaceRowsTests.swift @@ -55,7 +55,7 @@ struct ChiefWorkspaceRowsTests { /// The Chief's daemon holding one subagent workspace `name` (`ws_…0011` in session /// `cmux-chief-6b68ac4a`), an agent chat in it when `harness` is set, pinned to the top. - static func pinnedChiefWorkspace(_ name: String, harness: String?) -> (SidebarLayoutDocument, WorkspaceLayoutRefs, LayoutItemRef) { + static func pinnedChiefWorkspace(_ name: String, harness: String?, pinned: Bool = false) -> (SidebarLayoutDocument, WorkspaceLayoutRefs, LayoutItemRef) { _ = NSApplication.shared let machines = MachineRegistry(local: DaemonService()) let session = ServerMachineSession(reach: chief, binary: nil, paths: .init(root: URL(fileURLWithPath: "/private/tmp/cn-chief-top-unused")), @@ -64,7 +64,7 @@ struct ChiefWorkspaceRowsTests { var tab = TabSnapshot(surface: 1, kind: harness == nil ? .pty : .conversation, cwd: nil) tab.conversation = harness.map { ConversationTabRef(agentSession: AgentSessionRef(host: "install:chief-top", session: "s1", harness: $0)) } let screen = ScreenSnapshot(id: 1, layout: .leaf(1), panes: [PaneSnapshot(id: 1, tabs: [tab])]) - let workspace = WorkspaceSnapshot(id: 1, key: nil, resourceID: "ws_00000000000000000000000000000011", name: name, screens: [screen]) + let workspace = WorkspaceSnapshot(id: 1, key: nil, resourceID: "ws_00000000000000000000000000000011", name: name, screens: [screen], pinned: pinned) session.daemon.store.apply(snapshot: DaemonTree(registryID: "cmux-chief-6b68ac4a", workspaces: [workspace])) let ref = LayoutItemRef.workspace("cmux-chief-6b68ac4a:ws_00000000000000000000000000000011") let top = LayoutSection(id: LayoutSectionID("sec_top"), region: .top, items: [LayoutItem(id: LayoutItemID("itm_pin"), ref: ref)]) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift index 2ec9fd0f05a1..e05c3dc89903 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift @@ -70,6 +70,54 @@ struct ColdCmdNRowSlotTests { let anchor = try #require(before.firstIndex(of: current.id), "middle workspace is not drawn: \(before)") try #require(anchor + 1 < before.count, "need a row following the selected middle workspace: \(before)") + // Exercise the pending stand-in directly while the restored sidebar projection is + // empty. The real placement rule carries the current machine from the Cmd-N press; + // the pre-fix path resolved this state to a top/bottom frame. + let machine = try #require(services.machines.daemon(forWorkspace: current.id)?.machineID, + "current workspace daemon is unavailable") + let probeRule = NewWorkspacePlacements.rule(for: window, in: services.windows) + let probe = PendingWorkspaceRows.Row( + id: "cold-probe", + window: window, + machine: MachineID(machine), + face: SidebarMapping.shared.creatingRow(id: "cold-probe", machine: MachineID(machine), name: "cold-probe", opensPage: true), + slot: nil, + byDefault: probeRule + ) + let staleSections = [SidebarRowSection( + kind: .machine(SidebarMachine(id: MachineID(machine), name: "This Mac", kind: .local)), nodes: []) + ] + // The document's drawn projection is a hop behind, as on a cold launch. + let document = try #require(SidebarDocStore.current) + document.shownVersion = document.version &- 1 + let catchingUp = NewWorkspaceDefaultSlot.layoutCatchingUp(rowsAt: document.shownChanges) + #expect(catchingUp) + let coldFrame = PendingWorkspaceRows.adding([probe], to: staleSections, home: nil, catchingUp: catchingUp) + #expect(coldFrame.flatMap(\.workspaces).isEmpty, "a stale cold layout must not draw a top or bottom frame") + let settledSections = [SidebarRowSection( + kind: .machine(SidebarMachine(id: MachineID(machine), name: "This Mac", kind: .local)), + nodes: [.workspace(SidebarWorkspace(id: WorkspaceID(current.id), machineID: MachineID(machine), title: "current"))] + )] + let settledFrame = PendingWorkspaceRows.adding([probe], to: settledSections, home: nil, catchingUp: catchingUp) + #expect(settledFrame.flatMap(\.workspaces).map(\.id.rawValue) == [current.id, "cold-probe"]) + #expect(!probeRule.awaitsCurrentAnchor(section: .machine(MachineID(machine)), in: staleSections, catchingUp: false), + "once the layout caught up, a current row left out on purpose (filter, space, close) uses the normal fallback") + let bottomRule = NewWorkspaceDefaultSlot(placement: .bottom, current: nil) + let bottom = PendingWorkspaceRows.Row( + id: "bottom-probe", window: window, machine: MachineID(machine), + face: SidebarMapping.shared.creatingRow(id: "bottom-probe", machine: MachineID(machine), name: "bottom-probe", opensPage: true), + slot: nil, byDefault: bottomRule) + let bottomFrame = PendingWorkspaceRows.adding([bottom], to: staleSections, home: nil, catchingUp: catchingUp) + #expect(bottomFrame.flatMap(\.workspaces).map(\.id.rawValue) == ["bottom-probe"], + "bottom placement keeps its immediate loose-row stand-in") + + // An updater can leave the first cold layout one projection behind while AppKit has no + // realized row geometry yet. Keep that state across the first Cmd-N so it cannot expose + // the daemon's temporary tail placement as a visible bottom frame. + document.shownVersion = document.version &- 1 + controller.sidebar.model.setSections([]) + controller.sidebar.container.sidebarView.renderNow() + var frames: [[String]] = [] let previousOnApply = list.onApply defer { list.onApply = previousOnApply } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift new file mode 100644 index 000000000000..b00b3f5e0f48 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift @@ -0,0 +1,167 @@ +import AppKit +import CmuxNextBridge +@testable import CmuxNextApp +@testable import CmuxNextDaemon +@testable import CmuxNextSidebar +import Foundation +import Testing + +/// Cold Cmd-N's wait for the current row (`NewWorkspaceDefaultSlot.awaitsCurrentAnchor`) is a property of the +/// window: its rows are built a hop after the document's `show`, so the document being caught up says nothing +/// about them (Sol on #27014). And a current row the window will never list (another window owns it) is no row to +/// wait for: the placement resolves by the documented fallback and its `then` runs. +@MainActor @Suite(.serialized) struct ColdCmdNWindowRowsTests { + private final class Remote: SidebarDocRemote { + var isAvailable: Bool { true } + func get() async throws -> Data { Data(#"{"revision":"1","sections":[],"items":[]}"#.utf8) } + func move(_ items: [String], to target: SidebarDocTarget, key: String) async throws -> SidebarDocReply { reply } + func pin(_ items: [String], key: String) async throws -> SidebarDocReply { reply } + func unpin(_ items: [String], key: String) async throws -> SidebarDocReply { reply } + func adopt(_ refs: [SidebarDocWorkspaceRef], key: String) async throws -> SidebarDocReply { reply } + func apply(ops: Data, key: String) async throws -> SidebarDocReply { reply } + private var reply: SidebarDocReply { SidebarDocReply(revision: 2, inverse: Data("[]".utf8)) } + } + + @MainActor private final class Ran { + var value = false + } + + /// An active document store, made the app's for `body`; `show` false leaves it a projection behind (cold). + static func withDocument(shown: Bool, _ body: (SidebarDocStore) async throws -> Void) async throws { + let previous = SidebarDocStore.current + defer { SidebarDocStore.current = previous } + let store = SidebarDocStore(remote: Remote()) + store.refresh() + await RapidWorkspaceCreateTests.wait(within: .seconds(10)) { store.isActive } + try #require(store.isActive) + if shown { + store.show(live: []) + try #require(store.shown != nil && store.shownVersion == store.version) + } + SidebarDocStore.current = store + try await body(store) + } + + static func probe(window: String, current: String, machine: String) -> PendingWorkspaceRows.Row { + let id = CmuxNextSidebar.MachineID(machine) + return PendingWorkspaceRows.Row( + id: "probe", window: window, machine: id, + face: SidebarMapping.shared.creatingRow(id: "probe", machine: id, name: "probe", opensPage: true), + slot: nil, byDefault: NewWorkspaceDefaultSlot(placement: .afterCurrent, current: current, currentMachine: machine)) + } + + /// The document caught up (`shownVersion == version`), but this window's rows were not built from its + /// projection: they omit the current row. The stand-in waits, rather than drawing at the top or bottom. + @Test func aWindowWhoseRowsLagTheDocumentKeepsTheStandInWaiting() async throws { + _ = NSApplication.shared + try await Self.withDocument(shown: true) { _ in + let model = SidebarModel() + let rows = SidebarRows(model: model) + let stale = [SidebarRowSection(kind: .machine(SidebarMachine(id: CmuxNextSidebar.MachineID("m"), name: "This Mac", kind: .local)), nodes: [])] + rows.show(stale, creating: ([Self.probe(window: "w", current: "current", machine: "m")], nil)) + let drawn = model.sections.flatMap(\.workspaces).map(\.id.rawValue) + #expect(drawn.isEmpty, "rows a projection behind drew the cold stand-in at \(drawn)") + } + } + + /// Rows built from the document's current projection that still omit the current row left it out on purpose: + /// the stand-in draws at the fallback slot. + @Test func rowsBuiltFromTheCurrentProjectionDrawTheStandInAtTheFallback() async throws { + _ = NSApplication.shared + try await Self.withDocument(shown: true) { store in + let model = SidebarModel() + let rows = SidebarRows(model: model) + let stale = [SidebarRowSection(kind: .machine(SidebarMachine(id: CmuxNextSidebar.MachineID("m"), name: "This Mac", kind: .local)), nodes: [])] + let probe = Self.probe(window: "w", current: "current", machine: "m") + rows.show(stale, creating: ([probe], nil)) + #expect(model.sections.flatMap(\.workspaces).isEmpty) + rows.show(stale, shownAt: store.shownChanges, creating: ([probe], nil), unlessShown: true) + #expect(!rows.layoutCatchingUp) + #expect(model.sections.flatMap(\.workspaces).map(\.id.rawValue) == ["probe"]) + } + } + + /// The window's current workspace moved to another window: this one will never list it, so the cold wait + /// resolves at once by the fallback and runs `then`. + @Test func aCurrentWorkspaceAnotherWindowOwnsDoesNotHoldThePlacement() async throws { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let store = services.machines.local.store + let identity = try PlacementStaleRowsTests.identity(mixedOrder: false) + store.noteHandshake(identity) + store.connectionState = .connected(identity) + store.apply(snapshot: PlacementStaleRowsTests.tree(["k1", "k2"], personal: ["k1", "k2"])) + let windows = services.windows + windows.ordersWindowsIn = false + let controller = try #require(windows.openWindow(workspaces: ["k2"])) + defer { controller.teardown(); controller.window?.close() } + windows.registry.apply { $0.openWindow(id: "other", workspaceIDs: ["k1"]) } + controller.sidebar.resync() + let machine = try #require(services.machines.daemon(forWorkspace: "k1")?.machineID) + try await Self.withDocument(shown: false) { _ in + #expect(NewWorkspaceDefaultSlot.layoutCatchingUp()) + let ran = Ran() + NewWorkspacePlacements.expect("k2", in: controller.state.id, byDefault: NewWorkspaceDefaultSlot(placement: .afterCurrent, current: "k1", currentMachine: machine), + then: { _, _ in ran.value = true }, windows: windows) + #expect(windows.pendingPlacements["k2"] == nil, "the placement waits for a row another window lists") + #expect(ran.value) + } + } + + /// A placement waiting for the current row whose new workspace closes (or whose create is undone) meanwhile: + /// its entry goes with it, rather than waiting forever for a workspace the daemon no longer has. + @Test func aWaitingPlacementWhoseWorkspaceClosesIsDropped() async throws { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let store = services.machines.local.store + let identity = try PlacementStaleRowsTests.identity(mixedOrder: false) + store.noteHandshake(identity) + store.connectionState = .connected(identity) + store.apply(snapshot: PlacementStaleRowsTests.tree(["k1", "k2"], personal: ["k1", "k2"])) + let windows = services.windows + windows.ordersWindowsIn = false + let controller = try #require(windows.openWindow(workspaces: ["k2"])) + defer { controller.teardown(); controller.window?.close() } + controller.sidebar.resync() + let machine = try #require(services.machines.daemon(forWorkspace: "k1")?.machineID) + try await Self.withDocument(shown: false) { _ in + let ran = Ran() + NewWorkspacePlacements.expect("k2", in: controller.state.id, byDefault: NewWorkspaceDefaultSlot(placement: .afterCurrent, current: "k1", currentMachine: machine), + then: { _, _ in ran.value = true }, windows: windows) + try #require(windows.pendingPlacements["k2"] != nil, "the placement did not wait") + store.apply(snapshot: PlacementStaleRowsTests.tree(["k1"], personal: ["k1"])) + windows.retryDeferredPlacements() + #expect(windows.pendingPlacements["k2"] == nil, "a placement for closed workspace k2 is kept") + #expect(!windows.pendingPlacementOrder.contains("k2")) + #expect(!ran.value, "then ran for a workspace that is gone") + } + } + + /// A current row the window could list (no window owns it yet) that never comes: the wait ends at its limit, + /// with no rows pass to retry it, and the placement resolves by the fallback. + @Test func aCurrentRowThatNeverComesStopsWaitingAtTheLimit() async throws { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let store = services.machines.local.store + let identity = try PlacementStaleRowsTests.identity(mixedOrder: false) + store.noteHandshake(identity) + store.connectionState = .connected(identity) + store.apply(snapshot: PlacementStaleRowsTests.tree(["k1", "k2"], personal: ["k1", "k2"])) + let windows = services.windows + windows.ordersWindowsIn = false + let controller = try #require(windows.openWindow(workspaces: ["k2"])) + defer { controller.teardown(); controller.window?.close() } + controller.sidebar.resync() + let machine = try #require(services.machines.daemon(forWorkspace: "k1")?.machineID) + try await Self.withDocument(shown: false) { _ in + let ran = Ran() + NewWorkspacePlacements.expect("k2", in: controller.state.id, byDefault: NewWorkspaceDefaultSlot(placement: .afterCurrent, current: "k1", currentMachine: machine), + then: { _, _ in ran.value = true }, windows: windows) + #expect(windows.pendingPlacements["k2"] != nil, "a row the window may still list is waited for") + #expect(!ran.value) + await RapidWorkspaceCreateTests.wait(within: NewWorkspacePlacements.anchorWaitLimit + .seconds(10)) { ran.value } + #expect(ran.value, "the placement waited past its limit") + #expect(windows.pendingPlacements.isEmpty) + } + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewWorkspaceBandRoomTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewWorkspaceBandRoomTests.swift new file mode 100644 index 000000000000..9d2f8ee39667 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewWorkspaceBandRoomTests.swift @@ -0,0 +1,32 @@ +@testable import CmuxNextApp +@testable import CmuxNextSidebar +import Testing + +/// Cold Cmd-N waits for the current row only while the window's list should show it: a workspace +/// pinned in a band of another space still lists in this one, so it does not count as in the band. +@MainActor @Suite struct NewWorkspaceBandRoomTests { + @Test func aBandOfAnotherSpaceDoesNotHideTheCurrentRow() throws { + let (_, refs, ref) = ChiefWorkspaceRowsTests.pinnedChiefWorkspace("band room", harness: nil) + let id = try #require(refs.workspace(for: ref)).0.id + let other = LayoutSection(id: LayoutSectionID("sec_other"), region: .top, room: "space-b", + items: [LayoutItem(id: LayoutItemID("itm_pin"), ref: ref)]) + let layout = SidebarLayoutDocument(sections: [other]) + #expect(NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-b", legacy: false, machines: refs.machines)) + #expect(!NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-a", legacy: false, machines: refs.machines)) + } +} + +extension NewWorkspaceBandRoomTests { + /// The daemon's pin flag decides only while pins are not layout tiles (`SidebarTopProjection.legacy`): + /// with the layout, a pin of another space still lists in this one. + @Test func aPinOfAnotherSpaceDoesNotHideTheCurrentRow() throws { + let (_, refs, ref) = ChiefWorkspaceRowsTests.pinnedChiefWorkspace("band pin", harness: nil, pinned: true) + let id = try #require(refs.workspace(for: ref)).0.id + let other = LayoutSection(id: LayoutSectionID("sec_other"), region: .top, room: "space-b", + items: [LayoutItem(id: LayoutItemID("itm_pin"), ref: ref)]) + let layout = SidebarLayoutDocument(sections: [other]) + #expect(!NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-a", legacy: false, machines: refs.machines)) + #expect(NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-b", legacy: false, machines: refs.machines)) + #expect(NewWorkspacePlacements.shownInBand(id, layout: SidebarLayoutDocument(sections: []), room: "space-a", legacy: true, machines: refs.machines)) + } +}