Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -180,65 +180,69 @@ struct WorkspaceGroupHeaderRow: View, Equatable {

@ViewBuilder
private var contextMenu: some View {
if value.canSetGroupPinned, let setGroupPinned = actions.setGroupPinned {
Button {
setGroupPinned(group.id, !group.isPinned)
} label: {
if group.isPinned {
Label(L10n.string("mobile.workspaceGroup.unpin", defaultValue: "Unpin Group"), systemImage: "pin.slash")
} else {
Label(L10n.string("mobile.workspaceGroup.pin", defaultValue: "Pin Group"), systemImage: "pin")
if value.canSetGroupPinned || value.canRenameGroup {
Section {
if value.canSetGroupPinned, let setGroupPinned = actions.setGroupPinned {
Button {
setGroupPinned(group.id, !group.isPinned)
} label: {
if group.isPinned {
Label(L10n.string("mobile.workspaceGroup.unpin", defaultValue: "Unpin Group"), systemImage: "pin.slash")
} else {
Label(L10n.string("mobile.workspaceGroup.pin", defaultValue: "Pin Group"), systemImage: "pin")
}
}
.accessibilityIdentifier("MobileWorkspaceGroupPinButton-\(group.id.rawValue)")
}
if value.canRenameGroup {
Button {
isRenaming = true
} label: {
Label(L10n.string("mobile.workspaceGroup.rename.action", defaultValue: "Rename Group"), systemImage: "pencil")
}
.accessibilityIdentifier("MobileWorkspaceGroupRenameButton-\(group.id.rawValue)")
}
}
.accessibilityIdentifier("MobileWorkspaceGroupPinButton-\(group.id.rawValue)")
}
if value.canRenameGroup {
Button {
isRenaming = true
} label: {
Label(L10n.string("mobile.workspaceGroup.rename.action", defaultValue: "Rename Group"), systemImage: "pencil")
}
.accessibilityIdentifier("MobileWorkspaceGroupRenameButton-\(group.id.rawValue)")
}
if value.canRenameGroup || value.canSetGroupPinned {
Divider()
}
if value.canCreateWorkspaceInGroup,
let createWorkspaceInGroup = actions.createWorkspaceInGroup {
Button {
createWorkspaceInGroup(group.id)
} label: {
Label(
L10n.string("mobile.workspaceGroup.newWorkspace", defaultValue: "New Workspace in Group"),
systemImage: "plus"
)
Section {
Button {
createWorkspaceInGroup(group.id)
} label: {
Label(
L10n.string("mobile.workspaceGroup.newWorkspace", defaultValue: "New Workspace in Group"),
systemImage: "plus"
)
}
.accessibilityIdentifier("MobileWorkspaceGroupNewWorkspace-\(group.id.rawValue)")
}
.accessibilityIdentifier("MobileWorkspaceGroupNewWorkspace-\(group.id.rawValue)")
}
if value.canUngroupWorkspaceGroup || value.canDeleteWorkspaceGroup {
Divider()
}
if value.canUngroupWorkspaceGroup {
Button(role: .destructive) {
pendingDestructiveAction = .ungroup
} label: {
Label(
L10n.string("mobile.workspaceGroup.ungroup", defaultValue: "Ungroup (Keep Workspaces)"),
systemImage: "rectangle.3.group"
)
}
.accessibilityIdentifier("MobileWorkspaceGroupUngroupButton-\(group.id.rawValue)")
}
if value.canDeleteWorkspaceGroup {
Button(role: .destructive) {
pendingDestructiveAction = .delete
} label: {
Label(
L10n.string("mobile.workspaceGroup.delete", defaultValue: "Delete Group (Close Workspaces)"),
systemImage: "trash"
)
Section {
if value.canUngroupWorkspaceGroup {
Button(role: .destructive) {
pendingDestructiveAction = .ungroup
} label: {
Label(
L10n.string("mobile.workspaceGroup.ungroup", defaultValue: "Ungroup (Keep Workspaces)"),
systemImage: "rectangle.3.group"
)
}
.accessibilityIdentifier("MobileWorkspaceGroupUngroupButton-\(group.id.rawValue)")
}
if value.canDeleteWorkspaceGroup {
Button(role: .destructive) {
pendingDestructiveAction = .delete
} label: {
Label(
L10n.string("mobile.workspaceGroup.delete", defaultValue: "Delete Group (Close Workspaces)"),
systemImage: "trash"
)
}
.accessibilityIdentifier("MobileWorkspaceGroupDeleteButton-\(group.id.rawValue)")
}
}
.accessibilityIdentifier("MobileWorkspaceGroupDeleteButton-\(group.id.rawValue)")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ final class GhosttySurfaceBridge: @unchecked Sendable {
}
}

static let ioWriteCallback: @convention(c) (
UnsafeMutableRawPointer?,
UnsafePointer<CChar>?,
UInt
) -> Void = { userdata, buf, len in
guard let buf, len > 0 else { return }
let data = Data(bytes: buf, count: Int(len))
GhosttySurfaceBridge.fromOpaque(userdata)?.handleWrite(data)
}

static let renderPresentedCallback: @convention(c) (
UnsafeMutableRawPointer?,
UInt64
) -> Void = { userdata, token in
GhosttySurfaceBridge.fromOpaque(userdata)?.handleRenderPresented(token: token)
}

static func fromOpaque(_ userdata: UnsafeMutableRawPointer?) -> GhosttySurfaceBridge? {
guard let userdata else { return nil }
return Unmanaged<GhosttySurfaceBridge>.fromOpaque(userdata).takeUnretainedValue()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3648,24 +3648,15 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
surfaceConfig.font_size = liveFontSize
surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW
surfaceConfig.io_mode = GHOSTTY_SURFACE_IO_MANUAL
surfaceConfig.io_write_cb = { userdata, buf, len in
guard let userdata, let buf, len > 0 else { return }
let data = Data(bytes: buf, count: Int(len))
let bridge = Unmanaged<GhosttySurfaceBridge>.fromOpaque(userdata).takeUnretainedValue()
DispatchQueue.main.async {
bridge.surfaceView?.handleOutboundBytes(data)
}
}
surfaceConfig.io_write_cb = GhosttySurfaceBridge.ioWriteCallback
surfaceConfig.io_write_userdata = bridgePointer
guard let createdSurface = ghostty_surface_new(app, &surfaceConfig) else {
retainedBridge.release()
return nil
}
guard ghostty_surface_set_render_presented_callback(
createdSurface,
{ userdata, token in
GhosttySurfaceBridge.fromOpaque(userdata)?.handleRenderPresented(token: token)
},
GhosttySurfaceBridge.renderPresentedCallback,
bridgePointer
) else {
ghostty_surface_free(createdSurface)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#if canImport(UIKit)
import Foundation
import Testing
@testable import CmuxMobileTerminal

@Suite("Ghostty surface callbacks")
struct GhosttySurfaceCallbackTests {
@Test("libghostty surface callbacks can run off the main thread")
func ghosttySurfaceCallbacksRunOffMainThread() async {
let bridge = GhosttySurfaceBridge()
let userdata = Unmanaged.passRetained(bridge).toOpaque()
let userdataAddress = UInt(bitPattern: userdata)
defer { GhosttySurfaceBridge.releaseRetainedOpaque(userdata) }

let ranOnMainThread = await withCheckedContinuation { continuation in
DispatchQueue(label: "dev.cmux.tests.ghostty-surface-callback").async {
let callbackUserdata = UnsafeMutableRawPointer(bitPattern: userdataAddress)
let bytes = Array("focus".utf8CString)
bytes.withUnsafeBufferPointer { buffer in
GhosttySurfaceBridge.ioWriteCallback(
callbackUserdata,
buffer.baseAddress,
UInt(max(buffer.count - 1, 0))
)
}
GhosttySurfaceBridge.renderPresentedCallback(callbackUserdata, 42)
continuation.resume(returning: Thread.isMainThread)
}
}

#expect(!ranOnMainThread)
}
}
#endif