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 @@ -64,6 +64,17 @@ public struct DaemonTree: Sendable {
public var workspaceNumbers: [String: Int] = [:]
/// Each tab group's pane (`tab_groups`), by group id.
public var groupPanes: [String: Int] = [:]
/// Every tab of each pane in the daemon's order, listed or not (`move-tab-group` indexes
/// count them all): its group and whether it is pinned (the Mac's `TabGroupReorder` slots).
public var paneSlots: [Int: [PaneSlot]] = [:]
/// Panes of an agent chat dock (`dock.role` `agent_chat`): the Mac never splits one or
/// moves a tab group into one (`ChatDockRules.isChatDock`), Home's pinned dock included.
public var chatDockPanes: Set<Int> = []

public struct PaneSlot: Hashable, Sendable {
public var group: String?
public var pinned: Bool
}
public var revision: Int?
/// This session's registry id (`registry_id`): the session of the Mac's
/// personal rows and layout refs.
Expand Down Expand Up @@ -123,6 +134,8 @@ public struct DaemonTree: Sendable {
var markers: [Int: NotificationMarker] = [:]
var numbers: [String: Int] = [:]
var groupPanes: [String: Int] = [:]
var paneSlots: [Int: [PaneSlot]] = [:]
var chatDockPanes: Set<Int> = []
let previousByID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
var result: [Workspace] = []
var home: [Workspace] = []
Expand All @@ -141,15 +154,23 @@ public struct DaemonTree: Sendable {
var blocked = false
var observed: Date?
var focusOrder = 0
var screens: [MacScreen] = []
for case let screen as [String: Any] in ws["screens"] as? [Any] ?? [] {
let activePane = screen["active_pane"] as? Int
if screen["active"] as? Bool == true, let activePane { panes[key] = activePane }
chatDockPanes.formUnion(Self.chatDockPanes(screen))
var screenPanes: [Int] = []
var screenTabs: [String] = []
for case let pane as [String: Any] in screen["panes"] as? [Any] ?? [] {
focusOrder = max(focusOrder, pane["focused_at"] as? Int ?? 0)
if panes[key] == nil, let id = pane["id"] as? Int { panes[key] = id }
let paneTabs = pane["tabs"] as? [[String: Any]] ?? []
let groups = Self.groups(pane["tab_groups"])
if let paneID = pane["id"] as? Int { for id in groups.keys { groupPanes[id] = paneID } }
if let paneID = pane["id"] as? Int {
for id in groups.keys { groupPanes[id] = paneID }
if pane["dead"] as? Bool != true { screenPanes.append(paneID) }
paneSlots[paneID] = paneTabs.map { PaneSlot(group: $0["group"] as? String, pinned: $0["pinned"] as? Bool == true) }
}
for (index, tab) in paneTabs.enumerated() {
// A dead tab is listed only when it is a terminal the Mac keeps (Restart
// Shell Here); other dead tabs are gone for the person.
Expand Down Expand Up @@ -181,8 +202,13 @@ public struct DaemonTree: Sendable {
if let pinned = tab["pinned"] as? Bool { mapped.pinned = pinned }
if let group = tab["group"] as? String { mapped.group = groups[group] ?? WorkspaceTab.TabGroupRef(id: group) }
tabs.append(mapped)
screenTabs.append(id)
}
}
if let screenID = screen["id"] as? Int {
screens.append(MacScreen(id: screenID, name: screen["name"] as? String ?? "", isActive: screen["active"] as? Bool == true,
activePane: activePane, panes: screenPanes, tabIDs: screenTabs))
}
}
let source = activeTab ?? (ws["screens"] as? [[String: Any]])?.first.flatMap { ($0["panes"] as? [[String: Any]])?.first?["tabs"] as? [[String: Any]] }?.first
let title = (ws["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? (ws["name"] as? String) ?? "Workspace"
Expand Down Expand Up @@ -218,6 +244,7 @@ public struct DaemonTree: Sendable {
workspace.isHome = isHome
workspace.hasCustomTitle = (ws["title"] as? String).map { !$0.isEmpty && $0 != ws["name"] as? String } ?? false
workspace.isPinned = legacyPinned.contains(key)
workspace.screens = screens
if isHome { home.append(workspace) } else { result.append(workspace) }
}
self.legacyPinned = legacyPinned
Expand All @@ -231,6 +258,8 @@ public struct DaemonTree: Sendable {
self.terminals = terminals
workspaceNumbers = numbers
self.groupPanes = groupPanes
self.paneSlots = paneSlots
self.chatDockPanes = chatDockPanes
notificationMarkers = markers
}

Expand All @@ -239,12 +268,60 @@ public struct DaemonTree: Sendable {
var out: [String: WorkspaceTab.TabGroupRef] = [:]
for case let group as [String: Any] in value as? [Any] ?? [] {
guard let id = group["id"] as? String else { continue }
let icon = (group["icon"] as? String).flatMap { $0.isEmpty ? nil : $0 }
out[id] = WorkspaceTab.TabGroupRef(id: id, name: group["name"] as? String ?? "", color: group["color"] as? String ?? "grey",
isSaved: group["saved_id"] is String)
isSaved: group["saved_id"] is String, icon: icon,
isCollapsed: group["collapsed"] as? Bool == true)
}
return out
}

/// The panes of a screen's agent chat docks (`columns[].dock`, role `agent_chat`), as the
/// Mac's `ChatDockRules.isChatDock`: a pinned dock that takes tabs still never splits.
static func chatDockPanes(_ screen: [String: Any]) -> Set<Int> {
var out: Set<Int> = []
for case let column as [String: Any] in screen["columns"] as? [Any] ?? [] {
guard let dock = column["dock"] as? [String: Any], dock["role"] as? String == "agent_chat" else { continue }
var trees: [Any] = [column["layout"] as Any]
for case let row as [String: Any] in column["rows"] as? [Any] ?? [] { trees.append(row["layout"] as Any) }
for tree in trees { out.formUnion(leaves(tree)) }
}
return out
}

/// The panes of a split tree (`leaf`, `split`, `stack`).
static func leaves(_ value: Any?) -> [Int] {
guard let node = value as? [String: Any] else { return [] }
switch node["type"] as? String {
case "leaf": return (node["pane"] as? Int).map { [$0] } ?? []
case "split": return leaves(node["a"]) + leaves(node["b"])
case "stack": return node["panes"] as? [Int] ?? []
default: return []
}
}

/// The Mac's Move Tab Group Left / Right index (`TabGroupReorder.targetIndex`): where the
/// group goes in its pane's other tabs, past the neighboring tab or whole group; nil at the
/// pane's edge or before a pinned tab.
public static func tabGroupMoveIndex(of group: String, forward: Bool, in slots: [PaneSlot]) -> Int? {
guard let start = slots.firstIndex(where: { $0.group == group }) else { return nil }
let rest = slots.filter { $0.group != group }
if forward {
guard start < rest.count else { return nil }
var end = start
if let neighbor = rest[start].group {
while end + 1 < rest.count, rest[end + 1].group == neighbor { end += 1 }
}
return end + 1
}
guard start > 0, !rest[start - 1].pinned else { return nil }
var begin = start - 1
if let neighbor = rest[begin].group {
while begin > 0, rest[begin - 1].group == neighbor { begin -= 1 }
}
return begin
}

/// One daemon tab as an app tab, or nil for tabs the phone does not show (pages, conversations).
/// `agent` is the surface's agent state; `pending` marks acpmux sessions
/// with an open permission or question. Both set the tab's `agentStatus`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -726,9 +726,142 @@ public final class LiveWorkspaceCreationBackend: WorkspaceCreationBackend {
try await run(client, "save-tab-group", ["group": try group(of: tab)])
case .unsave:
try await run(client, "unsave-tab-group", ["group": try group(of: tab)])
case .setColor(let color):
try await run(client, "update-tab-group", ["group": try group(of: tab), "color": color])
case .setIcon(let icon):
// `null` removes the icon (`tab-group-icon-v1`), as the Mac's Remove Tab Group Icon.
try await run(client, "update-tab-group", ["group": try group(of: tab), "icon": icon.map { $0 as Any } ?? NSNull()])
case .setCollapsed(let collapsed):
try await run(client, "update-tab-group", ["group": try group(of: tab), "collapsed": collapsed])
case .moveLeft, .moveRight:
let id = try group(of: tab)
let pane = try groupPane(id)
guard let index = DaemonTree.tabGroupMoveIndex(of: id, forward: edit == .moveRight, in: sync.tree?.paneSlots[pane] ?? []) else {
throw WorkspaceCreationError(Self.groupAtEdge, isForPerson: true)
}
try await run(client, "move-tab-group", ["group": id, "pane": pane, "index": index])
case .moveToSplit(let edge):
let id = try group(of: tab)
let pane = try groupPane(id)
// The chat dock never splits (the Mac's ChatDockRules), from any entry point.
guard sync.tree?.chatDockPanes.contains(pane) != true else { throw WorkspaceCreationError(Self.chatDockCannotSplit, isForPerson: true) }
try await run(client, "move-tab-group-to-split", ["group": id, "pane": pane, "edge": edge.wire])
case .moveToColumn:
let id = try group(of: tab)
try await run(client, "move-tab-group-to-column", ["group": id, "pane": try groupPane(id)])
case .moveToNewWorkspace:
try await run(client, "move-tab-group-to-new-workspace", ["group": try group(of: tab)])
case .moveToWorkspace(let destination):
// The Mac's tabGroup.moveToWorkspace: the destination's first screen's default pane
// (never its chat dock: then that screen's first other pane), at the end of its tabs.
let id = try group(of: tab)
guard let target = sync.tree?.workspaces.first(where: { $0.id == destination }) else {
throw WorkspaceCreationError("That workspace is no longer on this Mac.", isForPerson: true)
}
let screen = target.screens.first
let docks = sync.tree?.chatDockPanes ?? []
let preferred = screen?.activePane.flatMap { docks.contains($0) ? nil : $0 }
guard let pane = preferred ?? screen?.panes.first(where: { !docks.contains($0) }) else {
throw WorkspaceCreationError("\(target.title) has no pane to move the group into.", isForPerson: true)
}
try await run(client, "move-tab-group", ["group": id, "pane": pane, "index": sync.tree?.paneSlots[pane]?.count ?? 0])
}
}

/// Whether Move Tab Group Left / Right can move `tab`'s group (`TabGroupReorder`).
public func canMoveTabGroup(of tab: WorkspaceTab, forward: Bool, in workspace: Workspace) -> Bool {
guard canManage(on: workspace.macID), let id = tab.group?.id, let pane = sync.tree?.groupPanes[id],
let slots = sync.tree?.paneSlots[pane] else { return false }
return DaemonTree.tabGroupMoveIndex(of: id, forward: forward, in: slots) != nil
}

/// The Mac's `tabGroup.newTab`: a terminal in the group's pane (hostd picks the pane's
/// folder; the phone sends none), then into the group.
public func newTabInGroup(of tab: WorkspaceTab, in workspace: Workspace) async throws -> WorkspaceTab {
let (client, _) = try target(tab, in: workspace)
let id = try group(of: tab)
let pane = try groupPane(id)
do {
let reply = try await client.request("new-tab", params: ["pane": pane])
guard let surface = (try WorkspaceSync.check(reply)["data"] as? [String: Any])?["surface"] as? Int else {
throw WorkspaceCreationError("The Mac didn't say which tab it opened")
}
try WorkspaceSync.check(await client.request("add-tabs-to-tab-group", params: Self.addParams(group: id, surface: surface)))
return try await listedTab(surface)
} catch let error as DaemonReplyError {
throw WorkspaceCreationError(error.message)
}
}

nonisolated static func addParams(group: String, surface: Int) -> sending [String: Any] {
["group": group, "surfaces": [surface]]
}

/// The tab of `surface` once the Mac lists it.
private func listedTab(_ surface: Int) async throws -> WorkspaceTab {
let id = DaemonTree.tabID(surface: surface)
let tree = try await sync.listNow()
guard let tab = tree.workspaces.flatMap(\.tabs).first(where: { $0.id == id }) else {
throw WorkspaceCreationError("The new tab didn't appear on the Mac")
}
return tab
}

private func groupPane(_ group: String) throws -> Int {
guard let pane = sync.tree?.groupPanes[group] else { throw WorkspaceCreationError("The Mac doesn't list this tab group") }
return pane
}

/// The Mac's refusals (`RefusalStrings.groupAtEdge`, `chatDockCannotSplit`), as sentences.
nonisolated static let groupAtEdge = "The group is already at the edge."
nonisolated static let chatDockCannotSplit = "The agent chat can't be split."

// MARK: Screens (the Mac's ScreenActionCatalog)

/// Select Screen: the screen becomes the workspace's active screen on the Mac. The daemon's
/// `select-screen` picks among the screens of whichever workspace the daemon has active (it
/// names no workspace), so the phone focuses the screen's own active pane instead
/// (`focus-pane`: "that pane, its screen and workspace active"), one request naming this
/// workspace's screen exactly. Like any focus change it also makes this workspace the
/// daemon's active one (what a terminal frontend following the session shows); the Mac's
/// windows keep their own shown workspace and screen.
public func selectScreen(_ screen: MacScreen, in workspace: Workspace) async throws {
guard canManage(on: workspace.macID), let client = sync.daemon else { throw WorkspaceCreationError("Not connected") }
guard let pane = screen.activePane ?? screen.panes.first else {
throw WorkspaceCreationError("The Mac doesn't list this screen's panes")
}
try await run(client, "focus-pane", ["pane": pane])
}

/// New Screen: the Mac's `new-screen` in this workspace (a new terminal in it, the Mac's
/// login shell; the phone names no program or folder).
public func newScreen(in workspace: Workspace) async throws -> WorkspaceTab {
guard canManage(on: workspace.macID), let client = sync.daemon else { throw WorkspaceCreationError("Not connected") }
guard let number = sync.tree?.workspaceNumbers[workspace.id] else { throw WorkspaceCreationError("The Mac doesn't list this workspace") }
do {
let reply = try await client.request("new-screen", params: ["workspace": number])
guard let surface = (try WorkspaceSync.check(reply)["data"] as? [String: Any])?["surface"] as? Int else {
throw WorkspaceCreationError("The Mac didn't say which tab it opened")
}
return try await listedTab(surface)
} catch let error as DaemonReplyError {
throw WorkspaceCreationError(error.message)
}
}

public func renameScreen(_ screen: MacScreen, in workspace: Workspace, to name: String) async throws {
guard canManage(on: workspace.macID), let client = sync.daemon else { throw WorkspaceCreationError("Not connected") }
try await run(client, "rename-screen", ["screen": screen.id, "name": name])
}

/// Close Screen: the phone has no Reopen Closed Screen, so the screen's terminals end with it
/// (`end_terminals`), as a phone tab close ends its terminal.
public func closeScreen(_ screen: MacScreen, in workspace: Workspace) async throws {
guard canManage(on: workspace.macID), let client = sync.daemon else { throw WorkspaceCreationError("Not connected") }
guard workspace.screens.count > 1 else { throw WorkspaceCreationError("This is the workspace's only screen.", isForPerson: true) }
try await run(client, "close-screen", ["screen": screen.id, "end_terminals": true])
}

// MARK: Saved tab groups (Reopen / Delete Saved Tab Group…)

/// The Mac's saved groups (`list-saved-tab-groups`), each marked open when one of its live
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@ public final class MultiMacWorkspaceBackend: WorkspaceCreationBackend, Workspace
public func deleteSavedTabGroup(_ saved: SavedTabGroup, on macID: String) async throws {
try await backend(macID).deleteSavedTabGroup(saved, on: macID)
}
public func canMoveTabGroup(of tab: WorkspaceTab, forward: Bool, in workspace: Workspace) -> Bool {
on(workspace.macID)?.canMoveTabGroup(of: tab, forward: forward, in: workspace) ?? false
}
public func newTabInGroup(of tab: WorkspaceTab, in workspace: Workspace) async throws -> WorkspaceTab {
try await backend(workspace.macID).newTabInGroup(of: tab, in: workspace)
}
public func selectScreen(_ screen: MacScreen, in workspace: Workspace) async throws {
try await backend(workspace.macID).selectScreen(screen, in: workspace)
}
public func newScreen(in workspace: Workspace) async throws -> WorkspaceTab {
try await backend(workspace.macID).newScreen(in: workspace)
}
public func renameScreen(_ screen: MacScreen, in workspace: Workspace, to name: String) async throws {
try await backend(workspace.macID).renameScreen(screen, in: workspace, to: name)
}
public func closeScreen(_ screen: MacScreen, in workspace: Workspace) async throws {
try await backend(workspace.macID).closeScreen(screen, in: workspace)
}

// MARK: WorkspaceArrangementBackend

Expand Down
Loading
Loading