Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
public let currentDirectory: String?
/// Whether the Mac currently has this workspace selected.
public let isSelected: Bool
/// Whether this workspace is pinned, if the Mac reported it. `nil` when
/// connected to a Mac old enough not to emit `is_pinned`.
public let isPinned: Bool?
/// Terminals belonging to this workspace.
public let terminals: [Terminal]

Expand All @@ -23,6 +26,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
case title
case currentDirectory = "current_directory"
case isSelected = "is_selected"
case isPinned = "is_pinned"
case terminals
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ extension MobileWorkspacePreview {
self.init(
id: ID(rawValue: remote.id),
name: remote.title,
isPinned: remote.isPinned ?? false,
terminals: remote.terminals.map { terminal in
MobileTerminalPreview(remote: terminal)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}

private static let terminalRenderGridCapability = "terminal.render_grid.v1"
private static let workspaceActionsCapability = "workspace.actions.v1"
private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000

/// How long the render-grid stream may stay silent (no event of any topic)
Expand Down Expand Up @@ -79,6 +80,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
public var pairingCode: String
public var workspaces: [MobileWorkspacePreview]
/// Whether the connected Mac advertises the `workspace.actions.v1` capability
/// (rename/pin over the mobile RPC). `false` until host status is read, and
/// for older Macs that lack the handler, so the UI can hide rename/pin rather
/// than offer actions that would fail with `method_not_found`.
public private(set) var supportsWorkspaceActions: Bool = false
public var terminalInputText: String
public var selectedWorkspaceID: MobileWorkspacePreview.ID? {
didSet {
Expand Down Expand Up @@ -310,6 +316,62 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}

// MARK: - Workspace actions

/// Rename a workspace on the Mac.
///
/// Fire-and-forget against the authoritative state: the Mac applies the title
/// and its workspace-list observer pushes `workspace.updated`, which refreshes
/// this list. No local optimistic mutation, so overlapping actions can never
/// leave stale state.
/// - Parameters:
/// - id: The workspace to rename.
/// - title: The new title. Whitespace-only titles are ignored.
public func renameWorkspace(id: MobileWorkspacePreview.ID, title: String) async {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let client = remoteClient else { return }
do {
let request = try MobileCoreRPCClient.requestData(
method: "workspace.action",
params: [
"workspace_id": id.rawValue,
"action": "rename",
"title": trimmed,
"client_id": clientID,
]
)
_ = try await client.sendRequest(request)
} catch {
mobileShellLog.error("workspace rename failed id=\(id.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)")
}
}

/// Pin or unpin a workspace on the Mac.
///
/// Fire-and-forget against the authoritative state: the Mac toggles the pin
/// and its workspace-list observer (which watches `$isPinned`) pushes
/// `workspace.updated`, which refreshes this list. No local optimistic
/// mutation, so overlapping pin/unpin taps can never leave stale state.
/// - Parameters:
/// - id: The workspace to pin or unpin.
/// - pinned: `true` to pin, `false` to unpin.
public func setWorkspacePinned(id: MobileWorkspacePreview.ID, _ pinned: Bool) async {
guard let client = remoteClient else { return }
do {
let request = try MobileCoreRPCClient.requestData(
method: "workspace.action",
params: [
"workspace_id": id.rawValue,
"action": pinned ? "pin" : "unpin",
"client_id": clientID,
]
)
_ = try await client.sendRequest(request)
} catch {
mobileShellLog.error("workspace pin failed id=\(id.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)")
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

// MARK: - Network recovery

/// True while an automatic reconnect is in progress after a network change
Expand Down Expand Up @@ -1168,6 +1230,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
pendingTerminalByteEndSeqBySurfaceID = [:]
terminalReplaySurfaceIDsInFlight = []
terminalOutputTransport = .rawBytes
supportsWorkspaceActions = false
terminalSubscriptionRefreshTask?.cancel()
terminalSubscriptionRefreshTask = nil
stopRenderGridLivenessWatchdog(listenerID: nil)
Expand Down Expand Up @@ -1446,15 +1509,18 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
)
guard let payload = try? MobileHostStatusResponse.decode(data) else {
terminalOutputTransport = fallback
supportsWorkspaceActions = false
return fallback
}
supportsWorkspaceActions = payload.capabilities.contains(Self.workspaceActionsCapability)
let transport: TerminalOutputTransport = payload.capabilities.contains(Self.terminalRenderGridCapability) ||
payload.terminalFidelity == "render_grid" ? .renderGrid : .rawBytes
terminalOutputTransport = transport
MobileDebugLog.anchormux("sync.transport=\(transport == .renderGrid ? "render_grid" : "raw_bytes")")
return transport
} catch {
terminalOutputTransport = fallback
supportsWorkspaceActions = false
MobileDebugLog.anchormux("sync.transport=raw_bytes reason=status_failed")
return fallback
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,22 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable {
public var id: ID
/// The workspace's user-facing display name.
public var name: String
/// Whether the workspace is pinned on the Mac. Pinned workspaces sort to the
/// top of the mobile list.
public var isPinned: Bool
/// The terminals contained in the workspace, in display order.
public var terminals: [MobileTerminalPreview]

/// Creates a workspace preview.
/// - Parameters:
/// - id: The workspace's stable identifier.
/// - name: The workspace's user-facing display name.
/// - isPinned: Whether the workspace is pinned on the Mac. Defaults to `false`.
/// - terminals: The terminals contained in the workspace, in display order.
public init(id: ID, name: String, terminals: [MobileTerminalPreview]) {
public init(id: ID, name: String, isPinned: Bool = false, terminals: [MobileTerminalPreview]) {
self.id = id
self.name = name
self.isPinned = isPinned
self.terminals = terminals
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,38 @@ struct WorkspaceListView: View {
/// previews), the menu is hidden.
var rescanQR: (() -> Void)?
var signOut: (() -> Void)?
/// Optional: rename a workspace on the Mac. When present, each row offers a
/// Rename context-menu action.
var renameWorkspace: ((MobileWorkspacePreview.ID, String) -> Void)?
/// Optional: pin/unpin a workspace on the Mac. When present, each row offers
/// a Pin/Unpin context-menu action and pinned workspaces sort to the top.
var setPinned: ((MobileWorkspacePreview.ID, Bool) -> Void)?
@State private var searchText = ""
@State private var showingShortcutsSettings = false
@State private var showingSettings = false

/// Workspaces after search filtering, pinned ones first (stable within each
/// group so the Mac's order is otherwise preserved).
private var filteredWorkspaces: [MobileWorkspacePreview] {
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else {
return workspaces
}
return workspaces.filter { workspace in
workspace.name.localizedCaseInsensitiveContains(query)
|| workspace.previewLine.localizedCaseInsensitiveContains(query)
|| workspace.terminals.contains { $0.name.localizedCaseInsensitiveContains(query) }
let matches: [MobileWorkspacePreview]
if query.isEmpty {
matches = workspaces
} else {
matches = workspaces.filter { workspace in
workspace.name.localizedCaseInsensitiveContains(query)
|| workspace.previewLine.localizedCaseInsensitiveContains(query)
|| workspace.terminals.contains { $0.name.localizedCaseInsensitiveContains(query) }
}
}
return matches.enumerated()
.sorted { lhs, rhs in
if lhs.element.isPinned != rhs.element.isPinned {
return lhs.element.isPinned
}
return lhs.offset < rhs.offset
}
.map(\.element)
}

var body: some View {
Expand All @@ -53,7 +71,9 @@ struct WorkspaceListView: View {
connectionStatus: connectionStatus,
isSelected: navigationStyle == .sidebar && selectedWorkspaceID == workspace.id,
navigationStyle: navigationStyle,
selectWorkspace: selectWorkspace
selectWorkspace: selectWorkspace,
renameWorkspace: renameWorkspace,
setPinned: setPinned
)
.listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
.listRowSeparator(.hidden)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CmuxMobileShellModel
import CmuxMobileSupport
import SwiftUI

struct WorkspaceNavigationRow: View {
Expand All @@ -8,6 +9,14 @@ struct WorkspaceNavigationRow: View {
let isSelected: Bool
let navigationStyle: WorkspaceNavigationStyle
let selectWorkspace: (MobileWorkspacePreview.ID) -> Void
/// Rename the workspace on the Mac. When `nil` (e.g. previews) the rename
/// affordance is hidden.
var renameWorkspace: ((MobileWorkspacePreview.ID, String) -> Void)?
/// Pin or unpin the workspace on the Mac. When `nil` the pin affordance is
/// hidden.
var setPinned: ((MobileWorkspacePreview.ID, Bool) -> Void)?

@State private var isRenaming = false

var body: some View {
Group {
Expand All @@ -28,10 +37,40 @@ struct WorkspaceNavigationRow: View {
.buttonStyle(.plain)
}
}
.contextMenu { contextMenu }
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier("MobileWorkspaceRow-\(workspace.id.rawValue)")
.accessibilityLabel(workspace.name)
.accessibilityValue(workspace.accessibilitySummary(host: host, connectionStatus: connectionStatus))
.sheet(isPresented: $isRenaming) {
WorkspaceRenameSheet(currentName: workspace.name) { newName in
renameWorkspace?(workspace.id, newName)
}
}
}

@ViewBuilder
private var contextMenu: some View {
if let setPinned {
Button {
setPinned(workspace.id, !workspace.isPinned)
} label: {
if workspace.isPinned {
Label(L10n.string("mobile.workspace.unpin", defaultValue: "Unpin"), systemImage: "pin.slash")
} else {
Label(L10n.string("mobile.workspace.pin", defaultValue: "Pin"), systemImage: "pin")
}
}
.accessibilityIdentifier("MobileWorkspacePinButton-\(workspace.id.rawValue)")
}
if renameWorkspace != nil {
Button {
isRenaming = true
} label: {
Label(L10n.string("mobile.workspace.rename.action", defaultValue: "Rename"), systemImage: "pencil")
}
.accessibilityIdentifier("MobileWorkspaceRenameButton-\(workspace.id.rawValue)")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#if os(iOS)
import CmuxMobileSupport
import SwiftUI

/// A small sheet that renames a workspace from the mobile workspace list.
///
/// Seeds its text field with the current name and hands the trimmed result back
/// through `onSave`; the caller forwards it to the Mac. Whitespace-only names are
/// rejected (Save is disabled).
struct WorkspaceRenameSheet: View {
@Environment(\.dismiss) private var dismiss
private let onSave: (String) -> Void
@State private var name: String

/// Creates the rename sheet.
/// - Parameters:
/// - currentName: The workspace's current name, used to seed the field.
/// - onSave: Called with the new trimmed name when the user taps Save.
init(currentName: String, onSave: @escaping (String) -> Void) {
self.onSave = onSave
_name = State(initialValue: currentName)
}

var body: some View {
NavigationStack {
Form {
TextField(
L10n.string("mobile.workspace.rename.placeholder", defaultValue: "Workspace name"),
text: $name
)
.autocorrectionDisabled()
.submitLabel(.done)
.onSubmit(save)
.accessibilityIdentifier("WorkspaceRenameField")
}
.navigationTitle(L10n.string("mobile.workspace.rename.title", defaultValue: "Rename Workspace"))
.mobileInlineNavigationTitle()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.string("mobile.common.cancel", defaultValue: "Cancel")) {
dismiss()
}
.accessibilityIdentifier("WorkspaceRenameCancelButton")
}
ToolbarItem(placement: .confirmationAction) {
Button(L10n.string("mobile.common.save", defaultValue: "Save")) {
save()
}
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.accessibilityIdentifier("WorkspaceRenameSaveButton")
}
}
}
}

private func save() {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
onSave(trimmed)
dismiss()
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ struct WorkspaceRow: View {

VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
if workspace.isPinned {
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
}

Text(workspace.name)
.font(.headline)
.foregroundStyle(isSelected ? Color.accentColor : Color.primary)
Expand Down
Loading
Loading