diff --git a/Examples/SampleSidebarExtensionApp/SampleSidebarExtension/Extension/SampleSidebarExtension.swift b/Examples/SampleSidebarExtensionApp/SampleSidebarExtension/Extension/SampleSidebarExtension.swift index 6113160de7..9b1dc74b3c 100644 --- a/Examples/SampleSidebarExtensionApp/SampleSidebarExtension/Extension/SampleSidebarExtension.swift +++ b/Examples/SampleSidebarExtensionApp/SampleSidebarExtension/Extension/SampleSidebarExtension.swift @@ -10,6 +10,9 @@ final class SampleSidebarExtension: CmuxSidebarExtension { .workspaceList, .workspaceMetadata, .surfaceMetadata, + .notifications, + .networkPorts, + .pullRequests, ], requestedActionScopes: [ .createSurface, diff --git a/Examples/SampleSidebarExtensionApp/SampleSidebarExtension/Model/SidebarInsightModel.swift b/Examples/SampleSidebarExtensionApp/SampleSidebarExtension/Model/SidebarInsightModel.swift index ff28fe504c..691c70095f 100644 --- a/Examples/SampleSidebarExtensionApp/SampleSidebarExtension/Model/SidebarInsightModel.swift +++ b/Examples/SampleSidebarExtensionApp/SampleSidebarExtension/Model/SidebarInsightModel.swift @@ -110,6 +110,8 @@ struct SurfaceInsight: Identifiable { return "doc" case .rightSidebarTool: return "sidebar.right" + case .project: + return "folder" case .unknown: return "rectangle" } diff --git a/Packages/CMUXExtensionClient/Sources/CMUXExtensionClient/Session/CMUXSidebarExtensionSession.swift b/Packages/CMUXExtensionClient/Sources/CMUXExtensionClient/Session/CMUXSidebarExtensionSession.swift index a3d895535e..e1ed59f971 100644 --- a/Packages/CMUXExtensionClient/Sources/CMUXExtensionClient/Session/CMUXSidebarExtensionSession.swift +++ b/Packages/CMUXExtensionClient/Sources/CMUXExtensionClient/Session/CMUXSidebarExtensionSession.swift @@ -50,7 +50,7 @@ public actor CMUXSidebarExtensionSession { /// - Returns: Host action result. /// - Throws: Errors thrown by the host dispatch callback. public func perform(_ action: CMUXSidebarAction) async throws -> CMUXExtensionActionResult { - guard grantedActionScopes.contains(action.requiredScope) else { + guard grantedActionScopes.isSuperset(of: action.requiredScopes) else { return .rejected("Extension action is not granted") } return try await client.dispatch(action) diff --git a/Packages/CMUXExtensionClient/Tests/CMUXExtensionClientTests/CMUXExtensionClientTests.swift b/Packages/CMUXExtensionClient/Tests/CMUXExtensionClientTests/CMUXExtensionClientTests.swift index 7527359d1a..a85fedcca4 100644 --- a/Packages/CMUXExtensionClient/Tests/CMUXExtensionClientTests/CMUXExtensionClientTests.swift +++ b/Packages/CMUXExtensionClient/Tests/CMUXExtensionClientTests/CMUXExtensionClientTests.swift @@ -125,6 +125,41 @@ struct CMUXExtensionClientTests { #expect(accepted == .accepted) #expect(actions == [.selectWorkspace(workspaceID)]) } + + @Test + func testSessionRequiresOpenURLForURLBearingBrowserCreation() async throws { + let workspaceID = UUID() + let emptyAction = CMUXSidebarAction.createBrowserSurface(workspaceID: workspaceID, url: nil) + let urlAction = CMUXSidebarAction.createBrowserSurface(workspaceID: workspaceID, url: "https://example.com") + let recorder = ActionRecorder() + let client = CMUXSidebarHostClient( + snapshot: { + CMUXSidebarSnapshot(sequence: 1, selectedWorkspaceID: nil, workspaces: []) + }, + dispatch: { action in + await recorder.append(action) + return .accepted + } + ) + let session = try CMUXSidebarExtensionSession( + manifest: CMUXExtensionManifest( + id: "dev.example.sidebar", + displayName: "Example", + requestedActionScopes: [.createSurface, .openURL] + ), + client: client, + grantedActionScopes: [.createSurface] + ) + + let emptyBrowser = try await session.perform(emptyAction) + let urlBrowser = try await session.perform(urlAction) + let actions = await recorder.actions() + + #expect(emptyBrowser == .accepted) + #expect(!urlBrowser.accepted) + #expect(urlBrowser.message == "Extension action is not granted") + #expect(actions == [emptyAction]) + } } private actor ActionRecorder { diff --git a/Packages/CmuxExtensionKit/Sources/CmuxExtensionKit/Sidebar/CMUXSidebarAction.swift b/Packages/CmuxExtensionKit/Sources/CmuxExtensionKit/Sidebar/CMUXSidebarAction.swift index ef20e8f665..4b0a8b3ff8 100644 --- a/Packages/CmuxExtensionKit/Sources/CmuxExtensionKit/Sidebar/CMUXSidebarAction.swift +++ b/Packages/CmuxExtensionKit/Sources/CmuxExtensionKit/Sidebar/CMUXSidebarAction.swift @@ -13,7 +13,7 @@ public enum CMUXSidebarAction: Codable, Equatable, Sendable { case closeWorkspace(UUID) case selectNextWorkspace case selectPreviousWorkspace - case createTerminalSurface(workspaceID: UUID?, initialInput: String?) + case createTerminalSurface(workspaceID: UUID?) case createBrowserSurface(workspaceID: UUID?, url: String?) case selectSurface(workspaceID: UUID, surfaceID: UUID) case selectNextSurface @@ -50,4 +50,35 @@ public enum CMUXSidebarAction: Codable, Equatable, Sendable { return .openURL } } + + public var requiredScopes: Set { + switch self { + case .createWorkspace: + return [.createWorkspace] + case .selectWorkspace: + return [.selectWorkspace] + case .closeWorkspace: + return [.closeWorkspace] + case .selectNextWorkspace, .selectPreviousWorkspace: + return [.navigateWorkspace] + case .createTerminalSurface: + return [.createSurface] + case .createBrowserSurface(_, let url): + return url == nil ? [.createSurface] : [.createSurface, .openURL] + case .selectSurface: + return [.selectSurface] + case .selectNextSurface, .selectPreviousSurface: + return [.navigateSurface] + case .closeSurface: + return [.closeSurface] + case .splitTerminal: + return [.splitSurface] + case .splitBrowser(_, _, _, let url): + return url == nil ? [.splitSurface] : [.splitSurface, .openURL] + case .toggleSurfaceZoom: + return [.zoomSurface] + case .openURL: + return [.openURL] + } + } } diff --git a/Packages/CmuxExtensionKit/Sources/CmuxExtensionKit/Sidebar/CmuxSidebarHost.swift b/Packages/CmuxExtensionKit/Sources/CmuxExtensionKit/Sidebar/CmuxSidebarHost.swift index d0216bfb4a..43b8e10195 100644 --- a/Packages/CmuxExtensionKit/Sources/CmuxExtensionKit/Sidebar/CmuxSidebarHost.swift +++ b/Packages/CmuxExtensionKit/Sources/CmuxExtensionKit/Sidebar/CmuxSidebarHost.swift @@ -49,11 +49,24 @@ public struct CmuxSidebarHost { await perform(.openURL(url.absoluteString)) } + /// Requests that CMUX create a terminal surface. + /// + /// Extensions can ask CMUX to create the surface, but cannot seed shell + /// input. This keeps `.createSurface` separate from command execution. + public func createTerminalSurface(in workspaceID: UUID? = nil) async -> CMUXExtensionActionResult { + await perform(.createTerminalSurface(workspaceID: workspaceID)) + } + + /// Requests that CMUX create a terminal surface. + /// + /// The `initialInput` parameter is ignored. It remains only so early + /// sidebar extensions can compile while moving to the safer overload. + @available(*, deprecated, message: "CMUX sidebar extensions cannot seed terminal input. Use createTerminalSurface(in:) instead.") public func createTerminalSurface( in workspaceID: UUID? = nil, - initialInput: String? = nil + initialInput _: String? ) async -> CMUXExtensionActionResult { - await perform(.createTerminalSurface(workspaceID: workspaceID, initialInput: initialInput)) + await createTerminalSurface(in: workspaceID) } public func createBrowserSurface( diff --git a/Packages/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift b/Packages/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift index 15446c33ac..2d04ed247c 100644 --- a/Packages/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift +++ b/Packages/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift @@ -254,19 +254,36 @@ struct CMUXExtensionKitTests { host.refresh() let selectResult = await host.selectWorkspace(workspaceID) let closeResult = await host.closeWorkspace(workspaceID) + let terminalResult = await host.createTerminalSurface(in: workspaceID) + let browserResult = await host.createBrowserSurface(in: workspaceID, url: url) let openResult = await host.openURL(url) #expect(refreshCount == 1) #expect(selectResult.accepted) #expect(closeResult.accepted) + #expect(terminalResult.accepted) + #expect(browserResult.accepted) #expect(openResult.accepted) #expect(actions == [ .selectWorkspace(workspaceID), .closeWorkspace(workspaceID), + .createTerminalSurface(workspaceID: workspaceID), + .createBrowserSurface(workspaceID: workspaceID, url: "https://example.com/pr/1"), .openURL("https://example.com/pr/1"), ]) } + @Test + func testURLBearingBrowserActionsRequireOpenURLScope() { + let workspaceID = UUID(uuidString: "77777777-7777-7777-7777-777777777777")! + let surfaceID = UUID(uuidString: "88888888-8888-8888-8888-888888888888")! + + #expect(CMUXSidebarAction.createBrowserSurface(workspaceID: workspaceID, url: nil).requiredScopes == [.createSurface]) + #expect(CMUXSidebarAction.createBrowserSurface(workspaceID: workspaceID, url: "https://example.com").requiredScopes == [.createSurface, .openURL]) + #expect(CMUXSidebarAction.splitBrowser(workspaceID: workspaceID, surfaceID: surfaceID, direction: .right, url: nil).requiredScopes == [.splitSurface]) + #expect(CMUXSidebarAction.splitBrowser(workspaceID: workspaceID, surfaceID: surfaceID, direction: .right, url: "https://example.com").requiredScopes == [.splitSurface, .openURL]) + } + @Test @MainActor func testSidebarHostCancelsPendingAsyncAction() async { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 09f418e9dd..74cc4c4b93 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -162568,6 +162568,40 @@ } } }, + "sidebar.extensions.action.surfaceCreateRejected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface could not be created" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスを作成できませんでした" + } + } + } + }, + "sidebar.extensions.action.surfaceNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスが見つかりません" + } + } + } + }, "sidebar.extensions.action.urlRejected": { "extractionState": "manual", "localizations": { @@ -162687,6 +162721,23 @@ } } }, + "sidebar.extensions.access.statusLimited": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Limited" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "制限中" + } + } + } + }, "sidebar.extensions.access.title": { "extractionState": "manual", "localizations": { @@ -162710,13 +162761,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "CMUX could not load this extension's manifest. No workspace data or actions were shared." + "value": "CMUX could not load this extension's configuration. No workspace data or actions were shared." } }, "ja": { "stringUnit": { "state": "translated", - "value": "CMUXはこの拡張機能のマニフェストを読み込めませんでした。ワークスペースデータや操作は共有されていません。" + "value": "CMUXはこの拡張機能の設定を読み込めませんでした。ワークスペースデータや操作は共有されていません。" } } } @@ -162727,13 +162778,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "CMUX lost the extension's XPC connection. No workspace data or actions are being shared." + "value": "CMUX lost the extension connection. No workspace data or actions are being shared." } }, "ja": { "stringUnit": { "state": "translated", - "value": "CMUXは拡張機能のXPC接続を失いました。ワークスペースデータや操作は共有されていません。" + "value": "CMUXは拡張機能との接続を失いました。ワークスペースデータや操作は共有されていません。" } } } @@ -162744,13 +162795,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "CMUX rejected this extension's manifest. No workspace data or actions were shared." + "value": "CMUX rejected this extension's configuration. No workspace data or actions were shared." } }, "ja": { "stringUnit": { "state": "translated", - "value": "CMUXはこの拡張機能のマニフェストを拒否しました。ワークスペースデータや操作は共有されていません。" + "value": "CMUXはこの拡張機能の設定を拒否しました。ワークスペースデータや操作は共有されていません。" } } } @@ -162761,13 +162812,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "CMUX did not receive this extension's manifest in time. No workspace data or actions are being shared." + "value": "CMUX did not receive this extension's configuration in time. No workspace data or actions are being shared." } }, "ja": { "stringUnit": { "state": "translated", - "value": "CMUXはこの拡張機能のマニフェストを時間内に受信しませんでした。ワークスペースデータや操作は共有されていません。" + "value": "CMUXはこの拡張機能の設定を時間内に受信しませんでした。ワークスペースデータや操作は共有されていません。" } } } @@ -162778,13 +162829,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "CMUX did not receive a sidebar extension manifest, so no workspace data or actions were shared." + "value": "CMUX did not receive a sidebar extension configuration, so no workspace data or actions were shared." } }, "ja": { "stringUnit": { "state": "translated", - "value": "CMUXはサイドバー拡張機能のマニフェストを受信しなかったため、ワークスペースデータや操作は共有されていません。" + "value": "CMUXはサイドバー拡張機能の設定を受信しなかったため、ワークスペースデータや操作は共有されていません。" } } } @@ -162812,13 +162863,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Blocked, manifest unavailable" + "value": "Blocked, configuration unavailable" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ブロック中、マニフェストを利用できません" + "value": "ブロック中、設定を利用できません" } } } @@ -162829,13 +162880,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Blocked, invalid manifest" + "value": "Blocked, invalid configuration" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ブロック中、マニフェストが無効です" + "value": "ブロック中、設定が無効です" } } } @@ -162846,13 +162897,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Blocked, manifest timed out" + "value": "Blocked, configuration timed out" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ブロック中、マニフェストがタイムアウトしました" + "value": "ブロック中、設定がタイムアウトしました" } } } @@ -162863,13 +162914,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Blocked, missing manifest" + "value": "Blocked, missing configuration" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ブロック中、マニフェストがありません" + "value": "ブロック中、設定がありません" } } } @@ -163033,13 +163084,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Manifest" + "value": "Configuration" } }, "ja": { "stringUnit": { "state": "translated", - "value": "マニフェスト" + "value": "設定" } } } @@ -163084,13 +163135,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "ExtensionKit host, XPC connection" + "value": "Secure extension connection" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ExtensionKitホスト、XPC接続" + "value": "安全な拡張機能接続" } } } @@ -163135,13 +163186,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Hosted out of process" + "value": "Connected" } }, "ja": { "stringUnit": { "state": "translated", - "value": "別プロセスでホスト中" + "value": "接続済み" } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c661267d45..c107647e46 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11263,10 +11263,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowDecorationsController.apply(to: window) } - func updateTitlebarAccessorySidebarTrailingEdge(_ edge: CGFloat, for window: NSWindow) { - titlebarAccessoryController.updateSidebarTrailingEdge(edge, for: window) - } - func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) } diff --git a/Sources/CMUXInstalledExtensionSidebarHostView.swift b/Sources/CMUXInstalledExtensionSidebarHostView.swift index 88409dfa73..a2bc4701f8 100644 --- a/Sources/CMUXInstalledExtensionSidebarHostView.swift +++ b/Sources/CMUXInstalledExtensionSidebarHostView.swift @@ -205,7 +205,6 @@ struct CMUXInstalledExtensionSidebarHostView: View { } } else { VStack(alignment: .leading, spacing: 10) { - extensionControlStrip(activeIdentity: nil) if isLoading { ProgressView() .controlSize(.small) @@ -311,46 +310,56 @@ struct CMUXInstalledExtensionSidebarHostView: View { @ViewBuilder private func extensionEmptyActions() -> some View { - HStack(spacing: 8) { - if enabledIdentities.count > 1 { - Menu { - ForEach(enabledIdentities, id: \.bundleIdentifier) { enabledIdentity in - Button { - selectExtension(enabledIdentity) - } label: { - Label(enabledIdentity.localizedName, systemImage: "puzzlepiece.extension") - } - } - } label: { - Label( - String(localized: "sidebar.extensions.choose.action", defaultValue: "Choose Extension"), - systemImage: "puzzlepiece.extension" - ) - } - .menuStyle(.button) - .controlSize(.small) + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + extensionEmptyActionButtons() + } + VStack(alignment: .leading, spacing: 8) { + extensionEmptyActionButtons() } + } + } - Button { - presentExtensionBrowser() + @ViewBuilder + private func extensionEmptyActionButtons() -> some View { + if enabledIdentities.count > 1 { + Menu { + ForEach(enabledIdentities, id: \.bundleIdentifier) { enabledIdentity in + Button { + selectExtension(enabledIdentity) + } label: { + Label(enabledIdentity.localizedName, systemImage: "puzzlepiece.extension") + } + } } label: { Label( - String(localized: "sidebar.extensions.manage.short", defaultValue: "Manage"), + String(localized: "sidebar.extensions.choose.action", defaultValue: "Choose Extension"), systemImage: "puzzlepiece.extension" ) } + .menuStyle(.button) .controlSize(.small) + } - Button { - onUseDefaultSidebar() - } label: { - Label( - String(localized: "sidebar.extensions.useDefault.short", defaultValue: "Use Default"), - systemImage: "sidebar.left" - ) - } - .controlSize(.small) + Button { + presentExtensionBrowser() + } label: { + Label( + String(localized: "sidebar.extensions.manage.short", defaultValue: "Manage"), + systemImage: "puzzlepiece.extension" + ) + } + .controlSize(.small) + + Button { + onUseDefaultSidebar() + } label: { + Label( + String(localized: "sidebar.extensions.useDefault.short", defaultValue: "Use Default"), + systemImage: "sidebar.left" + ) } + .controlSize(.small) } private func extensionControlStrip(activeIdentity: AppExtensionIdentity?) -> some View { @@ -358,10 +367,17 @@ struct CMUXInstalledExtensionSidebarHostView: View { extensionIdentityControl(activeIdentity: activeIdentity) Spacer(minLength: 8) if effectiveGrant?.needsAdditionalApproval == true { - Image(systemName: "lock") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - .help(String(localized: "sidebar.extensions.access.statusLimited.help", defaultValue: "This extension has limited access.")) + Button { + isShowingAccessReview = true + } label: { + Label( + String(localized: "sidebar.extensions.access.statusLimited", defaultValue: "Limited"), + systemImage: "lock" + ) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .help(String(localized: "sidebar.extensions.access.statusLimited.help", defaultValue: "This extension has limited access.")) } Button { isShowingExtensionDetails = true @@ -392,7 +408,7 @@ struct CMUXInstalledExtensionSidebarHostView: View { Text(activeIdentity?.localizedName ?? String(localized: "sidebar.provider.extensions.title", defaultValue: "Extension Sidebar")) .font(.system(size: 13, weight: .semibold)) .lineLimit(1) - Text(String(localized: "sidebar.extensions.details.runtime", defaultValue: "ExtensionKit host, XPC connection")) + Text(String(localized: "sidebar.extensions.details.runtime", defaultValue: "Secure extension connection")) .font(.system(size: 11)) .foregroundStyle(.secondary) } @@ -403,7 +419,7 @@ struct CMUXInstalledExtensionSidebarHostView: View { title: String(localized: "sidebar.extensions.details.status", defaultValue: "Status"), value: blockedManifestReason.map(blockedStatusText(reason:)) ?? (activeIdentity == nil ? String(localized: "sidebar.extensions.details.statusWaiting", defaultValue: "Waiting for an enabled extension") - : String(localized: "sidebar.extensions.details.statusActive", defaultValue: "Hosted out of process")) + : String(localized: "sidebar.extensions.details.statusActive", defaultValue: "Connected")) ) if let activeIdentity { detailRow( @@ -413,7 +429,7 @@ struct CMUXInstalledExtensionSidebarHostView: View { } if let manifest = effectiveGrant?.manifest { detailRow( - title: String(localized: "sidebar.extensions.details.manifest", defaultValue: "Manifest"), + title: String(localized: "sidebar.extensions.details.manifest", defaultValue: "Configuration"), value: "\(manifest.id) · API \(manifest.minimumAPIVersion.major).\(manifest.minimumAPIVersion.minor)" ) } @@ -479,39 +495,13 @@ struct CMUXInstalledExtensionSidebarHostView: View { .font(.system(size: 12)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - HStack(spacing: 8) { - Button { - blockedManifestReason = nil - effectiveGrant = nil - xpcHost.invalidate() - hostReloadToken &+= 1 - } label: { - Label( - String(localized: "sidebar.extensions.retry", defaultValue: "Try Again"), - systemImage: "arrow.clockwise" - ) + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + blockedExtensionActionButtons() } - .controlSize(.small) - - Button { - onUseDefaultSidebar() - } label: { - Label( - String(localized: "sidebar.extensions.useDefault.short", defaultValue: "Use Default"), - systemImage: "sidebar.left" - ) + VStack(alignment: .leading, spacing: 8) { + blockedExtensionActionButtons() } - .controlSize(.small) - - Button { - presentExtensionBrowser() - } label: { - Label( - String(localized: "sidebar.extensions.manage.short", defaultValue: "Manage"), - systemImage: "puzzlepiece.extension" - ) - } - .controlSize(.small) } } .padding(.horizontal, 14) @@ -520,33 +510,68 @@ struct CMUXInstalledExtensionSidebarHostView: View { .accessibilityIdentifier("CMUXExtensionSidebarBlockedState") } + @ViewBuilder + private func blockedExtensionActionButtons() -> some View { + Button { + blockedManifestReason = nil + effectiveGrant = nil + xpcHost.invalidate() + hostReloadToken &+= 1 + } label: { + Label( + String(localized: "sidebar.extensions.retry", defaultValue: "Try Again"), + systemImage: "arrow.clockwise" + ) + } + .controlSize(.small) + + Button { + onUseDefaultSidebar() + } label: { + Label( + String(localized: "sidebar.extensions.useDefault.short", defaultValue: "Use Default"), + systemImage: "sidebar.left" + ) + } + .controlSize(.small) + + Button { + presentExtensionBrowser() + } label: { + Label( + String(localized: "sidebar.extensions.manage.short", defaultValue: "Manage"), + systemImage: "puzzlepiece.extension") + } + .controlSize(.small) + } + private func blockedStatusText(reason: String) -> String { switch reason { case "connectionInterrupted": return String(localized: "sidebar.extensions.blocked.status.connectionInterrupted", defaultValue: "Blocked, connection interrupted") case "manifestTimedOut": - return String(localized: "sidebar.extensions.blocked.status.manifestTimedOut", defaultValue: "Blocked, manifest timed out") + return String(localized: "sidebar.extensions.blocked.status.manifestTimedOut", defaultValue: "Blocked, configuration timed out") case "missingManifest": - return String(localized: "sidebar.extensions.blocked.status.missingManifest", defaultValue: "Blocked, missing manifest") + return String(localized: "sidebar.extensions.blocked.status.missingManifest", defaultValue: "Blocked, missing configuration") case "invalidManifest": - return String(localized: "sidebar.extensions.blocked.status.invalidManifest", defaultValue: "Blocked, invalid manifest") + return String(localized: "sidebar.extensions.blocked.status.invalidManifest", defaultValue: "Blocked, invalid configuration") default: - return String(localized: "sidebar.extensions.blocked.status.failedManifest", defaultValue: "Blocked, manifest unavailable") + return String(localized: "sidebar.extensions.blocked.status.failedManifest", defaultValue: "Blocked, configuration unavailable") } } private func blockedDetailText(reason: String) -> String { switch reason { case "connectionInterrupted": - return String(localized: "sidebar.extensions.blocked.detail.connectionInterrupted", defaultValue: "CMUX lost the extension's XPC connection. No workspace data or actions are being shared.") + return String(localized: "sidebar.extensions.blocked.detail.connectionInterrupted", defaultValue: "CMUX lost the extension connection. No workspace data or actions are being shared.") case "manifestTimedOut": - return String(localized: "sidebar.extensions.blocked.detail.manifestTimedOut", defaultValue: "CMUX did not receive this extension's manifest in time. No workspace data or actions are being shared.") + return String(localized: "sidebar.extensions.blocked.detail.manifestTimedOut", defaultValue: "CMUX did not receive this extension's configuration in time. No workspace data or actions are being shared.") case "missingManifest": - return String(localized: "sidebar.extensions.blocked.detail.missingManifest", defaultValue: "CMUX did not receive a sidebar extension manifest, so no workspace data or actions were shared.") + return String(localized: "sidebar.extensions.blocked.detail.missingManifest", defaultValue: "CMUX did not receive a sidebar extension configuration, so no workspace data or actions were shared.") case "invalidManifest": - return String(localized: "sidebar.extensions.blocked.detail.invalidManifest", defaultValue: "CMUX rejected this extension's manifest. No workspace data or actions were shared.") + return String(localized: "sidebar.extensions.blocked.detail.invalidManifest", defaultValue: "CMUX rejected this extension's configuration. No workspace data or actions were shared.") default: - return String(localized: "sidebar.extensions.blocked.detail.failedManifest", defaultValue: "CMUX could not load this extension's manifest. No workspace data or actions were shared.") + return String(localized: "sidebar.extensions.blocked.detail.failedManifest", defaultValue: "CMUX could not load this extension's configuration. No workspace data or actions were shared.") } } @@ -571,25 +596,34 @@ struct CMUXInstalledExtensionSidebarHostView: View { ForEach(effectiveGrant.manifest.requestedScopes, id: \.self) { scope in permissionRow( title: scope.displayName, + detail: permissionDescription(scope: scope), isGranted: effectiveGrant.readScopes.contains(scope) ) } ForEach(effectiveGrant.manifest.requestedActionScopes, id: \.self) { scope in permissionRow( title: scope.displayName, + detail: permissionDescription(actionScope: scope), isGranted: effectiveGrant.actionScopes.contains(scope) ) } } } - private func permissionRow(title: String, isGranted: Bool) -> some View { - HStack(spacing: 6) { + private func permissionRow(title: String, detail: String, isGranted: Bool) -> some View { + HStack(alignment: .top, spacing: 6) { Image(systemName: isGranted ? "checkmark.circle.fill" : "circle") .font(.system(size: 11, weight: .medium)) .foregroundStyle(isGranted ? .green : .secondary) - Text(title) - .font(.system(size: 11)) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 11, weight: .medium)) + Text(detail) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } Spacer() Text(isGranted ? String(localized: "sidebar.extensions.details.granted", defaultValue: "Granted") @@ -668,19 +702,13 @@ struct CMUXInstalledExtensionSidebarHostView: View { } } .padding(.top, 2) - HStack(spacing: 8) { - Button { - isShowingAccessReview = true - } label: { - Text(String(localized: "sidebar.extensions.access.review", defaultValue: "Review Access...")) + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + limitedAccessActionButtons(identity: identity, effectiveGrant: effectiveGrant) } - .controlSize(.small) - Button { - keepLimitedAccess(identity: identity, effectiveGrant: effectiveGrant) - } label: { - Text(String(localized: "sidebar.extensions.access.keepLimited", defaultValue: "Keep Limited")) + VStack(alignment: .leading, spacing: 8) { + limitedAccessActionButtons(identity: identity, effectiveGrant: effectiveGrant) } - .controlSize(.small) } } .padding(.horizontal, 12) @@ -689,6 +717,25 @@ struct CMUXInstalledExtensionSidebarHostView: View { .background(Color(nsColor: .controlBackgroundColor).opacity(0.88)) } + @ViewBuilder + private func limitedAccessActionButtons( + identity: AppExtensionIdentity, + effectiveGrant: CMUXSidebarExtensionEffectiveGrant + ) -> some View { + Button { + isShowingAccessReview = true + } label: { + Text(String(localized: "sidebar.extensions.access.review", defaultValue: "Review Access...")) + } + .controlSize(.small) + Button { + keepLimitedAccess(identity: identity, effectiveGrant: effectiveGrant) + } label: { + Text(String(localized: "sidebar.extensions.access.keepLimited", defaultValue: "Keep Limited")) + } + .controlSize(.small) + } + private func accessReviewSheet( identity: AppExtensionIdentity, effectiveGrant: CMUXSidebarExtensionEffectiveGrant @@ -717,7 +764,7 @@ struct CMUXInstalledExtensionSidebarHostView: View { VStack(alignment: .leading, spacing: 8) { detailRow( - title: String(localized: "sidebar.extensions.details.manifest", defaultValue: "Manifest"), + title: String(localized: "sidebar.extensions.details.manifest", defaultValue: "Configuration"), value: "\(effectiveGrant.manifest.id) · API \(effectiveGrant.manifest.minimumAPIVersion.major).\(effectiveGrant.manifest.minimumAPIVersion.minor)" ) Divider() @@ -1285,7 +1332,7 @@ private final class CMUXSidebarExtensionHostXPC { { [weak self] action in guard let self, self.currentManifest != nil, - self.allowedActionScopes.contains(action.requiredScope) else { + self.allowedActionScopes.isSuperset(of: action.requiredScopes) else { return CMUXExtensionActionResult( accepted: false, message: String(localized: "sidebar.extensions.action.scopeRejected", defaultValue: "Extension action is not granted") diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index aaef85bc9b..971e920353 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1580,10 +1580,7 @@ struct ContentView: View { private static let minimumTerminalWidthWithRightSidebar: CGFloat = 360 private var minimumSidebarWidth: CGFloat { - max( - CGFloat(SessionPersistencePolicy.sanitizedMinimumSidebarWidth(sidebarMinimumWidthSetting)), - TitlebarControlsLayoutMetrics.minimumSidebarWidth(config: titlebarControlsConfig) - ) + CGFloat(SessionPersistencePolicy.sanitizedMinimumSidebarWidth(sidebarMinimumWidthSetting)) } private enum SidebarResizerHandle: Hashable { @@ -1701,14 +1698,6 @@ struct ContentView: View { ) } - private func syncTitlebarControlsSidebarTrailingEdge() { - guard let observedWindow else { return } - AppDelegate.shared?.updateTitlebarAccessorySidebarTrailingEdge( - sidebarState.isVisible ? normalizedSidebarWidth(sidebarWidth) : 0, - for: observedWindow - ) - } - private func resolvedRightSidebarAvailableWidth(_ availableWidth: CGFloat? = nil) -> CGFloat { if let availableWidth { return availableWidth @@ -2116,12 +2105,6 @@ struct ContentView: View { .accessibilityHidden(sidebarSelectionState.selection != .notifications) } .padding(.top, effectiveTitlebarPadding) - .overlay(alignment: .top) { - if !isMinimalMode { - // Titlebar overlay is only over terminal content, not the sidebar. - customTitlebar(appearance: appearance) - } - } } private func terminalContentWithSidebarDropOverlay(appearance: WindowAppearanceSnapshot) -> some View { @@ -2325,10 +2308,6 @@ struct ContentView: View { .offset(y: -TitlebarControlsVisualMetrics.verticalLift) } - private var titlebarControlsConfig: TitlebarControlsStyleConfig { - (TitlebarControlsStyle(rawValue: titlebarControlsStyleRawValue) ?? .classic).config - } - private var titlebarDebugChromeSnapshot: MinimalModeTitlebarDebugSnapshot { MinimalModeTitlebarDebugSnapshot( leftControlsLeadingInset: MinimalModeTitlebarDebugSettings.clamped( @@ -2365,27 +2344,25 @@ struct ContentView: View { fullscreenControls } - if !sidebarState.isVisible { - // Draggable folder icon + focused command name - if let directory = focusedDirectory { - DetachedFolderDragIcon(directory: directory) - .frame(width: 16, height: 16) - .padding(.leading, -6) - } - - Text(titlebarText) - .font(.system(size: 13, weight: .bold)) - .foregroundColor(fakeTitlebarTextColor(appearance: appearance)) - .lineLimit(1) - .allowsHitTesting(false) + // Draggable folder icon + focused command name + if let directory = focusedDirectory { + DetachedFolderDragIcon(directory: directory) + .frame(width: 16, height: 16) + .padding(.leading, -6) } + Text(titlebarText) + .font(.system(size: 13, weight: .bold)) + .foregroundColor(fakeTitlebarTextColor(appearance: appearance)) + .lineLimit(1) + .allowsHitTesting(false) + Spacer() } .frame(height: titlebarContentHeight) .padding(.top, 2) - .padding(.leading, (isFullScreen && !sidebarState.isVisible) ? 8 : (sidebarState.isVisible ? 12 : titlebarLeadingInset)) + .padding(.leading, (isFullScreen && !sidebarState.isVisible) ? 8 : (sidebarState.isVisible ? sidebarWidth + 12 : titlebarLeadingInset)) .padding(.trailing, 8) } .frame(height: WindowChromeMetrics.appTitlebarHeight) @@ -2397,6 +2374,23 @@ struct ContentView: View { } } + private func workspaceTitlebarBand(appearance: WindowAppearanceSnapshot) -> some View { + Color.clear + .frame(height: WindowChromeMetrics.appTitlebarHeight) + .frame(maxWidth: .infinity) + .overlay(alignment: .topLeading) { + customTitlebar(appearance: appearance) + } + .overlay(alignment: .topLeading) { + if isFullScreen && sidebarState.isVisible { + fullscreenControls + .environment(\.colorScheme, appearance.sidebarContentColorScheme) + .padding(.leading, 10) + .padding(.top, 4) + } + } + } + private func syncTrafficLightInset() { let inset: CGFloat = (isMinimalMode && !sidebarState.isVisible && !isFullScreen) ? CGFloat(titlebarDebugChromeSnapshot.trafficLightTabBarLeadingInset) @@ -2670,16 +2664,13 @@ struct ContentView: View { .allowsHitTesting(false) contentAndSidebarLayout(appearance: appearance) + + if !isMinimalMode { + workspaceTitlebarBand(appearance: appearance) + .zIndex(100) + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .overlay(alignment: .topLeading) { - if isFullScreen && sidebarState.isVisible && !isMinimalMode { - fullscreenControls - .environment(\.colorScheme, appearance.sidebarContentColorScheme) - .padding(.leading, 10) - .padding(.top, 4) - } - } .frame(minWidth: CGFloat(SessionPersistencePolicy.minimumWindowWidth), minHeight: CGFloat(SessionPersistencePolicy.minimumWindowHeight)) .background(Color.clear) .background( @@ -2697,7 +2688,6 @@ struct ContentView: View { if abs(sidebarWidth - restoredWidth) > 0.5 { sidebarWidth = restoredWidth } - syncTitlebarControlsSidebarTrailingEdge() if abs(sidebarState.persistedWidth - restoredWidth) > 0.5 { sidebarState.persistedWidth = restoredWidth } @@ -3176,7 +3166,6 @@ struct ContentView: View { sidebarWidth = sanitized return } - syncTitlebarControlsSidebarTrailingEdge() if abs(sidebarState.persistedWidth - sanitized) > 0.5 { sidebarState.persistedWidth = sanitized } @@ -3191,7 +3180,12 @@ struct ContentView: View { updateSidebarResizerBandState() }) - view = AnyView(view.onChange(of: sidebarState.isVisible) { isVisible in + view = AnyView(view.onChange(of: titlebarControlsStyleRawValue) { _ in + clampSidebarWidthIfNeeded() + updateSidebarResizerBandState() + }) + + view = AnyView(view.onChange(of: sidebarState.isVisible) { _, isVisible in setMinimalModeSidebarTitlebarControlsAvailable(isVisible, in: observedWindow) if let observedWindow { AppDelegate.shared?.applyWindowDecorations(to: observedWindow) @@ -3199,7 +3193,6 @@ struct ContentView: View { schedulePortalGeometrySynchronize() updateSidebarResizerBandState() syncTrafficLightInset() - syncTitlebarControlsSidebarTrailingEdge() }) view = AnyView(view.onChange(of: fileExplorerState.isVisible) { isVisible in @@ -3290,7 +3283,6 @@ struct ContentView: View { syncCommandPaletteDebugStateForObservedWindow() installSidebarResizerPointerMonitorIfNeeded() updateSidebarResizerBandState() - syncTitlebarControlsSidebarTrailingEdge() } } @@ -10983,10 +10975,10 @@ struct VerticalTabsSidebar: View { } } - private func handleCMUXSidebarExtensionAction( - _ action: CMUXSidebarAction - ) -> CMUXExtensionActionResult { - switch action { + private func handleCMUXSidebarExtensionAction( + _ action: CMUXSidebarAction + ) -> CMUXExtensionActionResult { + switch action { case .createWorkspace(let title, let workingDirectory, let select): let workspace = tabManager.addWorkspace( title: title, @@ -10996,9 +10988,9 @@ struct VerticalTabsSidebar: View { ) return CMUXExtensionActionResult(accepted: true, message: workspace.id.uuidString) - case .selectWorkspace(let workspaceId): - guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { - return CMUXExtensionActionResult( + case .selectWorkspace(let workspaceId): + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + return CMUXExtensionActionResult( accepted: false, message: String(localized: "sidebar.extensions.action.workspaceNotFound", defaultValue: "Workspace not found") ) @@ -11012,8 +11004,8 @@ struct VerticalTabsSidebar: View { accepted: false, message: String(localized: "sidebar.extensions.action.closeRejected", defaultValue: "Workspace could not be closed") ) - } - return .accepted + } + return .accepted case .selectNextWorkspace: tabManager.selectNextTab() @@ -11023,26 +11015,29 @@ struct VerticalTabsSidebar: View { tabManager.selectPreviousTab() return .accepted - case .createTerminalSurface(let workspaceId, let initialInput): + case .createTerminalSurface(let workspaceId): guard let workspace = workspaceId.flatMap({ id in tabManager.tabs.first(where: { $0.id == id }) }) ?? tabManager.selectedWorkspace else { return .rejected(String(localized: "sidebar.extensions.action.workspaceNotFound", defaultValue: "Workspace not found")) } if tabManager.selectedTabId != workspace.id { tabManager.selectWorkspace(workspace) } - let panel = workspace.newTerminalSurfaceInFocusedPane(focus: true, initialInput: initialInput) + let panel = workspace.newTerminalSurfaceInFocusedPane(focus: true, initialInput: nil) return panel.map { CMUXExtensionActionResult(accepted: true, message: $0.id.uuidString) } ?? .rejected(String(localized: "sidebar.extensions.action.surfaceCreateRejected", defaultValue: "Surface could not be created")) case .createBrowserSurface(let workspaceId, let urlString): + let validatedURL = cmuxSidebarExtensionOptionalHTTPURL(from: urlString) + guard validatedURL.accepted else { + return .rejected(String(localized: "sidebar.extensions.action.urlRejected", defaultValue: "URL could not be opened")) + } guard let workspace = workspaceId.flatMap({ id in tabManager.tabs.first(where: { $0.id == id }) }) ?? tabManager.selectedWorkspace else { return .rejected(String(localized: "sidebar.extensions.action.workspaceNotFound", defaultValue: "Workspace not found")) } if tabManager.selectedTabId != workspace.id { tabManager.selectWorkspace(workspace) } - let url = urlString.flatMap(URL.init(string:)) - let panelId = tabManager.createBrowserSplit(direction: .right, url: url) + let panelId = tabManager.createBrowserSplit(direction: .right, url: validatedURL.url) return panelId.map { CMUXExtensionActionResult(accepted: true, message: $0.uuidString) } ?? .rejected(String(localized: "sidebar.extensions.action.surfaceCreateRejected", defaultValue: "Surface could not be created")) @@ -11064,9 +11059,12 @@ struct VerticalTabsSidebar: View { return .accepted case .closeSurface(let workspaceId, let surfaceId): - guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return .rejected(String(localized: "sidebar.extensions.action.workspaceNotFound", defaultValue: "Workspace not found")) } + guard workspace.panels[surfaceId] != nil else { + return .rejected(String(localized: "sidebar.extensions.action.surfaceNotFound", defaultValue: "Surface not found")) + } tabManager.closePanelWithConfirmation(tabId: workspaceId, surfaceId: surfaceId) return .accepted @@ -11078,6 +11076,10 @@ struct VerticalTabsSidebar: View { return CMUXExtensionActionResult(accepted: true, message: panelId.uuidString) case .splitBrowser(let workspaceId, let surfaceId, let direction, let urlString): + let validatedURL = cmuxSidebarExtensionOptionalHTTPURL(from: urlString) + guard validatedURL.accepted else { + return .rejected(String(localized: "sidebar.extensions.action.urlRejected", defaultValue: "URL could not be opened")) + } guard let splitDirection = splitDirection(from: direction), let tab = tabManager.tabs.first(where: { $0.id == workspaceId }), tab.panels[surfaceId] != nil else { @@ -11085,7 +11087,7 @@ struct VerticalTabsSidebar: View { } tabManager.selectWorkspace(tab) tab.focusPanel(surfaceId) - let panelId = tabManager.createBrowserSplit(direction: splitDirection, url: urlString.flatMap(URL.init(string:))) + let panelId = tabManager.createBrowserSplit(direction: splitDirection, url: validatedURL.url) return panelId.map { CMUXExtensionActionResult(accepted: true, message: $0.uuidString) } ?? .rejected(String(localized: "sidebar.extensions.action.surfaceCreateRejected", defaultValue: "Surface could not be created")) @@ -11096,9 +11098,7 @@ struct VerticalTabsSidebar: View { return .accepted case .openURL(let urlString): - guard let url = URL(string: urlString), - let scheme = url.scheme?.lowercased(), - ["https", "http"].contains(scheme), + guard let url = cmuxSidebarExtensionRequiredHTTPURL(from: urlString), NSWorkspace.shared.open(url) else { return CMUXExtensionActionResult( accepted: false, @@ -11106,8 +11106,29 @@ struct VerticalTabsSidebar: View { ) } return .accepted - } - } + } + } + + private func cmuxSidebarExtensionOptionalHTTPURL(from urlString: String?) -> (url: URL?, accepted: Bool) { + guard let urlString, !urlString.isEmpty else { + return (nil, true) + } + guard let url = cmuxSidebarExtensionRequiredHTTPURL(from: urlString) else { + return (nil, false) + } + return (url, true) + } + + private func cmuxSidebarExtensionRequiredHTTPURL(from urlString: String) -> URL? { + guard let url = URL(string: urlString), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host, + !host.isEmpty else { + return nil + } + return url + } private func splitDirection(from direction: CMUXSplitDirection) -> SplitDirection? { switch direction { diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 8e4e14ba21..b3a35fc9d0 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -15,8 +15,8 @@ enum SessionSnapshotSchema { enum SessionPersistencePolicy { static let sidebarMinimumWidthKey = "sidebarMinimumWidth" static let defaultSidebarWidth: Double = 220 - static let defaultMinimumSidebarWidth: Double = 190 - static let minimumSidebarWidth: Double = 190 + static let defaultMinimumSidebarWidth: Double = 216 + static let minimumSidebarWidth: Double = 216 static let sidebarMinimumWidthRange: ClosedRange = 120...260 static let maximumSidebarWidth: Double = 600 static let minimumWindowWidth: Double = 300 diff --git a/Sources/Update/MinimalModeSidebarControls.swift b/Sources/Update/MinimalModeSidebarControls.swift index 7a777e240a..8abb799044 100644 --- a/Sources/Update/MinimalModeSidebarControls.swift +++ b/Sources/Update/MinimalModeSidebarControls.swift @@ -27,7 +27,7 @@ struct MinimalModeSidebarControlActionProxyView: NSViewRepresentable { } enum TitlebarControlsHitRegions { - static let outerLeadingPadding: CGFloat = 4 + static let outerLeadingPadding: CGFloat = 0 static let buttonCount = MinimalModeSidebarControlActionSlot.allCases.count static func buttonXRanges(config: TitlebarControlsStyleConfig) -> [ClosedRange] { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 1bcda34828..c1bfba8402 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -431,30 +431,13 @@ enum TitlebarShortcutHintActionSlot: Int, CaseIterable { } } - var titlebarButtonIndex: Int { - switch self { - case .toggleSidebar: - return 0 - case .showNotifications: - return 1 - case .newTab: - return 2 - case .focusHistoryBack: - return 3 - case .focusHistoryForward: - return 4 - } - } } enum TitlebarControlsLayoutMetrics { static let outerLeadingPadding: CGFloat = TitlebarControlsHitRegions.outerLeadingPadding - static let sidebarTrailingPadding: CGFloat = 2 - static let hintRightSafetyShift: CGFloat = 6 - static let hintTrailingBaseInset: CGFloat = 4 - static let extraButtonCount = 0 + static let hintRightSafetyShift: CGFloat = 10 + static let hintTrailingBaseInset: CGFloat = 8 static let trafficLightGap: CGFloat = 2 - static let trafficLightClusterWidth: CGFloat = 48 static func hintTrailingInset(titlebarShortcutHintXOffset: Double = ShortcutHintDebugSettings.defaultTitlebarHintX) -> CGFloat { max(0, ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)) @@ -463,7 +446,7 @@ enum TitlebarControlsLayoutMetrics { } static func buttonRowWidth(config: TitlebarControlsStyleConfig) -> CGFloat { - let buttonCount = CGFloat(TitlebarShortcutHintActionSlot.allCases.count + extraButtonCount) + let buttonCount = CGFloat(TitlebarShortcutHintActionSlot.allCases.count) let gapCount = max(0, buttonCount - 1) return (buttonCount * config.buttonSize) + (gapCount * config.spacing) } @@ -490,32 +473,16 @@ enum TitlebarControlsLayoutMetrics { } static func leadingOffset( - contentWidth: CGFloat, trafficLightFrame: NSRect?, - debugSnapshot: MinimalModeTitlebarDebugSnapshot, - sidebarTrailingEdge: CGFloat = 0 + debugSnapshot: MinimalModeTitlebarDebugSnapshot ) -> CGFloat { let debugOffset = MinimalModeTitlebarDebugSettings.leftControlsXOffset( leadingInset: debugSnapshot.leftControlsLeadingInset ) - let minimumOffset: CGFloat - if let trafficLightFrame, !trafficLightFrame.isEmpty { - minimumOffset = max(debugOffset, trafficLightFrame.maxX + trafficLightGap) - } else { - minimumOffset = debugOffset + guard let trafficLightFrame, !trafficLightFrame.isEmpty else { + return debugOffset } - - guard sidebarTrailingEdge > 0, contentWidth > 0 else { - return minimumOffset - } - return max(minimumOffset, sidebarTrailingEdge - contentWidth - sidebarTrailingPadding) - } - - static func minimumSidebarWidth(config: TitlebarControlsStyleConfig) -> CGFloat { - trafficLightClusterWidth - + trafficLightGap - + contentSize(config: config).width - + sidebarTrailingPadding + return max(debugOffset, trafficLightFrame.maxX + trafficLightGap) } static func yOffset( @@ -1085,7 +1052,7 @@ struct TitlebarControlsView: View { } private func titlebarButtonRightEdge(for slot: TitlebarShortcutHintActionSlot, config: TitlebarControlsStyleConfig) -> CGFloat { - let index = CGFloat(slot.titlebarButtonIndex) + let index = CGFloat(slot.rawValue) return (index + 1) * config.buttonSize + index * config.spacing } @@ -1866,7 +1833,6 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private var windowGeometryObservers: [NSObjectProtocol] = [] private let viewModel = TitlebarControlsViewModel() private var userDefaultsObserver: NSObjectProtocol? - private var sidebarTrailingEdge: CGFloat = 0 var popoverIsShownForTesting: Bool { notificationsPopover.isShown } private var showsWorkspaceTitlebar: Bool { !WorkspacePresentationModeSettings.isMinimal() } @@ -2035,13 +2001,9 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont titlebarHeight: titlebarHeight ) let debugSnapshot = MinimalModeTitlebarDebugSettings.snapshot() - let accessoryOriginXInWindow = view.convert(view.bounds, to: nil).minX - let sidebarTrailingEdgeInAccessory = max(0, sidebarTrailingEdge - accessoryOriginXInWindow) let xOffset = TitlebarControlsLayoutMetrics.leadingOffset( - contentWidth: contentSize.width, trafficLightFrame: trafficLightFrame, - debugSnapshot: debugSnapshot, - sidebarTrailingEdge: sidebarTrailingEdgeInAccessory + debugSnapshot: debugSnapshot ) let yOffset = TitlebarControlsLayoutMetrics.yOffset( contentHeight: contentSize.height, @@ -2068,14 +2030,6 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont hostingView.frame = NSRect(x: xOffset, y: yOffset, width: contentSize.width, height: contentSize.height) } - func setSidebarTrailingEdge(_ edge: CGFloat) { - let sanitizedEdge = max(0, edge) - guard abs(sidebarTrailingEdge - sanitizedEdge) > 0.5 else { return } - sidebarTrailingEdge = sanitizedEdge - lastAppliedLayoutSnapshot = nil - updateSize() - } - private func applyWorkspaceTitlebarVisibility() { let shouldShow = showsWorkspaceTitlebar self.isHidden = !shouldShow @@ -2801,7 +2755,6 @@ final class UpdateTitlebarAccessoryController { private var startupScanWorkItems: [DispatchWorkItem] = [] private let controlsIdentifier = NSUserInterfaceItemIdentifier("cmux.titlebarControls") private let controlsControllers = NSHashTable.weakObjects() - private var sidebarTrailingEdgesByWindow: [ObjectIdentifier: CGFloat] = [:] private var lastKnownPresentationMode: WorkspacePresentationModeSettings.Mode = WorkspacePresentationModeSettings.mode() private var detachedNotificationsPopover: NSPopover? private var detachedNotificationsPopoverDelegate: DetachedNotificationsPopoverDelegate? @@ -2954,7 +2907,6 @@ final class UpdateTitlebarAccessoryController { let controls = TitlebarControlsAccessoryViewController( notificationStore: TerminalNotificationStore.shared ) - controls.setSidebarTrailingEdge(sidebarTrailingEdgesByWindow[ObjectIdentifier(window)] ?? 0) controls.layoutAttribute = .left controls.view.identifier = controlsIdentifier window.addTitlebarAccessoryViewController(controls) @@ -2989,16 +2941,6 @@ final class UpdateTitlebarAccessoryController { } } - func updateSidebarTrailingEdge(_ edge: CGFloat, for window: NSWindow) { - guard isMainTerminalWindow(window) else { return } - let windowID = ObjectIdentifier(window) - let sanitizedEdge = max(0, edge) - sidebarTrailingEdgesByWindow[windowID] = sanitizedEdge - for controller in controlsControllers.allObjects where controller.view.window === window { - controller.setSidebarTrailingEdge(sanitizedEdge) - } - } - private func removeAccessoryIfPresent(from window: NSWindow) { guard canAccessTitlebarAccessories(on: window) else { attachedWindows.remove(window) diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift index df7ee54fb7..0c8ea354c5 100644 --- a/cmuxTests/SidebarWidthPolicyTests.swift +++ b/cmuxTests/SidebarWidthPolicyTests.swift @@ -16,12 +16,12 @@ final class SidebarWidthPolicyTests: XCTestCase { XCTAssertEqual( SessionPersistencePolicy.defaultMinimumSidebarWidth, - 190, + 216, accuracy: 0.001 ) XCTAssertEqual( SessionPersistencePolicy.resolvedMinimumSidebarWidth(defaults: defaults), - 190, + 216, accuracy: 0.001 ) } diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 95d2172eef..91ac45e117 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -219,18 +219,47 @@ final class TitlebarControlsSizingPolicyTests: XCTestCase { func testTitlebarControlsUseDeterministicContentSize() { let classic = TitlebarControlsLayoutMetrics.contentSize(config: TitlebarControlsStyle.classic.config) - XCTAssertEqual(classic.width, 138, accuracy: 0.001) + XCTAssertEqual(classic.width, 150, accuracy: 0.001) XCTAssertEqual(classic.height, WindowChromeMetrics.appTitlebarHeight, accuracy: 0.001) let compact = TitlebarControlsLayoutMetrics.contentSize(config: TitlebarControlsStyle.compact.config) - XCTAssertEqual(compact.width, 124, accuracy: 0.001) + XCTAssertEqual(compact.width, 136, accuracy: 0.001) XCTAssertEqual(compact.height, WindowChromeMetrics.appTitlebarHeight, accuracy: 0.001) } - func testTitlebarControlsMinimumSidebarWidthKeepsButtonsClearOfTrafficLights() { + func testTitlebarControlsLeadingOffsetSticksToTrafficLights() { + let snapshot = MinimalModeTitlebarDebugSnapshot( + leftControlsLeadingInset: MinimalModeTitlebarDebugSettings.defaultLeftControlsLeadingInset, + leftControlsTopInset: MinimalModeTitlebarDebugSettings.defaultLeftControlsTopInset, + trafficLightTabBarLeadingInset: MinimalModeTitlebarDebugSettings.defaultTrafficLightTabBarInset, + trafficLightTitlebarLeadingInset: MinimalModeTitlebarDebugSettings.defaultTrafficLightTitlebarLeadingInset + ) + let trafficLightFrame = NSRect(x: 18, y: 7, width: 14, height: 14) + XCTAssertEqual( - TitlebarControlsLayoutMetrics.minimumSidebarWidth(config: TitlebarControlsStyle.classic.config), - 190, + TitlebarControlsLayoutMetrics.leadingOffset( + trafficLightFrame: trafficLightFrame, + debugSnapshot: snapshot + ), + trafficLightFrame.maxX + TitlebarControlsLayoutMetrics.trafficLightGap, + accuracy: 0.001 + ) + } + + func testTitlebarControlsLeadingOffsetDoesNotFollowSidebarTrailingEdge() { + let snapshot = MinimalModeTitlebarDebugSnapshot( + leftControlsLeadingInset: 150, + leftControlsTopInset: MinimalModeTitlebarDebugSettings.defaultLeftControlsTopInset, + trafficLightTabBarLeadingInset: MinimalModeTitlebarDebugSettings.defaultTrafficLightTabBarInset, + trafficLightTitlebarLeadingInset: MinimalModeTitlebarDebugSettings.defaultTrafficLightTitlebarLeadingInset + ) + + XCTAssertEqual( + TitlebarControlsLayoutMetrics.leadingOffset( + trafficLightFrame: NSRect(x: 18, y: 7, width: 14, height: 14), + debugSnapshot: snapshot + ), + 78, accuracy: 0.001 ) } diff --git a/scripts/reload.sh b/scripts/reload.sh index bb4a059e98..1e6866147f 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -18,6 +18,10 @@ CMUX_DEV_ORIGIN="" CLI_PATH="" LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/cmux" AUTO_SKIP_ZIG_BUILD_REASON="" +SWIFT_FRONTEND_WORKAROUND=0 +XCODEBUILD_STARTED=0 +XCODEBUILD_OUTPUT_VALID=0 +XCODEBUILD_CLEANED_OUTPUTS=0 should_skip_ghostty_cli_helper_zig_build() { if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then @@ -190,6 +194,13 @@ Options: --name Override app display/bundle name. --bundle-id Override bundle identifier. --derived-data Override derived data path. + --swift-frontend-workaround + Work around Swift arm64 frontend spins for this reload + only by disabling batch mode, debug symbol emission, + and AArch64 GlobalISel. Also enabled by + CMUX_SWIFT_FRONTEND_WORKAROUND=1. + --swift-disable-global-isel + Alias for --swift-frontend-workaround. -h, --help Show this help. EOF } @@ -274,6 +285,55 @@ tagged_derived_data_path() { echo "$HOME/Library/Developer/Xcode/DerivedData/cmux-${slug}" } +remove_app_bundle_output() { + local path="${1:-}" + if [[ -z "$path" || ! -e "$path" ]]; then + return 0 + fi + if [[ -z "${BUILD_PRODUCTS_DEBUG_DIR:-}" ]]; then + echo "warning: refusing to remove app output without a build products directory: $path" >&2 + return 0 + fi + case "$path" in + "$BUILD_PRODUCTS_DEBUG_DIR"/*.app) + rm -rf "$path" + ;; + *) + echo "warning: refusing to remove unexpected app output: $path" >&2 + ;; + esac +} + +cleanup_incomplete_xcodebuild_outputs() { + if [[ "$XCODEBUILD_CLEANED_OUTPUTS" -eq 1 ]]; then + return 0 + fi + XCODEBUILD_CLEANED_OUTPUTS=1 + remove_app_bundle_output "${XCODEBUILD_SOURCE_APP_PATH:-}" + remove_app_bundle_output "${XCODEBUILD_TAG_APP_PATH:-}" + remove_app_bundle_output "${TAG_APP_STAGING_PATH:-}" +} + +validate_app_bundle() { + local app_path="$1" + local executable_name="$2" + local executable_path="$app_path/Contents/MacOS/$executable_name" + local info_plist="$app_path/Contents/Info.plist" + + if [[ ! -d "$app_path" ]]; then + echo "error: app bundle not found after xcodebuild: $app_path" >&2 + return 1 + fi + if [[ ! -f "$info_plist" ]]; then + echo "error: app Info.plist not found after xcodebuild: $info_plist" >&2 + return 1 + fi + if [[ ! -x "$executable_path" ]]; then + echo "error: app executable not found after xcodebuild: $executable_path" >&2 + return 1 + fi +} + print_tag_cleanup_reminder() { local current_slug="$1" local path="" @@ -373,6 +433,14 @@ while [[ $# -gt 0 ]]; do DERIVED_SET=1 shift 2 ;; + --swift-disable-global-isel) + SWIFT_FRONTEND_WORKAROUND=1 + shift + ;; + --swift-frontend-workaround) + SWIFT_FRONTEND_WORKAROUND=1 + shift + ;; -h|--help) usage exit 0 @@ -421,6 +489,23 @@ RELOAD_LOG="/tmp/cmux-reload-${TAG_SLUG}.log" RELOAD_START_TIME="$(date +%s)" : > "$RELOAD_LOG" +BUILD_PRODUCTS_DEBUG_DIR="" +XCODEBUILD_SOURCE_APP_NAME="$APP_NAME" +XCODEBUILD_SOURCE_APP_PATH="" +XCODEBUILD_TAG_APP_PATH="" +TAG_APP_FINAL_PATH="" +TAG_APP_STAGING_PATH="" +if [[ -n "$DERIVED_DATA" ]]; then + BUILD_PRODUCTS_DEBUG_DIR="${DERIVED_DATA}/Build/Products/Debug" + if [[ -n "$TAG" ]]; then + XCODEBUILD_SOURCE_APP_NAME="$BASE_APP_NAME" + fi + XCODEBUILD_SOURCE_APP_PATH="${BUILD_PRODUCTS_DEBUG_DIR}/${XCODEBUILD_SOURCE_APP_NAME}.app" + if [[ -n "$TAG" && "$APP_NAME" != "$XCODEBUILD_SOURCE_APP_NAME" ]]; then + XCODEBUILD_TAG_APP_PATH="${BUILD_PRODUCTS_DEBUG_DIR}/${APP_NAME}.app" + fi +fi + # Save the original stdout/stderr so the EXIT trap can write the user-facing # summary after the body redirect, then redirect bulk output into the log. exec 3>&1 4>&2 @@ -432,6 +517,13 @@ reload_finalize() { exec 1>&3 2>&4 local elapsed=$(( $(date +%s) - RELOAD_START_TIME )) if [[ "$rc" -ne 0 ]]; then + if [[ "$XCODEBUILD_STARTED" -eq 1 && "$XCODEBUILD_OUTPUT_VALID" -ne 1 ]]; then + cleanup_incomplete_xcodebuild_outputs + echo "==> removed incomplete xcodebuild app outputs" >&2 + elif [[ -n "${TAG_APP_STAGING_PATH:-}" && -e "$TAG_APP_STAGING_PATH" ]]; then + remove_app_bundle_output "$TAG_APP_STAGING_PATH" + echo "==> removed incomplete staged tagged app" >&2 + fi if [[ -s "$RELOAD_LOG" ]]; then cat "$RELOAD_LOG" >&2 fi @@ -464,6 +556,11 @@ reload_finalize() { fi echo "If your shell still resolves the old cmux, run: rehash" fi + if [[ "${SWIFT_FRONTEND_WORKAROUND_EFFECTIVE:-0}" -eq 1 ]]; then + echo + echo "Swift workaround:" + echo " batch mode, debug symbols, and AArch64 GlobalISel disabled for this reload" + fi if [[ "$LAUNCH" -eq 0 ]]; then echo echo "Build complete. Pass --launch to open the app, or cmd-click the path above." @@ -507,8 +604,24 @@ fi if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then XCODEBUILD_ARGS+=(CMUX_SKIP_ZIG_BUILD=1) fi +if [[ "$SWIFT_FRONTEND_WORKAROUND" -eq 1 || "${CMUX_SWIFT_FRONTEND_WORKAROUND:-}" == "1" || "${CMUX_SWIFT_DISABLE_GLOBAL_ISEL:-}" == "1" ]]; then + SWIFT_FRONTEND_WORKAROUND_EFFECTIVE=1 + echo "==> Swift frontend workaround enabled for this reload" + XCODEBUILD_ARGS+=(SWIFT_ENABLE_BATCH_MODE=NO) + XCODEBUILD_ARGS+=(DEBUG_INFORMATION_FORMAT=) + XCODEBUILD_ARGS+=(GCC_GENERATE_DEBUGGING_SYMBOLS=NO) + XCODEBUILD_ARGS+=('OTHER_SWIFT_FLAGS=$(inherited) -Xllvm -aarch64-enable-global-isel-at-O=-1') +else + SWIFT_FRONTEND_WORKAROUND_EFFECTIVE=0 +fi XCODEBUILD_ARGS+=(build) +if [[ -n "$BUILD_PRODUCTS_DEBUG_DIR" ]]; then + mkdir -p "$BUILD_PRODUCTS_DEBUG_DIR" + cleanup_incomplete_xcodebuild_outputs + XCODEBUILD_CLEANED_OUTPUTS=0 +fi + XCODEBUILD_LOCK_DIR="${TMPDIR:-/tmp}/cmux-xcodebuild-$(id -u).locks" XCODEBUILD_LOCK_CONCURRENCY="${CMUX_XCODEBUILD_LOCK_CONCURRENCY:-5}" if ! is_positive_integer "$XCODEBUILD_LOCK_CONCURRENCY"; then @@ -523,6 +636,7 @@ fi # Xcode 26's SWBBuildService is a per-user singleton. Too many concurrent # xcodebuild invocations can trample that daemon, so cap reload.sh builds at # five per user while still allowing useful parallel tagged builds. +XCODEBUILD_STARTED=1 python3 -c ' import array import fcntl @@ -663,16 +777,23 @@ except OSError as exc: raise SystemExit(f"error: exec: {exc}") ' "$XCODEBUILD_LOCK_DIR" "$XCODEBUILD_LOCK_CONCURRENCY" "$XCODEBUILD_LOCK_WAIT_SECONDS" xcodebuild "${XCODEBUILD_ARGS[@]}" sleep 0.2 +if LC_ALL=C grep -q 'BUILD INTERRUPTED' "$RELOAD_LOG"; then + echo "error: xcodebuild reported ** BUILD INTERRUPTED **; refusing to reuse DerivedData app artifacts" >&2 + exit 65 +fi FALLBACK_APP_NAME="$BASE_APP_NAME" SEARCH_APP_NAME="$APP_NAME" +APP_EXECUTABLE_NAME="$SEARCH_APP_NAME" if [[ -n "$TAG" ]]; then SEARCH_APP_NAME="$BASE_APP_NAME" + APP_EXECUTABLE_NAME="$BASE_APP_NAME" fi if [[ -n "$DERIVED_DATA" ]]; then APP_PATH="${DERIVED_DATA}/Build/Products/Debug/${SEARCH_APP_NAME}.app" if [[ ! -d "${APP_PATH}" && "$SEARCH_APP_NAME" != "$FALLBACK_APP_NAME" ]]; then APP_PATH="${DERIVED_DATA}/Build/Products/Debug/${FALLBACK_APP_NAME}.app" + APP_EXECUTABLE_NAME="$FALLBACK_APP_NAME" fi else APP_BINARY="$( @@ -695,6 +816,7 @@ else )" if [[ -n "${APP_BINARY}" ]]; then APP_PATH="$(dirname "$(dirname "$(dirname "$APP_BINARY")")")" + APP_EXECUTABLE_NAME="$FALLBACK_APP_NAME" fi fi fi @@ -702,6 +824,8 @@ if [[ -z "${APP_PATH}" || ! -d "${APP_PATH}" ]]; then echo "${APP_NAME}.app not found in DerivedData" >&2 exit 1 fi +validate_app_bundle "$APP_PATH" "$APP_EXECUTABLE_NAME" +XCODEBUILD_OUTPUT_VALID=1 if [[ -n "${TAG_SLUG:-}" ]]; then TMP_COMPAT_DERIVED_LINK="/tmp/cmux-${TAG_SLUG}" @@ -713,10 +837,11 @@ if [[ -n "${TAG_SLUG:-}" ]]; then fi if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then - TAG_APP_PATH="$(dirname "$APP_PATH")/${APP_NAME}.app" - rm -rf "$TAG_APP_PATH" - cp -R "$APP_PATH" "$TAG_APP_PATH" - INFO_PLIST="$TAG_APP_PATH/Contents/Info.plist" + TAG_APP_FINAL_PATH="$(dirname "$APP_PATH")/${APP_NAME}.app" + TAG_APP_STAGING_PATH="$(dirname "$APP_PATH")/.${APP_NAME}.reload-$$.app" + rm -rf "$TAG_APP_STAGING_PATH" + cp -R "$APP_PATH" "$TAG_APP_STAGING_PATH" + INFO_PLIST="$TAG_APP_STAGING_PATH/Contents/Info.plist" if [[ -f "$INFO_PLIST" ]]; then /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :CFBundleName string $APP_NAME" "$INFO_PLIST" @@ -740,8 +865,8 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then set_plist_env "$INFO_PLIST" CMUX_SOCKET_MODE "allowAll" set_plist_env "$INFO_PLIST" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD "1" set_plist_env "$INFO_PLIST" CMUXTERM_REPO_ROOT "$PWD" - set_plist_env "$INFO_PLIST" CMUX_BUNDLED_CLI_PATH "$TAG_APP_PATH/Contents/Resources/bin/cmux" - set_plist_env "$INFO_PLIST" CMUX_SHELL_INTEGRATION_DIR "$TAG_APP_PATH/Contents/Resources/shell-integration" + set_plist_env "$INFO_PLIST" CMUX_BUNDLED_CLI_PATH "$TAG_APP_FINAL_PATH/Contents/Resources/bin/cmux" + set_plist_env "$INFO_PLIST" CMUX_SHELL_INTEGRATION_DIR "$TAG_APP_FINAL_PATH/Contents/Resources/shell-integration" set_plist_env "$INFO_PLIST" CMUX_PORT "$CMUX_DEV_PORT" set_plist_env "$INFO_PLIST" CMUX_PORT_END "$CMUX_DEV_PORT_END" set_plist_env "$INFO_PLIST" CMUX_PORT_RANGE "$CMUX_DEV_PORT_RANGE" @@ -760,7 +885,7 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then fi fi fi - APP_PATH="$TAG_APP_PATH" + APP_PATH="$TAG_APP_STAGING_PATH" fi CLI_PATH="$(dirname "$APP_PATH")/cmux" @@ -812,9 +937,15 @@ if ! /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement- exit 1 fi fi +if [[ -n "${TAG_APP_FINAL_PATH:-}" && -n "${TAG_APP_STAGING_PATH:-}" ]]; then + rm -rf "$TAG_APP_FINAL_PATH" + mv "$TAG_APP_STAGING_PATH" "$TAG_APP_FINAL_PATH" + APP_PATH="$TAG_APP_FINAL_PATH" +fi CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" if [[ -x "$CLI_PATH" ]]; then echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true + ln -sfn "$CLI_PATH" /tmp/cmux-cli || true fi # Tag mode: always terminate the existing same-tag instance after a successful build,