diff --git a/Packages/CMUXExtensionClient/Sources/CMUXExtensionClient/Browser/CMUXSidebarExtensionBrowserPresenter.swift b/Packages/CMUXExtensionClient/Sources/CMUXExtensionClient/Browser/CMUXSidebarExtensionBrowserPresenter.swift index 692eb05a36be..37293fb99b11 100644 --- a/Packages/CMUXExtensionClient/Sources/CMUXExtensionClient/Browser/CMUXSidebarExtensionBrowserPresenter.swift +++ b/Packages/CMUXExtensionClient/Sources/CMUXExtensionClient/Browser/CMUXSidebarExtensionBrowserPresenter.swift @@ -5,75 +5,15 @@ import Foundation @available(macOS 13.0, *) @MainActor public enum CMUXSidebarExtensionBrowserPresenter { - public static func present(from anchorView: NSView, title: String) { + public static func makeViewController(title: String) -> NSViewController { let browserViewController = EXAppExtensionBrowserViewController() browserViewController.title = title - - let browserPanel = SidebarExtensionBrowserPanel(contentViewController: browserViewController) - browserPanel.title = title - browserPanel.styleMask = [.titled, .closable, .resizable, .utilityWindow] - browserPanel.contentMinSize = NSSize(width: 560, height: 460) - browserPanel.setContentSize(NSSize(width: 760, height: 600)) - browserPanel.isFloatingPanel = true - browserPanel.hidesOnDeactivate = false - browserPanel.isReleasedWhenClosed = false - - if let parentWindow = anchorView.window { - browserPanel.setFrameOrigin(Self.panelOrigin(anchorView: anchorView, parentWindow: parentWindow, panel: browserPanel)) - } else { - browserPanel.center() - } - BrowserPanelController.shared.show(panel: browserPanel) - } - - private static func panelOrigin(anchorView: NSView, parentWindow: NSWindow, panel: NSPanel) -> NSPoint { - let anchorFrame = anchorView.convert(anchorView.bounds, to: nil) - let anchorFrameInScreen = parentWindow.convertToScreen(anchorFrame) - let visibleFrame = parentWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero - let proposedX = anchorFrameInScreen.minX - let proposedY = anchorFrameInScreen.minY - panel.frame.height - 8 - let x = min(max(proposedX, visibleFrame.minX + 12), visibleFrame.maxX - panel.frame.width - 12) - let y = min(max(proposedY, visibleFrame.minY + 12), visibleFrame.maxY - panel.frame.height - 12) - return NSPoint(x: x, y: y) - } -} - -@available(macOS 13.0, *) -@MainActor -private final class SidebarExtensionBrowserPanel: NSPanel { - override func cancelOperation(_ sender: Any?) { - close() + return browserViewController } - override func performKeyEquivalent(with event: NSEvent) -> Bool { - if event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command, - event.charactersIgnoringModifiers == "w" { - close() - return true - } - return super.performKeyEquivalent(with: event) - } -} - -@available(macOS 13.0, *) -@MainActor -private final class BrowserPanelController: NSObject, NSWindowDelegate { - static let shared = BrowserPanelController() - - private var panel: NSPanel? - - func show(panel: NSPanel) { - if let existingPanel = self.panel { - existingPanel.close() - } - self.panel = panel - panel.delegate = self - panel.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } - - func windowWillClose(_ notification: Notification) { - guard let window = notification.object as? NSWindow, window === panel else { return } - self.panel = nil + @available(*, unavailable, message: "Open the extension browser through the host app pane-tab flow.") + public static func present(from anchorView: NSView, title: String) { + _ = anchorView + _ = title } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index a32695e03fd7..e2c214601f03 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -166126,17 +166126,125 @@ "sidebar.extensions.browser.title": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "امتدادات الشريط الجانبي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ekstenzije bočne trake" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sidepaneludvidelser" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleisten-Erweiterungen" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Sidebar Extensions" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Extensiones de la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Extensions de la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Estensioni della barra laterale" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "サイドバー拡張機能" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្នែកបន្ថែមរបារចំហៀង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 확장 프로그램" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sidefeltutvidelser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozszerzenia paska bocznego" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Extensões da barra lateral" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Расширения боковой панели" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่วนขยายแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar çubuğu uzantıları" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Розширення бічної панелі" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "侧边栏扩展" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "側邊欄擴充功能" + } } } }, @@ -166398,17 +166506,125 @@ "sidebar.extensions.manage": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدارة امتدادات الشريط الجانبي..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upravljaj ekstenzijama bočne trake..." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Administrer sidepaneludvidelser..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleisten-Erweiterungen verwalten..." + } + }, "en": { "stringUnit": { "state": "translated", "value": "Manage Sidebar Extensions..." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Administrar extensiones de la barra lateral..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérer les extensions de la barre latérale..." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Gestisci estensioni della barra laterale..." + } + }, "ja": { "stringUnit": { "state": "translated", "value": "サイドバー拡張機能を管理..." } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "គ្រប់គ្រងផ្នែកបន្ថែមរបារចំហៀង..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 확장 프로그램 관리..." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Administrer sidefeltutvidelser..." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zarządzaj rozszerzeniami paska bocznego..." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Gerenciar extensões da barra lateral..." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управление расширениями боковой панели..." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จัดการส่วนขยายแถบด้านข้าง..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar çubuğu uzantılarını yönet..." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Керувати розширеннями бічної панелі..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理侧边栏扩展..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "管理側邊欄擴充功能..." + } } } }, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6f3f35a74abf..e64a408726c7 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -13450,6 +13450,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return panelId } + @discardableResult + func openSidebarExtensionBrowser(from anchorView: NSView?, title: String) -> UUID? { + let preferredWindow = anchorView?.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetTabManager = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) + guard let workspace = targetTabManager?.selectedWorkspace, + let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { + return nil + } + + return workspace.newSidebarExtensionBrowserSurface( + inPane: paneId, + title: title, + focus: true + )?.id + } + private func focusBrowserAddressBar(in panel: BrowserPanel) { #if DEBUG let requestId = panel.requestAddressBarFocus() diff --git a/Sources/CMUXInstalledExtensionSidebarHostView.swift b/Sources/CMUXInstalledExtensionSidebarHostView.swift index a2bc4701f878..2e924103e412 100644 --- a/Sources/CMUXInstalledExtensionSidebarHostView.swift +++ b/Sources/CMUXInstalledExtensionSidebarHostView.swift @@ -1,5 +1,6 @@ @_spi(CmuxHostTransport) import CMUXExtensionClient @_spi(CmuxHostTransport) import CmuxExtensionKit +import AppKit import ExtensionFoundation import Observation import SwiftUI @@ -668,9 +669,11 @@ struct CMUXInstalledExtensionSidebarHostView: View { } private func presentExtensionBrowser() { - guard let browserAnchorView else { return } - CMUXSidebarExtensionBrowserPresenter.present( - from: browserAnchorView, + guard let anchorView = browserAnchorView + ?? NSApp.keyWindow?.contentView + ?? NSApp.mainWindow?.contentView else { return } + AppDelegate.shared?.openSidebarExtensionBrowser( + from: anchorView, title: String( localized: "sidebar.extensions.browser.title", defaultValue: "Sidebar Extensions" @@ -990,15 +993,21 @@ struct CMUXInstalledExtensionSidebarHostView: View { let continuationBox = MonitorContinuationBox() await withTaskCancellationHandler { await withCheckedContinuation { continuation in - continuationBox.set(continuation) + Task { + await continuationBox.set(continuation) + } withObservationTracking { applyModernExtensionState(monitor.state) } onChange: { - continuationBox.resume() + Task { + await continuationBox.resume() + } } } } onCancel: { - continuationBox.cancel() + Task { + await continuationBox.cancel() + } } if Task.isCancelled { break @@ -1008,37 +1017,28 @@ struct CMUXInstalledExtensionSidebarHostView: View { #endif } -private final class MonitorContinuationBox: @unchecked Sendable { - private let lock = NSLock() +private actor MonitorContinuationBox { private var continuation: CheckedContinuation? - private var isCancelled = false + private var isResolved = false func set(_ continuation: CheckedContinuation) { - lock.lock() - if isCancelled { - lock.unlock() + if isResolved { continuation.resume() return } self.continuation = continuation - lock.unlock() } func resume() { - lock.lock() + guard !isResolved else { return } + isResolved = true let continuation = continuation self.continuation = nil - lock.unlock() continuation?.resume() } func cancel() { - lock.lock() - isCancelled = true - let continuation = continuation - self.continuation = nil - lock.unlock() - continuation?.resume() + resume() } } diff --git a/Sources/CMUXSidebarExtensionBrowserPanel.swift b/Sources/CMUXSidebarExtensionBrowserPanel.swift new file mode 100644 index 000000000000..dea84565d765 --- /dev/null +++ b/Sources/CMUXSidebarExtensionBrowserPanel.swift @@ -0,0 +1,261 @@ +@_spi(CmuxHostTransport) import CMUXExtensionClient +import AppKit +import SwiftUI + +@MainActor +final class CMUXSidebarExtensionBrowserPanel: NSObject, Panel, ObservableObject { + let id = UUID() + let panelType: PanelType = .extensionBrowser + let browserViewController: NSViewController + + private let title: String + + var displayTitle: String { title } + var displayIcon: String? { "puzzlepiece.extension" } + + init(title: String) { + self.title = title + self.browserViewController = CMUXSidebarExtensionBrowserPresenter.makeViewController(title: title) + super.init() + } + + func close() {} + + func focus() { + guard let window = browserViewController.view.window else { return } + _ = window.makeFirstResponder(browserViewController.view) + } + + func unfocus() {} + + func triggerFlash(reason: WorkspaceAttentionFlashReason) { + _ = reason + } +} + +struct CMUXSidebarExtensionBrowserPanelView: NSViewControllerRepresentable { + let panel: CMUXSidebarExtensionBrowserPanel + let onRequestPanelFocus: () -> Void + + func makeNSViewController(context: Context) -> NSViewController { + CMUXSidebarExtensionBrowserContainerViewController( + browserViewController: panel.browserViewController, + onRequestPanelFocus: onRequestPanelFocus + ) + } + + func updateNSViewController(_ nsViewController: NSViewController, context: Context) { + guard let container = nsViewController as? CMUXSidebarExtensionBrowserContainerViewController else { + return + } + container.browserViewController.title = panel.displayTitle + container.onRequestPanelFocus = onRequestPanelFocus + container.attachBrowserIfNeeded() + container.updateLayoutForCurrentBounds() + } + + static func dismantleNSViewController( + _ nsViewController: NSViewController, + coordinator: () + ) { + (nsViewController as? CMUXSidebarExtensionBrowserContainerViewController)?.detachBrowserForTransientReparent() + } +} + +@MainActor +private final class CMUXSidebarExtensionBrowserContainerViewController: NSViewController { + private final class RootView: NSView { + var onLayout: (() -> Void)? + var onMoveToWindow: (() -> Void)? + + override var isFlipped: Bool { true } + + override func layout() { + super.layout() + onLayout?() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + onMoveToWindow?() + } + } + + private final class FocusCardView: NSView { + var onMouseDown: (() -> Void)? + + override var isFlipped: Bool { true } + + override func mouseDown(with event: NSEvent) { + onMouseDown?() + super.mouseDown(with: event) + } + } + + let browserViewController: NSViewController + var onRequestPanelFocus: () -> Void + + private let rootView = RootView(frame: .zero) + private let cardView = FocusCardView(frame: .zero) + private let contentView = NSView(frame: .zero) + private var cardWidthConstraint: NSLayoutConstraint? + private var cardHeightConstraint: NSLayoutConstraint? + private var cardTopConstraint: NSLayoutConstraint? + private var cardHorizontalSafetyConstraints: [NSLayoutConstraint] = [] + private var cardBottomSafetyConstraint: NSLayoutConstraint? + private var browserConstraints: [NSLayoutConstraint] = [] + + init( + browserViewController: NSViewController, + onRequestPanelFocus: @escaping () -> Void + ) { + self.browserViewController = browserViewController + self.onRequestPanelFocus = onRequestPanelFocus + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + rootView.wantsLayer = true + rootView.layer?.backgroundColor = NSColor.clear.cgColor + rootView.onLayout = { [weak self] in + self?.updateLayoutForCurrentBounds() + } + rootView.onMoveToWindow = { [weak self] in + self?.attachBrowserIfNeeded() + self?.updateLayoutForCurrentBounds() + } + + cardView.translatesAutoresizingMaskIntoConstraints = false + cardView.wantsLayer = true + cardView.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(Self.backgroundAlpha).cgColor + cardView.layer?.cornerRadius = Self.cornerRadius + cardView.layer?.cornerCurve = .continuous + cardView.layer?.borderWidth = 0 + cardView.layer?.masksToBounds = true + cardView.onMouseDown = { [weak self] in + self?.onRequestPanelFocus() + } + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.wantsLayer = true + contentView.layer?.backgroundColor = NSColor.clear.cgColor + + rootView.addSubview(cardView) + cardView.addSubview(contentView) + let cardWidthConstraint = cardView.widthAnchor.constraint(equalToConstant: Self.defaultWidth) + let cardHeightConstraint = cardView.heightAnchor.constraint(equalToConstant: Self.defaultHeight) + let cardTopConstraint = cardView.topAnchor.constraint(equalTo: rootView.topAnchor, constant: Self.topInset) + let cardBottomSafetyConstraint = cardView.bottomAnchor.constraint(lessThanOrEqualTo: rootView.bottomAnchor, constant: -Self.bottomInset) + cardWidthConstraint.priority = .defaultHigh + cardHeightConstraint.priority = .defaultHigh + cardHorizontalSafetyConstraints = [ + cardView.leadingAnchor.constraint(greaterThanOrEqualTo: rootView.leadingAnchor, constant: Self.sideInset), + cardView.trailingAnchor.constraint(lessThanOrEqualTo: rootView.trailingAnchor, constant: -Self.sideInset), + ] + self.cardWidthConstraint = cardWidthConstraint + self.cardHeightConstraint = cardHeightConstraint + self.cardTopConstraint = cardTopConstraint + self.cardBottomSafetyConstraint = cardBottomSafetyConstraint + + NSLayoutConstraint.activate( + cardHorizontalSafetyConstraints + [ + cardView.centerXAnchor.constraint(equalTo: rootView.centerXAnchor), + cardTopConstraint, + cardBottomSafetyConstraint, + cardWidthConstraint, + cardHeightConstraint, + contentView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor), + contentView.topAnchor.constraint(equalTo: cardView.topAnchor), + contentView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor), + ]) + + view = rootView + attachBrowserIfNeeded() + } + + func attachBrowserIfNeeded() { + guard isViewLoaded else { return } + + if browserViewController.parent !== self { + if browserViewController.parent != nil { + browserViewController.removeFromParent() + } + browserViewController.view.removeFromSuperview() + + addChild(browserViewController) + browserViewController.view.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(browserViewController.view) + browserConstraints = [ + browserViewController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + browserViewController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + browserViewController.view.topAnchor.constraint(equalTo: contentView.topAnchor), + browserViewController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ] + NSLayoutConstraint.activate(browserConstraints) + } else if browserViewController.view.superview !== contentView { + NSLayoutConstraint.deactivate(browserConstraints) + browserViewController.view.removeFromSuperview() + browserViewController.view.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(browserViewController.view) + browserConstraints = [ + browserViewController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + browserViewController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + browserViewController.view.topAnchor.constraint(equalTo: contentView.topAnchor), + browserViewController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ] + NSLayoutConstraint.activate(browserConstraints) + } + + browserViewController.view.wantsLayer = true + browserViewController.view.layer?.cornerRadius = Self.cornerRadius + browserViewController.view.layer?.cornerCurve = .continuous + browserViewController.view.layer?.masksToBounds = true + } + + func detachBrowserForTransientReparent() { + guard browserViewController.parent === self else { return } + NSLayoutConstraint.deactivate(browserConstraints) + browserConstraints = [] + browserViewController.view.removeFromSuperview() + browserViewController.removeFromParent() + } + + func updateLayoutForCurrentBounds() { + cardWidthConstraint?.constant = Self.width(for: rootView.bounds.width) + cardHeightConstraint?.constant = Self.height(for: rootView.bounds.height) + cardTopConstraint?.constant = Self.topInset + cardBottomSafetyConstraint?.constant = -Self.bottomInset + cardHorizontalSafetyConstraints.first?.constant = Self.sideInset + cardHorizontalSafetyConstraints.dropFirst().first?.constant = -Self.sideInset + + cardView.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(Self.backgroundAlpha).cgColor + cardView.layer?.cornerRadius = Self.cornerRadius + browserViewController.view.layer?.cornerRadius = Self.cornerRadius + } + + private static func width(for availableWidth: CGFloat) -> CGFloat { + guard availableWidth > 0 else { return defaultWidth } + return min(defaultWidth, max(minWidth, availableWidth - sideInset * 2)) + } + + private static func height(for availableHeight: CGFloat) -> CGFloat { + guard availableHeight > 0 else { return defaultHeight } + return min(maxHeight, max(minHeight, min(defaultHeight, availableHeight - topInset - bottomInset))) + } + + private static let sideInset: CGFloat = 20 + private static let topInset: CGFloat = 16 + private static let bottomInset: CGFloat = 16 + private static let minWidth: CGFloat = 260 + private static let defaultWidth: CGFloat = 720 + private static let minHeight: CGFloat = 360 + private static let defaultHeight: CGFloat = 460 + private static let maxHeight: CGFloat = 560 + private static let cornerRadius: CGFloat = 8 + private static let backgroundAlpha: CGFloat = 0.35 +} diff --git a/Sources/ClosedItemHistory.swift b/Sources/ClosedItemHistory.swift index a6ea3758b8d3..542fc5cfafc6 100644 --- a/Sources/ClosedItemHistory.swift +++ b/Sources/ClosedItemHistory.swift @@ -714,6 +714,8 @@ final class ClosedItemHistoryStore: ObservableObject { return String(localized: "menu.history.recentlyClosed.panel.tool", defaultValue: "Tool") case .project: return String(localized: "menu.history.recentlyClosed.panel.project", defaultValue: "Project") + case .extensionBrowser: + return String(localized: "sidebar.extensions.browser.title", defaultValue: "Sidebar Extensions") } } diff --git a/Sources/CmuxLifecycleEventPublishing.swift b/Sources/CmuxLifecycleEventPublishing.swift index 3e59bf2995c0..a5d215725027 100644 --- a/Sources/CmuxLifecycleEventPublishing.swift +++ b/Sources/CmuxLifecycleEventPublishing.swift @@ -216,6 +216,8 @@ extension Workspace { return "right_sidebar_tool" case .project: return "project" + case .extensionBrowser: + return "extension_browser" } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 0000eb1566e6..60e6d565a25b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5840,6 +5840,8 @@ struct ContentView: View { return String(localized: "commandPalette.kind.rightSidebarTool", defaultValue: "Tool") case .project: return String(localized: "commandPalette.kind.project", defaultValue: "Project") + case .extensionBrowser: + return String(localized: "sidebar.extensions.browser.title", defaultValue: "Sidebar Extensions") } } @@ -5857,6 +5859,8 @@ struct ContentView: View { return ["tool", "files", "find", "vault", "sidebar"] case .project: return ["project", "xcode", "build", "settings", "schemes", "targets"] + case .extensionBrowser: + return ["sidebar", "extensions", "extensionkit", "browser"] } } @@ -10012,7 +10016,7 @@ private final class CmuxExtensionSidebarMenuTarget: NSObject { @objc func manageExtensions(_ sender: NSMenuItem) { guard let anchorView = sender.representedObject as? NSView else { return } - CMUXSidebarExtensionBrowserPresenter.present( + AppDelegate.shared?.openSidebarExtensionBrowser( from: anchorView, title: String(localized: "sidebar.extensions.browser.title", defaultValue: "Sidebar Extensions") ) @@ -11021,6 +11025,8 @@ struct VerticalTabsSidebar: View { return .rightSidebarTool case .project: return .project + case .extensionBrowser: + return .unknown } } diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index ed89f68022e1..3a51517e5f25 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -10,6 +10,7 @@ public enum PanelType: String, Codable, Sendable { case filePreview = "filepreview" case rightSidebarTool case project + case extensionBrowser public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index a5eeea8ee82f..10c15604eea1 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -101,6 +101,13 @@ struct PanelContentView: View { onRequestPanelFocus: onRequestPanelFocus ) } + case .extensionBrowser: + if let extensionBrowserPanel = panel as? CMUXSidebarExtensionBrowserPanel { + CMUXSidebarExtensionBrowserPanelView( + panel: extensionBrowserPanel, + onRequestPanelFocus: onRequestPanelFocus + ) + } } } @@ -118,7 +125,7 @@ struct PanelContentView: View { private var shouldInstallPaneDropTarget: Bool { guard isVisibleInUI else { return false } switch panel.panelType { - case .markdown, .filePreview, .rightSidebarTool, .project: + case .markdown, .filePreview, .rightSidebarTool, .project, .extensionBrowser: return true case .terminal, .browser: return false diff --git a/Sources/Search/GlobalSearchDocuments.swift b/Sources/Search/GlobalSearchDocuments.swift index 6ca7bb4b11e2..06dc2957a472 100644 --- a/Sources/Search/GlobalSearchDocuments.swift +++ b/Sources/Search/GlobalSearchDocuments.swift @@ -34,7 +34,7 @@ enum GlobalSearchDocuments { kind = .browser case .markdown: kind = .markdown - case .terminal, .filePreview, .rightSidebarTool, .project: + case .terminal, .filePreview, .rightSidebarTool, .project, .extensionBrowser: kind = .title } diff --git a/Sources/TerminalPaneDropTargetView.swift b/Sources/TerminalPaneDropTargetView.swift index f3f3a0ec78d1..d6e2fc450a81 100644 --- a/Sources/TerminalPaneDropTargetView.swift +++ b/Sources/TerminalPaneDropTargetView.swift @@ -324,6 +324,8 @@ final class PaneDropTargetView: NSView { return nil case .project: return nil + case .extensionBrowser: + return nil } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 40bb7b7ffdf1..c819ac9c0ec2 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -661,6 +661,8 @@ extension Workspace { selectedSchemeName: projectPanel.selectedSchemeName, selectedConfigurationName: projectPanel.selectedConfigurationName ) + case .extensionBrowser: + return nil } return SessionPanelSnapshot( @@ -1787,6 +1789,8 @@ extension Workspace { } applySessionPanelMetadata(snapshot, toPanelId: projectPanel.id) return projectPanel.id + case .extensionBrowser: + return nil } } @@ -10174,6 +10178,7 @@ final class Workspace: Identifiable, ObservableObject { static let filePreview = "filePreview" static let rightSidebarTool = "rightSidebarTool" static let project = "project" + static let extensionBrowser = "extensionBrowser" } enum PanelShellActivityState: String { @@ -11118,6 +11123,8 @@ final class Workspace: Identifiable, ObservableObject { return SurfaceKind.rightSidebarTool case .project: return SurfaceKind.project + case .extensionBrowser: + return SurfaceKind.extensionBrowser } } @@ -13855,6 +13862,57 @@ final class Workspace: Identifiable, ObservableObject { return browserPanel } + /// Creates a sidebar extension browser tab in the requested pane and returns its panel. + /// + /// - Parameters: + /// - paneId: The pane that should receive the extension browser tab. + /// - title: The display title used for the tab and panel. + /// - focus: When true, selects the new tab and moves focus to its pane. The tab is not restored from saved workspace sessions. + /// - Returns: The created extension browser panel, or `nil` if the pane cannot accept a new tab. + @discardableResult + func newSidebarExtensionBrowserSurface( + inPane paneId: PaneID, + title: String, + focus: Bool = true + ) -> CMUXSidebarExtensionBrowserPanel? { + let shouldFocusNewTab = focus || bonsplitController.focusedPaneId == paneId + let extensionBrowserPanel = CMUXSidebarExtensionBrowserPanel(title: title) + panels[extensionBrowserPanel.id] = extensionBrowserPanel + panelTitles[extensionBrowserPanel.id] = extensionBrowserPanel.displayTitle + + guard let newTabId = bonsplitController.createTab( + title: extensionBrowserPanel.displayTitle, + icon: extensionBrowserPanel.displayIcon, + kind: SurfaceKind.extensionBrowser, + isDirty: false, + isLoading: false, + isPinned: false, + inPane: paneId + ) else { + panels.removeValue(forKey: extensionBrowserPanel.id) + panelTitles.removeValue(forKey: extensionBrowserPanel.id) + return nil + } + + surfaceIdToPanelId[newTabId] = extensionBrowserPanel.id + publishCmuxSurfaceCreated( + extensionBrowserPanel.id, + paneId: paneId, + kind: SurfaceKind.extensionBrowser, + origin: "extension_browser_tab", + focused: shouldFocusNewTab + ) + + if shouldFocusNewTab { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(newTabId) + extensionBrowserPanel.focus() + applyTabSelection(tabId: newTabId, inPane: paneId) + } + + return extensionBrowserPanel + } + /// Open the markdown viewer for `filePath`, reusing an existing /// `MarkdownPanel` in this workspace that already shows the same file. /// Paths are compared after symlink resolution so `./README.md` and a diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 5c35b929bcbf..b29f88c8a7ba 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ CD0CFE5300000000CD0CFE53 /* CmuxSettings in Frameworks */ = {isa = PBXBuildFile; productRef = CD0CFE5200000000CD0CFE52 /* CmuxSettings */; }; D3610C010000000000000001 /* CmuxSettingsJSONPathSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3610C010000000000000002 /* CmuxSettingsJSONPathSupport.swift */; }; CD0CFE5600000000CD0CFE56 /* CmuxSettingsUI in Frameworks */ = {isa = PBXBuildFile; productRef = CD0CFE5500000000CD0CFE55 /* CmuxSettingsUI */; }; + C0DE46030000000000000001 /* CMUXSidebarExtensionBrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE46030000000000000002 /* CMUXSidebarExtensionBrowserPanel.swift */; }; E7E000000000000000000009 /* CmuxSocketEventMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E00000000000000000000A /* CmuxSocketEventMapper.swift */; }; A5354303A5354303A5354303 /* CMUXSocketPathDomain in Frameworks */ = {isa = PBXBuildFile; productRef = A5354305A5354305A5354305 /* CMUXSocketPathDomain */; }; B900004CA1B2C3D4E5F60719 /* CMUXSocketPathDomain in Frameworks */ = {isa = PBXBuildFile; productRef = A5354305A5354305A5354305 /* CMUXSocketPathDomain */; }; @@ -783,6 +784,7 @@ 2F0C07000000000000000001 /* CmuxMainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxMainWindow.swift; sourceTree = ""; }; C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXOpenCommandTests.swift; sourceTree = ""; }; D3610C010000000000000002 /* CmuxSettingsJSONPathSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxSettingsJSONPathSupport.swift; sourceTree = ""; }; + C0DE46030000000000000002 /* CMUXSidebarExtensionBrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXSidebarExtensionBrowserPanel.swift; sourceTree = ""; }; E7E00000000000000000000A /* CmuxSocketEventMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxSocketEventMapper.swift; sourceTree = ""; }; C3677002000000000000002 /* CmuxSSHURLRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxSSHURLRequest.swift; sourceTree = ""; }; C3677001000000000000002 /* CmuxSSHURLRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxSSHURLRequestTests.swift; sourceTree = ""; }; @@ -1392,6 +1394,7 @@ C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */, A5001012 /* ContentView.swift */, C0DE46010000000000000002 /* CMUXInstalledExtensionSidebarHostView.swift */, + C0DE46030000000000000002 /* CMUXSidebarExtensionBrowserPanel.swift */, E4C001000000000000000002 /* ExtensionSidebarWorkspaceRowView.swift */, E4C001000000000000000004 /* ExtensionWorktreePrototype.swift */, C0DE36230000000000000002 /* WorkspaceFinderDirectoryResolver.swift */, @@ -2236,6 +2239,7 @@ E7E00000000000000000000B /* CmuxLifecycleEventPublishing.swift in Sources */, 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */, D3610C010000000000000001 /* CmuxSettingsJSONPathSupport.swift in Sources */, + C0DE46030000000000000001 /* CMUXSidebarExtensionBrowserPanel.swift in Sources */, E7E000000000000000000009 /* CmuxSocketEventMapper.swift in Sources */, C3677002000000000000001 /* CmuxSSHURLRequest.swift in Sources */, C10D00030000000000000003 /* CmuxSurfaceTabBarBuiltInAction.swift in Sources */, diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index b0272baa83f4..1fd276a735aa 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -4681,6 +4681,40 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { } +@MainActor +final class WorkspaceSidebarExtensionBrowserSurfaceTests: XCTestCase { + func testCreatesExtensionBrowserTabInFocusedPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else { + XCTFail("Expected split workspace setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + + workspace.focusPanel(leftPanelId) + XCTAssertEqual(workspace.bonsplitController.focusedPaneId, leftPaneId) + + guard let extensionBrowserPanel = workspace.newSidebarExtensionBrowserSurface( + inPane: leftPaneId, + title: "Sidebar Extensions", + focus: true + ) else { + XCTFail("Expected extension browser tab creation to succeed") + return + } + + XCTAssertEqual(extensionBrowserPanel.panelType, .extensionBrowser) + XCTAssertEqual(workspace.focusedPanelId, extensionBrowserPanel.id) + XCTAssertEqual(workspace.paneId(forPanelId: extensionBrowserPanel.id), leftPaneId) + XCTAssertNotEqual(workspace.paneId(forPanelId: extensionBrowserPanel.id), workspace.paneId(forPanelId: rightPanel.id)) + } +} + + @MainActor final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() {