Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ final class SampleSidebarExtension: CmuxSidebarExtension {
.workspaceList,
.workspaceMetadata,
.surfaceMetadata,
.notifications,
.networkPorts,
.pullRequests,
],
requestedActionScopes: [
.createSurface,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ struct SurfaceInsight: Identifiable {
return "doc"
case .rightSidebarTool:
return "sidebar.right"
case .project:
return "folder"
case .unknown:
return "rectangle"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,4 +50,35 @@ public enum CMUXSidebarAction: Codable, Equatable, Sendable {
return .openURL
}
}

public var requiredScopes: Set<CMUXExtensionActionScope> {
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]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading