Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
e189f5a
Add nested workspace group model
austinywang Jun 22, 2026
92554f4
Render nested workspace folders
austinywang Jun 22, 2026
386508c
Expose nested group parents in workspace payloads
austinywang Jun 22, 2026
a2a3d71
Update Swift file length budget
austinywang Jun 22, 2026
7e25f9f
Fix nested workspace folder review findings
austinywang Jun 22, 2026
fc1577e
Fix nested folder invariant edge cases
austinywang Jun 22, 2026
69258e9
Address nested folder PR feedback
austinywang Jun 22, 2026
6f36b81
Address nested folder review feedback
austinywang Jun 22, 2026
15b35eb
Fix nested folder mobile and root promotion
austinywang Jun 22, 2026
b930c13
Fix nested folder promotion edge cases
austinywang Jun 22, 2026
71fbb2d
Show stale grouped workspaces at sidebar root
austinywang Jun 22, 2026
ea084c6
Fix nested folder insertion and promotion
austinywang Jun 22, 2026
304361f
Merge origin/main into nested folders branch
austinywang Jun 22, 2026
a164b09
Fix root group pin classification
austinywang Jun 22, 2026
3c0b629
Fix nested group slot move ordering
austinywang Jun 22, 2026
7f732a6
Fix nested workspace folder review issues
austinywang Jun 22, 2026
43d15b0
Fix workspace group run helper compilation
austinywang Jun 22, 2026
d4c9567
Address nested folder pre-merge feedback
austinywang Jun 22, 2026
c66cd18
Make WorkspaceGroupRunChildRow generic over Tab
austinywang Jun 22, 2026
e8e7423
Merge remote-tracking branch 'origin/main' into issue-6610-nested-wor…
austinywang Jun 22, 2026
cd74b42
Isolate WorkspaceGroupRunChildRow to the main actor
austinywang Jun 22, 2026
60dbff1
Fix tuple membership assertions in nested render-item test
austinywang Jun 22, 2026
37fe23f
Re-link restored folders to nearest surviving ancestor
austinywang Jun 22, 2026
028258b
Refresh Swift file length budget for nested-folder restore fix
austinywang Jun 22, 2026
819ba96
Merge remote-tracking branch 'origin/main' into issue-6610-nested-wor…
austinywang Jul 4, 2026
026a871
Fix nested workspace folder closeout findings
austinywang Jul 4, 2026
577981f
Fix nested folder root drop targeting
austinywang Jul 4, 2026
9317957
Address nested folder review followups
austinywang Jul 4, 2026
f704023
Preserve mobile nested workspace row depth
austinywang Jul 4, 2026
59d77a3
Merge remote-tracking branch 'origin/main' into issue-6610-nested-wor…
austinywang Jul 4, 2026
a1c8eef
Fix stale group anchor sidebar rendering
austinywang Jul 4, 2026
99b6900
Add nested workspace folder regressions
austinywang Jul 4, 2026
8ace562
Fix nested workspace folder closeout regressions
austinywang Jul 4, 2026
50b5ef5
Add closeout regressions for nested folders
austinywang Jul 4, 2026
2b5e219
Fix nested folder hover and ungroup ordering
austinywang Jul 4, 2026
36732c8
Fix mobile shell deinit isolation
austinywang Jul 4, 2026
1af9abc
Add nested folder closeout regressions
austinywang Jul 4, 2026
24e4512
Fix nested folder closeout findings
austinywang Jul 4, 2026
3ff9f8d
Fix nested folder closeout edge cases
austinywang Jul 4, 2026
a551bd4
Fix nested folder review regressions
austinywang Jul 4, 2026
558ee80
Merge remote-tracking branch 'origin/main' into issue-6610-nested-wor…
austinywang Jul 4, 2026
78784d8
Use isolated mobile shell deinit
austinywang Jul 4, 2026
9e329f4
Fix nested folder edge drop parent scope
austinywang Jul 4, 2026
8062971
Guard folder edge drops across parents
austinywang Jul 4, 2026
6b63fde
Fix nested folder drop review gaps
austinywang Jul 4, 2026
cd9e6bb
Fix nested folder pin and reparent sync
austinywang Jul 4, 2026
0ff67a9
Split nested workspace helper types
austinywang Jul 4, 2026
c2e1525
Fix nested folder drop indicator scope
austinywang Jul 4, 2026
2019ad6
Add nested child boundary drop regression test
austinywang Jul 4, 2026
b04a566
Fix nested child boundary drop scope
austinywang Jul 4, 2026
4c6236f
Refine nested child boundary drop lanes
austinywang Jul 4, 2026
81bee70
Keep nested parent boundaries in subtree scope
austinywang Jul 4, 2026
b92024f
Classify nested pinned rows by root group
austinywang Jul 4, 2026
287c753
Ignore stale group ids for parent inference
austinywang Jul 4, 2026
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
14 changes: 7 additions & 7 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
34660 CLI/cmux.swift
17954 Sources/AppDelegate.swift
16427 Sources/ContentView.swift
17958 Sources/AppDelegate.swift
16535 Sources/ContentView.swift
14270 Sources/TerminalController.swift
13172 Sources/Workspace.swift
12348 cmuxTests/AppDelegateShortcutRoutingTests.swift
Expand All @@ -17,7 +17,7 @@
7218 cmuxTests/WorkspaceRemoteConnectionTests.swift
6359 cmuxTests/SessionPersistenceTests.swift
6255 cmuxTests/GhosttyConfigTests.swift
6166 Sources/TabManager.swift
6268 Sources/TabManager.swift
5915 cmuxTests/TerminalAndGhosttyTests.swift
5809 Sources/TextBoxInput.swift
5573 cmuxTests/BrowserConfigTests.swift
Expand Down Expand Up @@ -45,7 +45,7 @@
2229 Sources/TerminalWindowPortal.swift
2126 cmuxTests/CmuxConfigTests.swift
2085 cmuxTests/ShortcutAndCommandPaletteTests.swift
2078 Sources/SessionPersistence.swift
2084 Sources/SessionPersistence.swift
2011 Sources/KeyboardShortcutSettingsFileStore.swift
1944 Sources/RestorableAgentSession.swift
1900 cmuxTests/NotificationAndMenuBarTests.swift
Expand Down Expand Up @@ -87,7 +87,7 @@
1110 Sources/AppDelegate+CmuxSSHURL.swift
1093 cmuxUITests/BonsplitTabDragUITests.swift
1087 Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift
1044 cmuxTests/WorkspaceGroupTests.swift
1303 cmuxTests/WorkspaceGroupTests.swift
1033 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift
1021 cmuxUITests/TerminalCmdClickUITests.swift
1009 cmuxTests/CmuxTopSnapshotScopeTests.swift
Expand Down Expand Up @@ -190,7 +190,7 @@
592 Sources/TextBoxSubmitActions.swift
591 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift
590 Sources/Panels/BrowserNavigationDelegate.swift
589 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift
647 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift
588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift
586 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift
586 Sources/JSONCParser.swift
Expand All @@ -200,7 +200,7 @@
580 cmuxTests/CLIHookNoResponseTests.swift
577 cmuxTests/AppearanceSettingsTests.swift
576 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift
574 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift
884 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift
572 Sources/Feed/FeedTextEditorDebugWindowController.swift
567 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift
567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
public let isCollapsed: Bool
/// Whether the group is pinned on the Mac.
public let isPinned: Bool
/// The parent group id when this group is nested under another group.
public let parentGroupID: String?
/// The anchor workspace that owns this group. It is represented by the
/// group header and never rendered as a separate row.
public let anchorWorkspaceID: String
Expand All @@ -84,6 +86,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable {
case name
case isCollapsed = "is_collapsed"
case isPinned = "is_pinned"
case parentGroupID = "parent_group_id"
case anchorWorkspaceID = "anchor_workspace_id"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ extension MobileWorkspaceGroupPreview {
name: remote.name,
isCollapsed: remote.isCollapsed,
isPinned: remote.isPinned,
parentGroupID: remote.parentGroupID.map { MobileWorkspaceGroupPreview.ID(rawValue: $0) },
anchorWorkspaceID: MobileWorkspacePreview.ID(rawValue: remote.anchorWorkspaceID)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,38 @@ import Testing
#expect(withoutPreview.previewAt == nil)
}

@Test func workspaceListResponseDecodesNestedGroupParent() throws {
let json = Data("""
{
"workspaces": [],
"groups": [
{
"id": "hotels",
"name": "Hotels",
"is_collapsed": false,
"is_pinned": false,
"anchor_workspace_id": "hotels-anchor"
},
{
"id": "marriott",
"name": "Marriott",
"is_collapsed": true,
"is_pinned": false,
"parent_group_id": "hotels",
"anchor_workspace_id": "marriott-anchor"
}
]
}
""".utf8)

let response = try MobileSyncWorkspaceListResponse.decode(json)
let child = try #require(response.groups.last)
#expect(child.parentGroupID == "hotels")

let mapped = MobileWorkspaceGroupPreview(remote: child)
#expect(mapped.parentGroupID == "hotels")
}

/// The Mac stamps `last_activity_at` on every workspace (falling back to
/// creation time when there is no notification) and emits `has_unread` for
/// the row's unread dot. Both must decode when present and degrade safely
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -982,12 +982,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalSubscriptionStartTask?.cancel()
renderGridLivenessTimer?.cancel()
renderGridLivenessProbeTask?.cancel()
terminalSubscriptionRefreshTask?.cancel()
createWorkspaceTask?.cancel()
createTerminalTask?.cancel()
workspaceListRefreshTask?.cancel()
pullToRefreshTask?.cancel()
cancelAllTerminalReplayTasks()
cancelRemoteOperationTasks()
teardownSecondaryMacSubscriptions()
if let remoteClient {
Task { await remoteClient.disconnect() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
public var isCollapsed: Bool
/// Whether the group is pinned on the Mac.
public var isPinned: Bool
/// The parent group when this group is nested, or `nil` at the root.
public var parentGroupID: ID?
/// The anchor workspace that owns this group. It is represented by the group
/// header and never rendered as a separate row.
public var anchorWorkspaceID: MobileWorkspacePreview.ID
Expand All @@ -45,18 +47,21 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
/// - name: The group's user-facing name.
/// - isCollapsed: Whether the group is collapsed. Defaults to `false`.
/// - isPinned: Whether the group is pinned. Defaults to `false`.
/// - parentGroupID: The parent group id for nested groups. Defaults to `nil`.
/// - anchorWorkspaceID: The anchor workspace that owns the group.
public init(
id: ID,
name: String,
isCollapsed: Bool = false,
isPinned: Bool = false,
parentGroupID: ID? = nil,
anchorWorkspaceID: MobileWorkspacePreview.ID
) {
self.id = id
self.name = name
self.isCollapsed = isCollapsed
self.isPinned = isPinned
self.parentGroupID = parentGroupID
self.anchorWorkspaceID = anchorWorkspaceID
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,41 +18,44 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable {
/// anchor workspace (visible member rows carry their own dots); while
/// collapsed it reflects the whole group, anchor included, so hidden
/// member activity is never silently swallowed.
case groupHeader(MobileWorkspaceGroupPreview, hasUnread: Bool)
///
/// `depth` is the normalized parent-chain depth used by the view to indent
/// nested group headers.
case groupHeader(MobileWorkspaceGroupPreview, hasUnread: Bool, depth: Int = 0)
/// A workspace row. `indented` is `true` for non-anchor members nested under
/// a group header, so the view can inset them.
case workspace(MobileWorkspacePreview, indented: Bool)
/// a group header, so the view can inset them. `depth` is the containing
/// group's normalized parent-chain depth.
case workspace(MobileWorkspacePreview, indented: Bool, depth: Int = 0)

/// A stable, list-unique identity for SwiftUI diffing. Namespaced by item
/// kind (`group.` / `workspace.`) so a group header and a workspace row can
/// never collide even though both wrap UUID-backed ids.
public var id: String {
switch self {
case .groupHeader(let group, _):
case .groupHeader(let group, _, _):
return "group.\(group.id.rawValue)"
case .workspace(let workspace, _):
case .workspace(let workspace, _, _):
return "workspace.\(workspace.id.rawValue)"
}
}

/// Build the ordered list items from a workspace list and its groups.
///
/// Mirrors `SidebarWorkspaceRenderItem.renderItems` on the Mac:
/// - Items follow `workspaces` order. A group header is emitted at the first
/// member's position.
/// - Items follow `workspaces` order. A group header is emitted at its anchor's
/// position within its parent.
/// - The anchor workspace is never a separate row (the header represents it).
/// - When a group is collapsed, its members are skipped (header kept).
/// - When a group is collapsed, its descendants are skipped (header kept).
/// - Ungrouped workspaces interleave inline by position.
///
/// A `groupID` referencing a group not present in `groups` (e.g. a transient
/// payload skew) degrades gracefully: the workspace renders as an ungrouped
/// row rather than vanishing.
/// A `groupID` referencing a group not present in `groups`, or a group whose
/// anchor is absent, degrades gracefully: the workspace renders as an
/// ungrouped row rather than vanishing.
///
/// Non-contiguous members of an already-emitted group (possible when the
/// Mac's spatial order interleaves another row between members) do not
/// re-emit the header: the stray member renders at its own position, still
/// indented to mark its membership, and is still hidden while its group is
/// collapsed. Membership is never silently dropped.
/// Non-contiguous members of an already-emitted expanded group stay at
/// their own spatial position, still indented to mark membership. A
/// collapsed group still hides those stray members and includes them in
/// the collapsed unread aggregate.
///
/// - Parameters:
/// - workspaces: The workspaces in the Mac's spatial order.
Expand All @@ -64,55 +67,140 @@ public enum MobileWorkspaceListItem: Identifiable, Equatable, Sendable {
) -> [MobileWorkspaceListItem] {
guard !workspaces.isEmpty else { return [] }
let groupsByID = Dictionary(groups.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let workspaceIDs = Set(workspaces.map(\.id))
let renderableGroupIDs = Set(groupsByID.values.compactMap {
workspaceIDs.contains($0.anchorWorkspaceID) ? $0.id : nil
})
var anchorGroupByWorkspaceID: [MobileWorkspacePreview.ID: MobileWorkspaceGroupPreview] = [:]
for group in groups where renderableGroupIDs.contains(group.id) && anchorGroupByWorkspaceID[group.anchorWorkspaceID] == nil {
anchorGroupByWorkspaceID[group.anchorWorkspaceID] = group
}
var parentGroupIDByGroupID: [MobileWorkspaceGroupPreview.ID: MobileWorkspaceGroupPreview.ID?] = [:]
for group in groups {
let parentID: MobileWorkspaceGroupPreview.ID? = {
guard let parentGroupID = group.parentGroupID,
parentGroupID != group.id,
renderableGroupIDs.contains(parentGroupID) else {
return nil
}
return parentGroupID
}()
parentGroupIDByGroupID[group.id] = parentID
}

// Aggregate unread state per group up front (membership can be
// non-contiguous, so this cannot be folded into the emit loop).
// Mirrors the Mac header badge: anchor-only while expanded, whole
// group (anchor included) while collapsed.
// subtree (anchor included) while collapsed.
var anchorUnreadByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:]
var anyMemberUnreadByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:]
var directUnreadByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:]
var childGroupsByParentID: [MobileWorkspaceGroupPreview.ID?: [MobileWorkspaceGroupPreview]] = [:]
for workspace in workspaces {
guard let groupID = workspace.groupID, let group = groupsByID[groupID] else { continue }
anyMemberUnreadByGroupID[groupID, default: false] = anyMemberUnreadByGroupID[groupID, default: false] || workspace.hasUnread
guard let groupID = workspace.groupID,
let group = groupsByID[groupID],
renderableGroupIDs.contains(groupID) else { continue }
directUnreadByGroupID[groupID, default: false] = directUnreadByGroupID[groupID, default: false] || workspace.hasUnread
if group.anchorWorkspaceID == workspace.id {
anchorUnreadByGroupID[groupID] = workspace.hasUnread
}
}
for group in groups where renderableGroupIDs.contains(group.id) {
childGroupsByParentID[parentGroupIDByGroupID[group.id] ?? nil, default: []].append(group)
}

var subtreeUnreadByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:]
func subtreeHasUnread(for groupID: MobileWorkspaceGroupPreview.ID, visiting: inout Set<MobileWorkspaceGroupPreview.ID>) -> Bool {
if let cached = subtreeUnreadByGroupID[groupID] {
return cached
}
guard visiting.insert(groupID).inserted else {
return directUnreadByGroupID[groupID, default: false]
}
var hasUnread = directUnreadByGroupID[groupID, default: false]
for childGroup in childGroupsByParentID[Optional(groupID)] ?? [] {
hasUnread = hasUnread || subtreeHasUnread(for: childGroup.id, visiting: &visiting)
}
visiting.remove(groupID)
subtreeUnreadByGroupID[groupID] = hasUnread
return hasUnread
}

func normalizedParentGroupID(for groupID: MobileWorkspaceGroupPreview.ID) -> MobileWorkspaceGroupPreview.ID? {
parentGroupIDByGroupID[groupID] ?? nil
}

var depthByGroupID: [MobileWorkspaceGroupPreview.ID: Int] = [:]
func groupDepth(for groupID: MobileWorkspaceGroupPreview.ID, visiting: inout Set<MobileWorkspaceGroupPreview.ID>) -> Int {
if let cached = depthByGroupID[groupID] {
return cached
}
guard visiting.insert(groupID).inserted else { return 0 }
defer { visiting.remove(groupID) }
guard let parentID = normalizedParentGroupID(for: groupID) else {
depthByGroupID[groupID] = 0
return 0
}
let depth = groupDepth(for: parentID, visiting: &visiting) + 1
depthByGroupID[groupID] = depth
return depth
}

var items: [MobileWorkspaceListItem] = []
items.reserveCapacity(workspaces.count + groups.count)
var lastEmittedGroupID: MobileWorkspaceGroupPreview.ID?
var emittedHeaders: Set<MobileWorkspaceGroupPreview.ID> = []
var collapsedByGroupID: [MobileWorkspaceGroupPreview.ID: Bool] = [:]

for workspace in workspaces {
// Resolve the membership only when the referenced group actually
// exists; otherwise treat the workspace as ungrouped.
let groupID: MobileWorkspaceGroupPreview.ID? = workspace.groupID
.flatMap { groupsByID[$0] != nil ? $0 : nil }

if groupID != lastEmittedGroupID {
lastEmittedGroupID = groupID
if let groupID, let group = groupsByID[groupID], !emittedHeaders.contains(groupID) {
let hasUnread = group.isCollapsed
? anyMemberUnreadByGroupID[groupID, default: false]
: anchorUnreadByGroupID[groupID, default: false]
items.append(.groupHeader(group, hasUnread: hasUnread))
emittedHeaders.insert(groupID)
collapsedByGroupID[groupID] = group.isCollapsed
func isHiddenByCollapsedAncestor(_ groupID: MobileWorkspaceGroupPreview.ID) -> Bool {
var visited: Set<MobileWorkspaceGroupPreview.ID> = []
var cursor = normalizedParentGroupID(for: groupID)
while let parentID = cursor {
guard visited.insert(parentID).inserted else { return false }
if collapsedByGroupID[parentID] == true {
return true
}
cursor = normalizedParentGroupID(for: parentID)
}
return false
}

func appendGroup(_ group: MobileWorkspaceGroupPreview) {
guard emittedHeaders.insert(group.id).inserted else { return }
var visiting: Set<MobileWorkspaceGroupPreview.ID> = []
let hasUnread = group.isCollapsed
? subtreeHasUnread(for: group.id, visiting: &visiting)
: anchorUnreadByGroupID[group.id, default: false]
var depthVisiting: Set<MobileWorkspaceGroupPreview.ID> = []
let depth = groupDepth(for: group.id, visiting: &depthVisiting)
items.append(.groupHeader(group, hasUnread: hasUnread, depth: depth))
collapsedByGroupID[group.id] = group.isCollapsed
}

if let groupID, let group = groupsByID[groupID], group.anchorWorkspaceID == workspace.id {
// Anchor is represented exclusively by the group header.
for workspace in workspaces {
guard let groupID = workspace.groupID,
groupsByID[groupID] != nil,
renderableGroupIDs.contains(groupID) else {
items.append(.workspace(workspace, indented: false))
continue
}

let isCollapsed = groupID.map { collapsedByGroupID[$0] ?? false } ?? false
if groupID == nil || !isCollapsed {
items.append(.workspace(workspace, indented: groupID != nil))
if isHiddenByCollapsedAncestor(groupID) {
continue
}

if let anchoredGroup = anchorGroupByWorkspaceID[workspace.id],
anchoredGroup.id == groupID {
appendGroup(anchoredGroup)
continue
}

if collapsedByGroupID[groupID] == true {
continue
}

var depthVisiting: Set<MobileWorkspaceGroupPreview.ID> = []
items.append(.workspace(workspace, indented: true, depth: groupDepth(for: groupID, visiting: &depthVisiting)))
}

return items
}
}
Loading