diff --git a/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift b/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift index c642ffd..c42b859 100644 --- a/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift +++ b/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift @@ -27,7 +27,10 @@ protocol GhosttyTerminalHostView: GhosttyFocusableHost { protocol GhosttyTerminalRuntimeControlling: AnyObject { func attachHost(surfaceId: UUID) func detachHost(surfaceId: UUID) + /// Returns the number of SwiftUI wrapper containers that currently have this surface mounted. + func mountedHostCount(surfaceId: UUID) -> Int func setSurfaceFocus(surfaceId: UUID, focused: Bool) + func setSurfaceOcclusion(surfaceId: UUID, occluded: Bool) func restorePendingFocusIfNeeded(surfaceId: UUID, hostView: any GhosttyFocusableHost) } @@ -73,6 +76,9 @@ struct GhosttyTerminalView: NSViewRepresentable { let surface: SurfaceModel let config: TerminalPanelConfig let isFocused: Bool + /// Whether this surface's workspace is the currently selected one. + /// Non-selected workspaces are occluded so libghostty stops their render loops. + let isWorkspaceSelected: Bool let onActivate: () -> Void let onIdleNotification: () -> Void let onInput: (SurfaceInputEvent) -> Void @@ -109,6 +115,7 @@ struct GhosttyTerminalView: NSViewRepresentable { surface: surface, config: config, isFocused: isFocused, + isWorkspaceSelected: isWorkspaceSelected, onActivate: onActivate, onIdleNotification: onIdleNotification, onInput: onInput, @@ -151,6 +158,7 @@ struct GhosttyTerminalView: NSViewRepresentable { surface: surface, config: config, isFocused: isFocused, + isWorkspaceSelected: isWorkspaceSelected, onActivate: onActivate, onIdleNotification: onIdleNotification, onInput: onInput, @@ -179,6 +187,7 @@ struct GhosttyTerminalView: NSViewRepresentable { surface: SurfaceModel, config: TerminalPanelConfig, isFocused: Bool, + isWorkspaceSelected: Bool, onActivate: @escaping () -> Void, onIdleNotification: @escaping () -> Void, onInput: @escaping (SurfaceInputEvent) -> Void, @@ -191,11 +200,19 @@ struct GhosttyTerminalView: NSViewRepresentable { if container.mountedSurfaceId != surface.id { if let mountedSurfaceId = container.mountedSurfaceId { runtime.detachHost(surfaceId: mountedSurfaceId) + // Only occlude when this was the last remaining mount for the surface. + // In reparent scenarios another container may still show it visibly. + if runtime.mountedHostCount(surfaceId: mountedSurfaceId) == 0 { + runtime.setSurfaceOcclusion(surfaceId: mountedSurfaceId, occluded: true) + } } runtime.attachHost(surfaceId: surface.id) } container.mountHostView(host, surfaceId: surface.id) + // Apply occlusion on every sync so workspace-selection changes take effect + // even when the mounted surface has not changed. + runtime.setSurfaceOcclusion(surfaceId: surface.id, occluded: !isWorkspaceSelected) syncHostView( host, runtime: runtime, @@ -220,6 +237,11 @@ struct GhosttyTerminalView: NSViewRepresentable { ) { guard let surfaceId = container.mountedSurfaceId else { return } runtime.detachHost(surfaceId: surfaceId) + // Only occlude when this was the last remaining mount for the surface. + // In reparent scenarios another container may still show it visibly. + if runtime.mountedHostCount(surfaceId: surfaceId) == 0 { + runtime.setSurfaceOcclusion(surfaceId: surfaceId, occluded: true) + } container.clearMountedSurface() } diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift index 0aa9083..a94bf3e 100644 --- a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift +++ b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift @@ -203,6 +203,7 @@ struct PaneLeafView: View { surface: activeSurface, config: activeSurface.terminalConfig, isFocused: isFocusedPane, + isWorkspaceSelected: manager.window.selectedWorkspaceId == workspaceId, onActivate: { manager.activateSurface(workspaceId: workspaceId, paneId: leaf.id, surfaceId: activeSurface.id) }, diff --git a/Sources/Shellraiser/Infrastructure/Agents/AgentCompletionEventMonitor.swift b/Sources/Shellraiser/Infrastructure/Agents/AgentCompletionEventMonitor.swift index d9ddcdc..e7a036d 100644 --- a/Sources/Shellraiser/Infrastructure/Agents/AgentCompletionEventMonitor.swift +++ b/Sources/Shellraiser/Infrastructure/Agents/AgentCompletionEventMonitor.swift @@ -114,13 +114,19 @@ final class AgentCompletionEventMonitor: AgentActivityEventMonitoring { ? lines.map(String.init) : lines.dropLast().map(String.init) - for line in completeLines where !line.isEmpty { - guard let event = AgentActivityEvent.parse(line) else { continue } - CompletionDebugLogger.log( - "event runtime=\(event.agentType.rawValue) phase=\(event.phase.rawValue) surface=\(event.surfaceId.uuidString)" - ) - Task { @MainActor in - self.onEvent?(event) + let events = completeLines.compactMap { line -> AgentActivityEvent? in + guard !line.isEmpty else { return nil } + return AgentActivityEvent.parse(line) + } + if !events.isEmpty { + Task { @MainActor [weak self] in + guard let self else { return } + for event in events { + CompletionDebugLogger.log( + "event runtime=\(event.agentType.rawValue) phase=\(event.phase.rawValue) surface=\(event.surfaceId.uuidString)" + ) + onEvent?(event) + } } } diff --git a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift index 8de057d..9467218 100644 --- a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift +++ b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift @@ -437,8 +437,10 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { [ "$latest_timestamp" = "$candidate_timestamp" ] } - while :; do + iteration=0 + while [ "$iteration" -lt 300 ]; do [ -f "$stamp_file" ] || exit 0 + iteration=$((iteration + 1)) while IFS= read -r session_file; do [ -f "$session_file" ] || continue diff --git a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift index 7babe79..d8e3954 100644 --- a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -200,6 +200,11 @@ final class GhosttyRuntime { mountedHostCountsBySurfaceId[surfaceId, default: 0] += 1 } + /// Returns the current number of SwiftUI wrapper containers that have this surface mounted. + func mountedHostCount(surfaceId: UUID) -> Int { + mountedHostCountsBySurfaceId[surfaceId, default: 0] + } + /// Marks a host view as detached and schedules delayed cleanup. func detachHost(surfaceId: UUID) { let current = mountedHostCountsBySurfaceId[surfaceId, default: 0] @@ -343,6 +348,15 @@ final class GhosttyRuntime { ghostty_surface_set_focus(surface, focused) } + /// Notifies libghostty whether a surface is occluded (not visible). + /// + /// `ghostty_surface_set_occlusion` takes `true` when the surface IS visible, + /// so we invert the `occluded` flag. + func setSurfaceOcclusion(surfaceId: UUID, occluded: Bool) { + guard let surface = surfaceHandlesById[surfaceId] else { return } + ghostty_surface_set_occlusion(surface, !occluded) + } + /// Moves first-responder focus to the host view that owns a surface id. func focusSurfaceHost(surfaceId: UUID) { pendingFocusedSurfaceId = surfaceId diff --git a/Sources/Shellraiser/Services/Persistence/WorkspacePersistence.swift b/Sources/Shellraiser/Services/Persistence/WorkspacePersistence.swift index 571f031..1ce242e 100644 --- a/Sources/Shellraiser/Services/Persistence/WorkspacePersistence.swift +++ b/Sources/Shellraiser/Services/Persistence/WorkspacePersistence.swift @@ -23,6 +23,11 @@ final class WorkspacePersistence: WorkspacePersisting { private let fileManager = FileManager.default private let logsErrors: Bool private let workspaceFileURL: URL + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() /// Returns the directory containing the persisted workspace file. var directoryURL: URL { @@ -146,10 +151,7 @@ final class WorkspacePersistence: WorkspacePersisting { withIntermediateDirectories: true ) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(workspaces) - try data.write(to: workspaceFileURL, options: .atomic) } catch { if logsErrors { @@ -185,20 +187,20 @@ final class CoalescingWorkspacePersistence: WorkspacePersisting { /// Stores the latest snapshot and resets the debounce timer. func save(_ workspaces: [WorkspaceModel]) { - coordinationQueue.sync { - pendingWorkspaces = workspaces - saveWorkItem?.cancel() + coordinationQueue.async { + self.pendingWorkspaces = workspaces + self.saveWorkItem?.cancel() - guard debounceInterval > 0 else { - persistPendingWorkspaces() + guard self.debounceInterval > 0 else { + self.persistPendingWorkspaces() return } let workItem = DispatchWorkItem { [weak self] in self?.persistPendingWorkspaces() } - saveWorkItem = workItem - coordinationQueue.asyncAfter(deadline: .now() + debounceInterval, execute: workItem) + self.saveWorkItem = workItem + self.coordinationQueue.asyncAfter(deadline: .now() + self.debounceInterval, execute: workItem) } } diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+GitBranches.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+GitBranches.swift index 03f089a..1bb22b1 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+GitBranches.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+GitBranches.swift @@ -29,27 +29,41 @@ extension WorkspaceManager { } /// Refreshes the resolved Git state for a surface working directory. + /// + /// Cancels any in-flight task for the same surface before spawning a replacement. @discardableResult func refreshGitBranch(workspaceId: UUID, surfaceId: UUID, workingDirectory: String) -> Task { + gitBranchTasks[surfaceId]?.cancel() + let requestedWorkingDirectory = workingDirectory let gitStateResolver = self.gitStateResolver - return Task.detached(priority: .utility) { + let task = Task.detached(priority: .utility) { [weak self] in let gitState = gitStateResolver(requestedWorkingDirectory) - await MainActor.run { - guard let workspace = self.workspace(id: workspaceId), - let surface = self.surface(in: workspace.rootPane, surfaceId: surfaceId), + guard !Task.isCancelled else { return } + await MainActor.run { [weak self] in + guard let self else { return } + // Re-check after the actor hop: a replacement task may have cancelled + // this one between the pre-hop check and the write below. + guard !Task.isCancelled else { return } + guard let workspace = workspace(id: workspaceId), + let surface = surface(in: workspace.rootPane, surfaceId: surfaceId), surface.terminalConfig.workingDirectory == requestedWorkingDirectory else { return } - self.gitStatesBySurfaceId[surfaceId] = gitState + gitStatesBySurfaceId[surfaceId] = gitState } } + + gitBranchTasks[surfaceId] = task + return task } /// Removes cached Git state for a surface that is no longer present. func clearGitBranch(surfaceId: UUID) { + gitBranchTasks[surfaceId]?.cancel() + gitBranchTasks.removeValue(forKey: surfaceId) gitStatesBySurfaceId.removeValue(forKey: surfaceId) } diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift index 7f60c2a..77be51e 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift @@ -70,6 +70,7 @@ extension WorkspaceManager { completionNotifications.removeNotifications(for: $0) GhosttyRuntime.shared.releaseSurface(surfaceId: $0) clearGitBranch(surfaceId: $0) + clearProgressReport(surfaceId: $0) } updateDockBadge() } diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift index 9baf967..aa9287d 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift @@ -79,6 +79,7 @@ final class WorkspaceManager: ObservableObject { var progressClearTimers: [UUID: Timer] = [:] /// Monotonically-increasing generation counter per surface; used to detect stale timer callbacks. var progressTimerGeneration: [UUID: Int] = [:] + var gitBranchTasks: [UUID: Task] = [:] let persistence: any WorkspacePersisting let workspaceCatalog: WorkspaceCatalogManager diff --git a/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift b/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift index 89fe5b7..b1fe7d2 100644 --- a/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift +++ b/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift @@ -119,9 +119,9 @@ final class AgentRuntimeBridgeTests: XCTestCase { XCTAssertFalse(codexWrapperContents.contains("surface_matches_current_codex_session")) XCTAssertTrue(codexWrapperContents.contains("normalize_codex_session_timestamp")) XCTAssertTrue(codexWrapperContents.contains("timestamp_is_at_or_after")) - XCTAssertTrue(codexWrapperContents.contains("while :; do")) - XCTAssertFalse(codexWrapperContents.contains("while [ \"$attempts\" -lt 40 ]; do")) - XCTAssertFalse(codexWrapperContents.contains("attempts=$((attempts + 1))")) + XCTAssertTrue(codexWrapperContents.contains("while [ \"$iteration\" -lt 300 ]; do")) + XCTAssertTrue(codexWrapperContents.contains("iteration=$((iteration + 1))")) + XCTAssertFalse(codexWrapperContents.contains("while :; do")) XCTAssertTrue(codexWrapperContents.contains("printf '%-9.9s'")) XCTAssertTrue(codexWrapperContents.contains("monitor_pid=\"$!\"")) XCTAssertTrue(codexWrapperContents.contains("rm -f \"$stamp_file\"")) diff --git a/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift b/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift index d57dab7..3b66563 100644 --- a/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift +++ b/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift @@ -93,6 +93,7 @@ final class GhosttyTerminalViewTests: XCTestCase { surface: surface, config: config, isFocused: true, + isWorkspaceSelected: true, onActivate: {}, onIdleNotification: {}, onInput: { _ in }, @@ -109,6 +110,7 @@ final class GhosttyTerminalViewTests: XCTestCase { surface: surface, config: config, isFocused: true, + isWorkspaceSelected: true, onActivate: {}, onIdleNotification: {}, onInput: { _ in }, @@ -126,6 +128,11 @@ final class GhosttyTerminalViewTests: XCTestCase { XCTAssertEqual(secondContainer.mountedSurfaceId, surface.id) XCTAssertEqual(runtime.attachHostSurfaceIds, [surface.id, surface.id]) XCTAssertEqual(runtime.detachHostSurfaceIds, []) + XCTAssertEqual( + runtime.setSurfaceOcclusionCalls.map(\.occluded), + [false, false], + "selected workspace surfaces must be un-occluded on each sync" + ) } /// Verifies a wrapper swaps in the current cached host when the surface stays the same. @@ -148,6 +155,7 @@ final class GhosttyTerminalViewTests: XCTestCase { surface: surface, config: config, isFocused: false, + isWorkspaceSelected: true, onActivate: {}, onIdleNotification: {}, onInput: { _ in }, @@ -164,6 +172,7 @@ final class GhosttyTerminalViewTests: XCTestCase { surface: surface, config: config, isFocused: true, + isWorkspaceSelected: true, onActivate: {}, onIdleNotification: {}, onInput: { _ in }, @@ -201,6 +210,7 @@ final class GhosttyTerminalViewTests: XCTestCase { surface: surface, config: config, isFocused: false, + isWorkspaceSelected: false, onActivate: {}, onIdleNotification: {}, onInput: { _ in }, @@ -215,6 +225,109 @@ final class GhosttyTerminalViewTests: XCTestCase { XCTAssertNil(container.mountedSurfaceId) XCTAssertEqual(runtime.attachHostSurfaceIds, [surface.id]) XCTAssertEqual(runtime.detachHostSurfaceIds, [surface.id]) + XCTAssertEqual( + runtime.setSurfaceOcclusionCalls.map(\.occluded), + [true, true], + "non-selected workspace surface must be occluded on sync and again on dismantle" + ) + } + + /// Verifies that dismantling one of two active mounts for the same surface does NOT occlude it, + /// because the second mount still shows the surface visibly. + func testDismantleContainerViewDoesNotOccludeSurfaceWhenAnotherMountRemains() { + let runtime = MockGhosttyTerminalRuntime() + let firstContainer = GhosttyTerminalContainerView(frame: .zero) + let secondContainer = GhosttyTerminalContainerView(frame: .zero) + let host = MockGhosttyTerminalHostView() + let surface = SurfaceModel.makeDefault() + let config = TerminalPanelConfig( + workingDirectory: "/tmp", + shell: "/bin/zsh", + environment: [:] + ) + + let sync: (GhosttyTerminalContainerView) -> Void = { container in + GhosttyTerminalView.syncContainerView( + container, + host: host, + runtime: runtime, + surface: surface, + config: config, + isFocused: false, + isWorkspaceSelected: true, + onActivate: {}, + onIdleNotification: {}, + onInput: { _ in }, + onTitleChange: { _ in }, + onWorkingDirectoryChange: { _ in }, + onChildExited: {}, + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } + ) + } + + // Mount surface in two containers (simulates reparent in progress). + sync(firstContainer) + sync(secondContainer) + + // Both attaches recorded; no detaches yet. + XCTAssertEqual(runtime.attachHostSurfaceIds, [surface.id, surface.id]) + XCTAssertEqual(runtime.detachHostSurfaceIds, []) + + // Dismantle one mount — second mount still active. + GhosttyTerminalView.dismantleContainerView(firstContainer, runtime: runtime) + + XCTAssertNil(firstContainer.mountedSurfaceId) + XCTAssertEqual(runtime.detachHostSurfaceIds, [surface.id]) + XCTAssertFalse( + runtime.setSurfaceOcclusionCalls.contains(where: { $0.surfaceId == surface.id && $0.occluded }), + "surface must NOT be occluded while a second mount still shows it" + ) + } + + /// Verifies occlusion is re-applied reactively when workspace selection changes + /// without the mounted surface changing. + func testSyncContainerViewReappliesOcclusionOnWorkspaceSelectionChange() { + let runtime = MockGhosttyTerminalRuntime() + let container = GhosttyTerminalContainerView(frame: .zero) + let host = MockGhosttyTerminalHostView() + let surface = SurfaceModel.makeDefault() + let config = TerminalPanelConfig( + workingDirectory: "/tmp", + shell: "/bin/zsh", + environment: [:] + ) + + let sync: (Bool) -> Void = { isSelected in + GhosttyTerminalView.syncContainerView( + container, + host: host, + runtime: runtime, + surface: surface, + config: config, + isFocused: false, + isWorkspaceSelected: isSelected, + onActivate: {}, + onIdleNotification: {}, + onInput: { _ in }, + onTitleChange: { _ in }, + onWorkingDirectoryChange: { _ in }, + onChildExited: {}, + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } + ) + } + + sync(true) // workspace selected → visible + sync(false) // workspace deselected → occlude + sync(true) // workspace reselected → visible again + + XCTAssertEqual(runtime.attachHostSurfaceIds, [surface.id], "attach fires only on first mount") + XCTAssertEqual( + runtime.setSurfaceOcclusionCalls.map(\.occluded), + [false, true, false], + "occlusion must track workspace selection on every sync" + ) } } @@ -266,6 +379,7 @@ private final class MockGhosttyTerminalRuntime: GhosttyTerminalRuntimeControllin private(set) var attachHostSurfaceIds: [UUID] = [] private(set) var detachHostSurfaceIds: [UUID] = [] private(set) var setSurfaceFocusCalls: [(surfaceId: UUID, focused: Bool)] = [] + private(set) var setSurfaceOcclusionCalls: [(surfaceId: UUID, occluded: Bool)] = [] private(set) var restorePendingFocusSurfaceIds: [UUID] = [] private(set) var restoredHosts: [AnyObject] = [] @@ -279,11 +393,23 @@ private final class MockGhosttyTerminalRuntime: GhosttyTerminalRuntimeControllin detachHostSurfaceIds.append(surfaceId) } + /// Returns the simulated remaining mount count for a surface (attaches minus detaches). + func mountedHostCount(surfaceId: UUID) -> Int { + let attached = attachHostSurfaceIds.filter { $0 == surfaceId }.count + let detached = detachHostSurfaceIds.filter { $0 == surfaceId }.count + return max(0, attached - detached) + } + /// Records direct focus-state updates for a surface. func setSurfaceFocus(surfaceId: UUID, focused: Bool) { setSurfaceFocusCalls.append((surfaceId: surfaceId, focused: focused)) } + /// Records occlusion state changes for a surface. + func setSurfaceOcclusion(surfaceId: UUID, occluded: Bool) { + setSurfaceOcclusionCalls.append((surfaceId: surfaceId, occluded: occluded)) + } + /// Records pending-focus restore attempts for a host view. func restorePendingFocusIfNeeded(surfaceId: UUID, hostView: any GhosttyFocusableHost) { restorePendingFocusSurfaceIds.append(surfaceId)