From 28b1ab6630d54b5f240afafff35b04a4b01b232e Mon Sep 17 00:00:00 2001 From: Morten Trydal Date: Sun, 15 Mar 2026 19:58:26 +0100 Subject: [PATCH 1/4] Add OSC 9;4 progress reporting (pane bar + tab indicator) Receives GHOSTTY_ACTION_PROGRESS_REPORT from libghostty and renders a 2px progress bar at the top of the active terminal surface. Tab dots reflect the progress state (accent/red/orange). State is auto-cleared after 15 seconds of inactivity, matching Ghostty's own behavior. --- Shellraiser.xcodeproj/project.pbxproj | 8 ++ .../Terminal/GhosttyTerminalView.swift | 28 +++-- .../WorkspaceDetail/PaneLeafView.swift | 18 +++ .../WorkspaceDetail/SurfaceProgressBar.swift | 105 ++++++++++++++++++ .../WorkspaceDetail/SurfaceTabButton.swift | 15 ++- .../Ghostty/GhosttyRuntime.swift | 49 +++++++- .../Ghostty/LibghosttySurfaceView.swift | 15 ++- .../Models/SurfaceProgressReport.swift | 15 +++ .../WorkspaceManager+SurfaceOperations.swift | 33 ++++++ .../Workspaces/WorkspaceManager.swift | 2 + .../GhosttyTerminalViewTests.swift | 22 ++-- 11 files changed, 283 insertions(+), 27 deletions(-) create mode 100644 Sources/Shellraiser/Features/WorkspaceDetail/SurfaceProgressBar.swift create mode 100644 Sources/Shellraiser/Models/SurfaceProgressReport.swift diff --git a/Shellraiser.xcodeproj/project.pbxproj b/Shellraiser.xcodeproj/project.pbxproj index f8bfd97..09cfcfc 100644 --- a/Shellraiser.xcodeproj/project.pbxproj +++ b/Shellraiser.xcodeproj/project.pbxproj @@ -57,6 +57,8 @@ A1000050 /* GitBranchResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000050; }; A1000051 /* WorkspaceManager+GitBranches.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000051; }; A1000052 /* SurfaceInputEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000052; }; + A1000053 /* SurfaceProgressReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000053; }; + A1000054 /* SurfaceProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000054; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -110,6 +112,8 @@ A2000050 /* GitBranchResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Infrastructure/Git/GitBranchResolver.swift; sourceTree = ""; }; A2000051 /* WorkspaceManager+GitBranches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Services/Workspaces/WorkspaceManager+GitBranches.swift"; sourceTree = ""; }; A2000052 /* SurfaceInputEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SurfaceInputEvent.swift; sourceTree = ""; }; + A2000053 /* SurfaceProgressReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SurfaceProgressReport.swift; sourceTree = ""; }; + A2000054 /* SurfaceProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Features/WorkspaceDetail/SurfaceProgressBar.swift; sourceTree = ""; }; A3000001 /* Shellraiser.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Shellraiser.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -147,6 +151,7 @@ A2000018 /* TerminalPanelConfig.swift */, A2000019 /* SurfaceModel.swift */, A2000052 /* SurfaceInputEvent.swift */, + A2000053 /* SurfaceProgressReport.swift */, A2000020 /* PaneNodeModel.swift */, A2000041 /* PaneNodeModel+Operations.swift */, A2000021 /* WorkspaceModel.swift */, @@ -171,6 +176,7 @@ A2000038 /* PaneSplitView.swift */, A2000039 /* PaneLeafView.swift */, A2000040 /* SurfaceTabButton.swift */, + A2000054 /* SurfaceProgressBar.swift */, A2000008 /* GhosttyTerminalView.swift */, A2000012 /* LibghosttySurfaceView.swift */, A2000013 /* GhosttyRuntime.swift */, @@ -285,6 +291,8 @@ A1000018 /* TerminalPanelConfig.swift in Sources */, A1000019 /* SurfaceModel.swift in Sources */, A1000052 /* SurfaceInputEvent.swift in Sources */, + A1000053 /* SurfaceProgressReport.swift in Sources */, + A1000054 /* SurfaceProgressBar.swift in Sources */, A1000020 /* PaneNodeModel.swift in Sources */, A1000041 /* PaneNodeModel+Operations.swift in Sources */, A1000021 /* WorkspaceModel.swift in Sources */, diff --git a/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift b/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift index 8112f03..0a56749 100644 --- a/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift +++ b/Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift @@ -17,7 +17,8 @@ protocol GhosttyTerminalHostView: GhosttyFocusableHost { onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) } @@ -79,6 +80,7 @@ struct GhosttyTerminalView: NSViewRepresentable { let onWorkingDirectoryChange: (String) -> Void let onChildExited: () -> Void let onPaneNavigationRequest: (PaneNodeModel.PaneFocusDirection) -> Void + let onProgressReport: (SurfaceProgressReport?) -> Void /// Builds the AppKit surface host. func makeNSView(context: Context) -> NSView { @@ -93,7 +95,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) Self.syncContainerView( containerView, @@ -108,7 +111,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) return containerView #else @@ -131,7 +135,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) Self.syncContainerView( container, @@ -146,7 +151,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) #endif } @@ -173,7 +179,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { if container.mountedSurfaceId != surface.id { if let mountedSurfaceId = container.mountedSurfaceId { @@ -195,7 +202,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) } @@ -222,7 +230,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { host.update( surfaceModel: surface, @@ -233,7 +242,8 @@ struct GhosttyTerminalView: NSViewRepresentable { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) runtime.setSurfaceFocus(surfaceId: surface.id, focused: isFocused) runtime.restorePendingFocusIfNeeded(surfaceId: surface.id, hostView: host) diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift index 654213c..74fafd8 100644 --- a/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift +++ b/Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift @@ -228,6 +228,13 @@ struct PaneLeafView: View { }, onPaneNavigationRequest: { direction in manager.focusAdjacentPane(from: activeSurface.id, direction: direction) + }, + onProgressReport: { report in + manager.setProgressReport( + workspaceId: workspaceId, + surfaceId: activeSurface.id, + report: report + ) } ) .id(activeSurface.id) @@ -248,6 +255,17 @@ struct PaneLeafView: View { .padding(8) .allowsHitTesting(false) } + + if let activeSurface, + let report = manager.progressBySurfaceId[activeSurface.id] { + VStack { + SurfaceProgressBar(report: report) + .padding(.horizontal, 8) + .padding(.top, 6) + Spacer() + } + .allowsHitTesting(false) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceProgressBar.swift b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceProgressBar.swift new file mode 100644 index 0000000..5b4a5a5 --- /dev/null +++ b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceProgressBar.swift @@ -0,0 +1,105 @@ +import SwiftUI + +/// 2px progress bar rendered at the top of a terminal surface pane. +/// +/// Renders a determinate fill for known percentages and a bouncing animation +/// for indeterminate states. Color reflects the report state: red for error, +/// orange for pause, accent for all others. +struct SurfaceProgressBar: View { + let report: SurfaceProgressReport + + private var color: Color { + switch report.state { + case .error: return .red + case .pause: return .orange + default: return .accentColor + } + } + + /// Resolved percentage value, treating pause-without-progress as 100%. + private var progress: UInt8? { + if let v = report.progress { return v } + if report.state == .pause { return 100 } + return nil + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + if let progress { + Rectangle() + .fill(color) + .frame( + width: geometry.size.width * CGFloat(progress) / 100, + height: geometry.size.height + ) + .animation(.easeInOut(duration: 0.2), value: progress) + } else { + BouncingProgressBar(color: color) + } + } + } + .frame(height: 2) + .clipped() + .allowsHitTesting(false) + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(.updatesFrequently) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(accessibilityValue) + } + + private var accessibilityLabel: String { + switch report.state { + case .error: return "Terminal progress - Error" + case .pause: return "Terminal progress - Paused" + case .indeterminate: return "Terminal progress - In progress" + default: return "Terminal progress" + } + } + + private var accessibilityValue: String { + if let progress { return "\(progress) percent complete" } + switch report.state { + case .error: return "Operation failed" + case .pause: return "Operation paused at completion" + case .indeterminate: return "Operation in progress" + default: return "Indeterminate progress" + } + } +} + +/// Bouncing animated bar used for indeterminate progress states. +private struct BouncingProgressBar: View { + let color: Color + @State private var position: CGFloat = 0 + + private let barWidthRatio: CGFloat = 0.25 + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Rectangle() + .fill(color.opacity(0.3)) + + Rectangle() + .fill(color) + .frame( + width: geometry.size.width * barWidthRatio, + height: geometry.size.height + ) + .offset(x: position * (geometry.size.width * (1 - barWidthRatio))) + } + } + .onAppear { + withAnimation( + .easeInOut(duration: 1.2) + .repeatForever(autoreverses: true) + ) { + position = 1 + } + } + .onDisappear { + position = 0 + } + } +} diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift index 30d2045..debc73b 100644 --- a/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift +++ b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift @@ -26,12 +26,25 @@ struct SurfaceTabButton: View { Color(nsColor: chromeStyle.foregroundColor).opacity(isSelected ? 0.76 : 0.55) } + /// Color for the tab status dot derived from progress state, completion, or default. + private var dotFill: AnyShapeStyle { + if let report = manager.progressBySurfaceId[surface.id] { + switch report.state { + case .error: return AnyShapeStyle(Color.red) + case .pause: return AnyShapeStyle(Color.orange) + default: return AnyShapeStyle(Color.accentColor) + } + } + if surface.hasPendingCompletion { return AnyShapeStyle(AppTheme.accentGradient) } + return AnyShapeStyle(Color.white.opacity(0.2)) + } + var body: some View { HStack(spacing: 6) { Button(action: onSelect) { HStack(spacing: 8) { Circle() - .fill(surface.hasPendingCompletion ? AnyShapeStyle(AppTheme.accentGradient) : AnyShapeStyle(Color.white.opacity(0.2))) + .fill(dotFill) .frame(width: 6, height: 6) Text(surface.title) diff --git a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift index d84f1fa..bfa8ade 100644 --- a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -24,6 +24,8 @@ final class GhosttyRuntime { case title(String) case workingDirectory(String) case childExited + /// OSC 9;4 progress report; nil means the remove state. + case progressReport(SurfaceProgressReport?) } struct SurfaceCallbacks { @@ -31,6 +33,7 @@ final class GhosttyRuntime { let onTitleChange: (String) -> Void let onWorkingDirectoryChange: (String) -> Void let onChildExited: () -> Void + let onProgressReport: (SurfaceProgressReport?) -> Void } /// Pasteboard targets supported by the embedded host. @@ -97,13 +100,15 @@ final class GhosttyRuntime { onIdleNotification: @escaping () -> Void, onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, - onChildExited: @escaping () -> Void + onChildExited: @escaping () -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { callbacksBySurfaceId[surfaceId] = SurfaceCallbacks( onIdleNotification: onIdleNotification, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, - onChildExited: onChildExited + onChildExited: onChildExited, + onProgressReport: onProgressReport ) } @@ -117,14 +122,16 @@ final class GhosttyRuntime { onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) -> LibghosttySurfaceView { registerSurfaceCallbacks( surfaceId: surfaceModel.id, onIdleNotification: onIdleNotification, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, - onChildExited: onChildExited + onChildExited: onChildExited, + onProgressReport: onProgressReport ) releasedSurfaceIds.remove(surfaceModel.id) @@ -138,7 +145,8 @@ final class GhosttyRuntime { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) return existing } @@ -152,7 +160,8 @@ final class GhosttyRuntime { onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest + onPaneNavigationRequest: onPaneNavigationRequest, + onProgressReport: onProgressReport ) hostViewsBySurfaceId[surfaceModel.id] = created mountedHostCountsBySurfaceId[surfaceModel.id] = 0 @@ -803,6 +812,32 @@ final class GhosttyRuntime { runtime.notifyAppearanceDidChange() } return true + case GHOSTTY_ACTION_PROGRESS_REPORT: + guard target.tag == GHOSTTY_TARGET_SURFACE, + let surface = target.target.surface else { return false } + let surfaceKey = UInt(bitPattern: surface) + let raw = action.action.progress_report + let report: SurfaceProgressReport? = { + switch raw.state { + case GHOSTTY_PROGRESS_STATE_REMOVE: + return nil + case GHOSTTY_PROGRESS_STATE_SET: + return SurfaceProgressReport(state: .set, progress: raw.progress >= 0 ? UInt8(raw.progress) : nil) + case GHOSTTY_PROGRESS_STATE_ERROR: + return SurfaceProgressReport(state: .error, progress: raw.progress >= 0 ? UInt8(raw.progress) : nil) + case GHOSTTY_PROGRESS_STATE_INDETERMINATE: + return SurfaceProgressReport(state: .indeterminate, progress: raw.progress >= 0 ? UInt8(raw.progress) : nil) + case GHOSTTY_PROGRESS_STATE_PAUSE: + return SurfaceProgressReport(state: .pause, progress: raw.progress >= 0 ? UInt8(raw.progress) : nil) + default: + return nil + } + }() + let capturedEvent: SurfaceActionEvent = .progressReport(report) + DispatchQueue.main.async { + runtime.handleAction(surfaceKey: surfaceKey, event: capturedEvent) + } + return true default: return true } @@ -983,6 +1018,8 @@ final class GhosttyRuntime { callbacks.onWorkingDirectoryChange(workingDirectory) case .childExited: callbacks.onChildExited() + case .progressReport(let report): + callbacks.onProgressReport(report) } } diff --git a/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift b/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift index 227cccd..1531bc7 100644 --- a/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift +++ b/Sources/Shellraiser/Infrastructure/Ghostty/LibghosttySurfaceView.swift @@ -16,6 +16,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati private var onWorkingDirectoryChange: (String) -> Void private var onChildExited: () -> Void private var onPaneNavigationRequest: (PaneNodeModel.PaneFocusDirection) -> Void + private var onProgressReport: (SurfaceProgressReport?) -> Void private var markedText = NSMutableAttributedString() private var keyTextAccumulator: [String]? private var didInterpretCommand = false @@ -36,7 +37,8 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { self.surfaceModel = surfaceModel self.terminalConfig = terminalConfig @@ -47,6 +49,7 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati self.onWorkingDirectoryChange = onWorkingDirectoryChange self.onChildExited = onChildExited self.onPaneNavigationRequest = onPaneNavigationRequest + self.onProgressReport = onProgressReport super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) wantsLayer = true @@ -61,7 +64,8 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati onIdleNotification: onIdleNotification, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, - onChildExited: onChildExited + onChildExited: onChildExited, + onProgressReport: onProgressReport ) surfaceHandle = GhosttyRuntime.shared.createSurface( for: self, @@ -430,7 +434,8 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { self.surfaceModel = surfaceModel self.terminalConfig = terminalConfig @@ -441,13 +446,15 @@ final class LibghosttySurfaceView: NSView, NSTextInputClient, NSMenuItemValidati self.onWorkingDirectoryChange = onWorkingDirectoryChange self.onChildExited = onChildExited self.onPaneNavigationRequest = onPaneNavigationRequest + self.onProgressReport = onProgressReport GhosttyRuntime.shared.registerSurfaceCallbacks( surfaceId: surfaceModel.id, onIdleNotification: onIdleNotification, onTitleChange: onTitleChange, onWorkingDirectoryChange: onWorkingDirectoryChange, - onChildExited: onChildExited + onChildExited: onChildExited, + onProgressReport: onProgressReport ) applyGhosttyBackgroundStyle() updateScaleAndSize() diff --git a/Sources/Shellraiser/Models/SurfaceProgressReport.swift b/Sources/Shellraiser/Models/SurfaceProgressReport.swift new file mode 100644 index 0000000..500a767 --- /dev/null +++ b/Sources/Shellraiser/Models/SurfaceProgressReport.swift @@ -0,0 +1,15 @@ +/// Progress state reported by a terminal surface via OSC 9;4. +enum SurfaceProgressState: Equatable { + case set + case error + case indeterminate + case pause +} + +/// Progress report delivered by a terminal surface via OSC 9;4. +struct SurfaceProgressReport: Equatable { + /// Current progress state. + let state: SurfaceProgressState + /// Percentage completion (0–100), or nil when not quantified. + let progress: UInt8? +} diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift index ae37020..a17704f 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift @@ -42,6 +42,7 @@ extension WorkspaceManager { clearBusySurface(surfaceId) clearLiveCodexSessionSurface(surfaceId) clearGitBranch(surfaceId: surfaceId) + clearProgressReport(surfaceId: surfaceId) if let workspace = workspace(id: workspaceId), let focusedSurfaceId = workspace.focusedSurfaceId ?? workspace.rootPane.firstActiveSurfaceId(), @@ -144,6 +145,38 @@ extension WorkspaceManager { markSurfaceBusy(surfaceId) } + /// Stores or removes an OSC 9;4 progress report for a surface, resetting the 15-second auto-clear timer. + func setProgressReport(workspaceId: UUID, surfaceId: UUID, report: SurfaceProgressReport?) { + if let report { + progressBySurfaceId[surfaceId] = report + progressClearTimers[surfaceId]?.invalidate() + progressClearTimers[surfaceId] = Timer.scheduledTimer(withTimeInterval: 15, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + self?.progressBySurfaceId.removeValue(forKey: surfaceId) + self?.progressClearTimers.removeValue(forKey: surfaceId) + } + } + } else { + clearProgressReport(surfaceId: surfaceId) + } + } + + /// Returns the progress report for the focused surface of a workspace, if any. + func focusedSurfaceProgress(workspaceId: UUID) -> SurfaceProgressReport? { + guard let workspace = workspace(id: workspaceId), + let focusedSurfaceId = workspace.focusedSurfaceId else { + return nil + } + return progressBySurfaceId[focusedSurfaceId] + } + + /// Removes any stored progress state and cancels the auto-clear timer for a surface. + func clearProgressReport(surfaceId: UUID) { + progressBySurfaceId.removeValue(forKey: surfaceId) + progressClearTimers[surfaceId]?.invalidate() + progressClearTimers.removeValue(forKey: surfaceId) + } + /// Updates tab title using the current terminal title. func setSurfaceTitle(workspaceId: UUID, surfaceId: UUID, title: String) { surfaceManager.setSurfaceTitle( diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift index 8fb65b9..4862445 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift @@ -74,7 +74,9 @@ final class WorkspaceManager: ObservableObject { @Published var pendingWorkspaceRename: WorkspaceRenameRequest? @Published var gitStatesBySurfaceId: [UUID: ResolvedGitState] = [:] @Published var busySurfaceIds: Set = [] + @Published var progressBySurfaceId: [UUID: SurfaceProgressReport] = [:] var liveCodexSessionSurfaceIds: Set = [] + var progressClearTimers: [UUID: Timer] = [:] let persistence: any WorkspacePersisting let workspaceCatalog: WorkspaceCatalogManager diff --git a/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift b/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift index 0c32a6d..e5659b1 100644 --- a/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift +++ b/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift @@ -29,7 +29,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) XCTAssertEqual(host.updatedSurfaceIds, [surface.id]) @@ -65,7 +66,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) GhosttyTerminalView.syncContainerView( secondContainer, @@ -80,7 +82,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) XCTAssertTrue(host.superview === secondContainer) @@ -118,7 +121,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) GhosttyTerminalView.syncContainerView( container, @@ -133,7 +137,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) XCTAssertTrue(firstHost.superview == nil) @@ -169,7 +174,8 @@ final class GhosttyTerminalViewTests: XCTestCase { onTitleChange: { _ in }, onWorkingDirectoryChange: { _ in }, onChildExited: {}, - onPaneNavigationRequest: { _ in } + onPaneNavigationRequest: { _ in }, + onProgressReport: { _ in } ) GhosttyTerminalView.dismantleContainerView(container, runtime: runtime) @@ -203,7 +209,8 @@ private final class MockGhosttyTerminalHostView: NSView, GhosttyTerminalHostView onTitleChange: @escaping (String) -> Void, onWorkingDirectoryChange: @escaping (String) -> Void, onChildExited: @escaping () -> Void, - onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void + onPaneNavigationRequest: @escaping (PaneNodeModel.PaneFocusDirection) -> Void, + onProgressReport: @escaping (SurfaceProgressReport?) -> Void ) { _ = terminalConfig _ = onActivate @@ -213,6 +220,7 @@ private final class MockGhosttyTerminalHostView: NSView, GhosttyTerminalHostView workingDirectoryChangeHandler = onWorkingDirectoryChange _ = onChildExited _ = onPaneNavigationRequest + _ = onProgressReport updatedSurfaceIds.append(surfaceModel.id) } } From c4e4066bbd3f102b9c701534b2441de18365499c Mon Sep 17 00:00:00 2001 From: Morten Trydal Date: Sun, 15 Mar 2026 20:33:46 +0100 Subject: [PATCH 2/4] Address review findings for OSC 9;4 progress reporting - Skip dispatch for unknown progress states instead of clearing progress - Clamp progress values to 100 at parse boundary and in model init - Guard surface existence in setProgressReport before writing state - Extract tintColor onto SurfaceProgressState to remove duplicate color mapping - Remove redundant host.update() call from acquireHostView - Extract progressAutoClearInterval constant for magic number 15 - Store onProgressReport in test mock; add closure-forwarding test --- .../WorkspaceDetail/SurfaceProgressBar.swift | 8 +--- .../WorkspaceDetail/SurfaceTabButton.swift | 6 +-- .../Ghostty/GhosttyRuntime.swift | 44 +++++++------------ .../Models/SurfaceProgressReport.swift | 16 +++++++ .../WorkspaceManager+SurfaceOperations.swift | 9 +++- .../GhosttyTerminalViewTests.swift | 37 +++++++++++++++- 6 files changed, 77 insertions(+), 43 deletions(-) diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceProgressBar.swift b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceProgressBar.swift index 5b4a5a5..51c0e7e 100644 --- a/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceProgressBar.swift +++ b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceProgressBar.swift @@ -8,13 +8,7 @@ import SwiftUI struct SurfaceProgressBar: View { let report: SurfaceProgressReport - private var color: Color { - switch report.state { - case .error: return .red - case .pause: return .orange - default: return .accentColor - } - } + private var color: Color { report.state.tintColor } /// Resolved percentage value, treating pause-without-progress as 100%. private var progress: UInt8? { diff --git a/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift index debc73b..3b09549 100644 --- a/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift +++ b/Sources/Shellraiser/Features/WorkspaceDetail/SurfaceTabButton.swift @@ -29,11 +29,7 @@ struct SurfaceTabButton: View { /// Color for the tab status dot derived from progress state, completion, or default. private var dotFill: AnyShapeStyle { if let report = manager.progressBySurfaceId[surface.id] { - switch report.state { - case .error: return AnyShapeStyle(Color.red) - case .pause: return AnyShapeStyle(Color.orange) - default: return AnyShapeStyle(Color.accentColor) - } + return AnyShapeStyle(report.state.tintColor) } if surface.hasPendingCompletion { return AnyShapeStyle(AppTheme.accentGradient) } return AnyShapeStyle(Color.white.opacity(0.2)) diff --git a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift index bfa8ade..2ca8efe 100644 --- a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -136,18 +136,6 @@ final class GhosttyRuntime { releasedSurfaceIds.remove(surfaceModel.id) if let existing = hostViewsBySurfaceId[surfaceModel.id] { - existing.update( - surfaceModel: surfaceModel, - terminalConfig: terminalConfig, - onActivate: onActivate, - onIdleNotification: onIdleNotification, - onInput: onInput, - onTitleChange: onTitleChange, - onWorkingDirectoryChange: onWorkingDirectoryChange, - onChildExited: onChildExited, - onPaneNavigationRequest: onPaneNavigationRequest, - onProgressReport: onProgressReport - ) return existing } @@ -817,22 +805,22 @@ final class GhosttyRuntime { let surface = target.target.surface else { return false } let surfaceKey = UInt(bitPattern: surface) let raw = action.action.progress_report - let report: SurfaceProgressReport? = { - switch raw.state { - case GHOSTTY_PROGRESS_STATE_REMOVE: - return nil - case GHOSTTY_PROGRESS_STATE_SET: - return SurfaceProgressReport(state: .set, progress: raw.progress >= 0 ? UInt8(raw.progress) : nil) - case GHOSTTY_PROGRESS_STATE_ERROR: - return SurfaceProgressReport(state: .error, progress: raw.progress >= 0 ? UInt8(raw.progress) : nil) - case GHOSTTY_PROGRESS_STATE_INDETERMINATE: - return SurfaceProgressReport(state: .indeterminate, progress: raw.progress >= 0 ? UInt8(raw.progress) : nil) - case GHOSTTY_PROGRESS_STATE_PAUSE: - return SurfaceProgressReport(state: .pause, progress: raw.progress >= 0 ? UInt8(raw.progress) : nil) - default: - return nil - } - }() + let clampedProgress: UInt8? = raw.progress >= 0 ? UInt8(min(raw.progress, 100)) : nil + let report: SurfaceProgressReport? + switch raw.state { + case GHOSTTY_PROGRESS_STATE_REMOVE: + report = nil + case GHOSTTY_PROGRESS_STATE_SET: + report = SurfaceProgressReport(state: .set, progress: clampedProgress) + case GHOSTTY_PROGRESS_STATE_ERROR: + report = SurfaceProgressReport(state: .error, progress: clampedProgress) + case GHOSTTY_PROGRESS_STATE_INDETERMINATE: + report = SurfaceProgressReport(state: .indeterminate, progress: clampedProgress) + case GHOSTTY_PROGRESS_STATE_PAUSE: + report = SurfaceProgressReport(state: .pause, progress: clampedProgress) + default: + return false + } let capturedEvent: SurfaceActionEvent = .progressReport(report) DispatchQueue.main.async { runtime.handleAction(surfaceKey: surfaceKey, event: capturedEvent) diff --git a/Sources/Shellraiser/Models/SurfaceProgressReport.swift b/Sources/Shellraiser/Models/SurfaceProgressReport.swift index 500a767..2aa4498 100644 --- a/Sources/Shellraiser/Models/SurfaceProgressReport.swift +++ b/Sources/Shellraiser/Models/SurfaceProgressReport.swift @@ -1,9 +1,20 @@ +import SwiftUI + /// Progress state reported by a terminal surface via OSC 9;4. enum SurfaceProgressState: Equatable { case set case error case indeterminate case pause + + /// Tint color representing the progress state in UI elements. + var tintColor: Color { + switch self { + case .error: return .red + case .pause: return .orange + default: return .accentColor + } + } } /// Progress report delivered by a terminal surface via OSC 9;4. @@ -12,4 +23,9 @@ struct SurfaceProgressReport: Equatable { let state: SurfaceProgressState /// Percentage completion (0–100), or nil when not quantified. let progress: UInt8? + + init(state: SurfaceProgressState, progress: UInt8?) { + self.state = state + self.progress = progress.map { min($0, 100) } + } } diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift index a17704f..9fc41a7 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift @@ -145,12 +145,17 @@ extension WorkspaceManager { markSurfaceBusy(surfaceId) } - /// Stores or removes an OSC 9;4 progress report for a surface, resetting the 15-second auto-clear timer. + /// Interval after which a progress report is automatically cleared if no update arrives. + private static let progressAutoClearInterval: TimeInterval = 15 + + /// Stores or removes an OSC 9;4 progress report for a surface, resetting the auto-clear timer. func setProgressReport(workspaceId: UUID, surfaceId: UUID, report: SurfaceProgressReport?) { + guard let workspace = workspace(id: workspaceId), + self.surface(in: workspace.rootPane, surfaceId: surfaceId) != nil else { return } if let report { progressBySurfaceId[surfaceId] = report progressClearTimers[surfaceId]?.invalidate() - progressClearTimers[surfaceId] = Timer.scheduledTimer(withTimeInterval: 15, repeats: false) { [weak self] _ in + progressClearTimers[surfaceId] = Timer.scheduledTimer(withTimeInterval: Self.progressAutoClearInterval, repeats: false) { [weak self] _ in Task { @MainActor [weak self] in self?.progressBySurfaceId.removeValue(forKey: surfaceId) self?.progressClearTimers.removeValue(forKey: surfaceId) diff --git a/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift b/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift index e5659b1..d57dab7 100644 --- a/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift +++ b/Tests/ShellraiserTests/GhosttyTerminalViewTests.swift @@ -40,6 +40,39 @@ final class GhosttyTerminalViewTests: XCTestCase { XCTAssertTrue(runtime.restoredHosts.first === host) } + /// Verifies syncHostView forwards the onProgressReport closure to the host view. + func testSyncHostViewForwardsProgressReportClosure() { + let runtime = MockGhosttyTerminalRuntime() + let host = MockGhosttyTerminalHostView() + let surface = SurfaceModel.makeDefault() + let config = TerminalPanelConfig( + workingDirectory: "/tmp", + shell: "/bin/zsh", + environment: [:] + ) + var receivedReport: SurfaceProgressReport?? + let expectedReport = SurfaceProgressReport(state: .set, progress: 42) + + GhosttyTerminalView.syncHostView( + host, + runtime: runtime, + surface: surface, + config: config, + isFocused: false, + onActivate: {}, + onIdleNotification: {}, + onInput: { _ in }, + onTitleChange: { _ in }, + onWorkingDirectoryChange: { _ in }, + onChildExited: {}, + onPaneNavigationRequest: { _ in }, + onProgressReport: { receivedReport = $0 } + ) + + host.progressReportHandler?(expectedReport) + XCTAssertEqual(receivedReport, .some(expectedReport)) + } + /// Verifies reparenting a shared host into a new wrapper keeps the host off the mount root. func testSyncContainerViewReparentsSharedHostWithoutDetachingCurrentMount() { let runtime = MockGhosttyTerminalRuntime() @@ -199,6 +232,8 @@ private final class MockGhosttyTerminalHostView: NSView, GhosttyTerminalHostView /// Supplies the responder target to make first responder. var firstResponderTarget: NSResponder { self } + private(set) var progressReportHandler: ((SurfaceProgressReport?) -> Void)? + /// Records host updates dispatched through the terminal view helper. func update( surfaceModel: SurfaceModel, @@ -220,7 +255,7 @@ private final class MockGhosttyTerminalHostView: NSView, GhosttyTerminalHostView workingDirectoryChangeHandler = onWorkingDirectoryChange _ = onChildExited _ = onPaneNavigationRequest - _ = onProgressReport + progressReportHandler = onProgressReport updatedSurfaceIds.append(surfaceModel.id) } } From f891015c07768db7f631187658a6fb97a12f3c98 Mon Sep 17 00:00:00 2001 From: Morten Trydal Date: Sun, 15 Mar 2026 21:02:36 +0100 Subject: [PATCH 3/4] Fix progress auto-clear timer not firing during UI tracking Use .common run loop mode so the timer fires during scroll/drag events, not just in .default mode which pauses when the run loop switches to .tracking mode. --- .../Workspaces/WorkspaceManager+SurfaceOperations.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift index 930e6c7..6bb1a1b 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift @@ -156,12 +156,14 @@ extension WorkspaceManager { if let report { progressBySurfaceId[surfaceId] = report progressClearTimers[surfaceId]?.invalidate() - progressClearTimers[surfaceId] = Timer.scheduledTimer(withTimeInterval: Self.progressAutoClearInterval, repeats: false) { [weak self] _ in + let timer = Timer(timeInterval: Self.progressAutoClearInterval, repeats: false) { [weak self] _ in Task { @MainActor [weak self] in self?.progressBySurfaceId.removeValue(forKey: surfaceId) self?.progressClearTimers.removeValue(forKey: surfaceId) } } + RunLoop.main.add(timer, forMode: .common) + progressClearTimers[surfaceId] = timer } else { clearProgressReport(surfaceId: surfaceId) } From 0dea37b5abd4d95fa1139c0fa4a7607959b335a4 Mon Sep 17 00:00:00 2001 From: Morten Trydal Date: Sun, 15 Mar 2026 21:19:31 +0100 Subject: [PATCH 4/4] Fix progress surface fallback and timer race - focusedSurfaceProgress now falls back to firstActiveSurfaceId() when focusedSurfaceId is nil, matching the pattern used by other surface lookup functions - Replace timer identity check (unsound due to Timer not being Sendable and forward-reference restrictions) with a generation counter; stale timer callbacks are now ignored if a newer timer has been registered for the same surface --- .../WorkspaceManager+SurfaceOperations.swift | 14 +++++++++----- .../Services/Workspaces/WorkspaceManager.swift | 2 ++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift index 6bb1a1b..d330ed7 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+SurfaceOperations.swift @@ -156,10 +156,14 @@ extension WorkspaceManager { if let report { progressBySurfaceId[surfaceId] = report progressClearTimers[surfaceId]?.invalidate() + let generation = (progressTimerGeneration[surfaceId] ?? 0) + 1 + progressTimerGeneration[surfaceId] = generation let timer = Timer(timeInterval: Self.progressAutoClearInterval, repeats: false) { [weak self] _ in Task { @MainActor [weak self] in + guard self?.progressTimerGeneration[surfaceId] == generation else { return } self?.progressBySurfaceId.removeValue(forKey: surfaceId) self?.progressClearTimers.removeValue(forKey: surfaceId) + self?.progressTimerGeneration.removeValue(forKey: surfaceId) } } RunLoop.main.add(timer, forMode: .common) @@ -171,11 +175,10 @@ extension WorkspaceManager { /// Returns the progress report for the focused surface of a workspace, if any. func focusedSurfaceProgress(workspaceId: UUID) -> SurfaceProgressReport? { - guard let workspace = workspace(id: workspaceId), - let focusedSurfaceId = workspace.focusedSurfaceId else { - return nil - } - return progressBySurfaceId[focusedSurfaceId] + guard let workspace = workspace(id: workspaceId) else { return nil } + let surfaceId = workspace.focusedSurfaceId ?? workspace.rootPane.firstActiveSurfaceId() + guard let surfaceId else { return nil } + return progressBySurfaceId[surfaceId] } /// Removes any stored progress state and cancels the auto-clear timer for a surface. @@ -183,6 +186,7 @@ extension WorkspaceManager { progressBySurfaceId.removeValue(forKey: surfaceId) progressClearTimers[surfaceId]?.invalidate() progressClearTimers.removeValue(forKey: surfaceId) + progressTimerGeneration.removeValue(forKey: surfaceId) } /// Updates tab title using the current terminal title. diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift index 4862445..9c863b9 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift @@ -77,6 +77,8 @@ final class WorkspaceManager: ObservableObject { @Published var progressBySurfaceId: [UUID: SurfaceProgressReport] = [:] var liveCodexSessionSurfaceIds: Set = [] var progressClearTimers: [UUID: Timer] = [:] + /// Monotonically-increasing generation counter per surface; used to detect stale timer callbacks. + var progressTimerGeneration: [UUID: Int] = [:] let persistence: any WorkspacePersisting let workspaceCatalog: WorkspaceCatalogManager