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
75 changes: 62 additions & 13 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15556,13 +15556,20 @@ private struct TabItemView: View, Equatable {
}

private func toggleWorkspaceTerminalScrollBarHidden(targetIds: [UUID]) {
let currentlyHidden = !targetIds.isEmpty && targetIds.allSatisfy { targetId in
tabManager.tabs.first(where: { $0.id == targetId })?.terminalScrollBarHidden == true
}
let targetIdSet = Set(targetIds)
var liveTargetCount = 0
var hiddenTargetCount = 0
for workspace in tabManager.tabs where targetIdSet.contains(workspace.id) {
liveTargetCount += 1
if workspace.terminalScrollBarHidden {
hiddenTargetCount += 1
}
}
let currentlyHidden = !targetIdSet.isEmpty &&
liveTargetCount == targetIdSet.count &&
hiddenTargetCount == liveTargetCount
let hideScrollBar = !currentlyHidden
for targetId in targetIds {
tabManager.setWorkspaceTerminalScrollBarHidden(tabId: targetId, hidden: hideScrollBar)
}
tabManager.setWorkspaceTerminalScrollBarHidden(hidden: hideScrollBar, forWorkspaceIds: targetIds)
}

private func promptCustomColor(targetIds: [UUID]) {
Expand Down Expand Up @@ -16258,6 +16265,38 @@ private struct SidebarBonsplitTabDropDelegate: DropDelegate {
}
}

@MainActor
enum SidebarWorkspaceSelectionSyncPolicy {
static func reconciledSelection(
previousSelectionIds: Set<UUID>,
liveWorkspaceIds: [UUID],
fallbackSelectedWorkspaceId: UUID?
) -> Set<UUID> {
let liveIdSet = Set(liveWorkspaceIds)
let liveSelectionIds = previousSelectionIds.filter { liveIdSet.contains($0) }
if !liveSelectionIds.isEmpty {
return liveSelectionIds
}
if let fallbackSelectedWorkspaceId, liveIdSet.contains(fallbackSelectedWorkspaceId) {
return [fallbackSelectedWorkspaceId]
}
return []
}

static func anchorIndex(
preferredWorkspaceId: UUID?,
selectedWorkspaceIds: Set<UUID>,
liveWorkspaceIds: [UUID]
) -> Int? {
if let preferredWorkspaceId,
selectedWorkspaceIds.contains(preferredWorkspaceId),
let preferredIndex = liveWorkspaceIds.firstIndex(of: preferredWorkspaceId) {
return preferredIndex
}
return liveWorkspaceIds.firstIndex { selectedWorkspaceIds.contains($0) }
}
}

@MainActor
private struct SidebarTabDropDelegate: DropDelegate {
let targetTabId: UUID?
Expand Down Expand Up @@ -16355,14 +16394,9 @@ private struct SidebarTabDropDelegate: DropDelegate {
#if DEBUG
cmuxDebugLog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)")
#endif
let selectionBeforeReorder = selectedTabIds
_ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex)
if let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
syncSidebarSelection(preferredSelectedTabId: selectedId)
} else {
selectedTabIds = []
syncSidebarSelection()
}
syncSidebarSelection(preserving: selectionBeforeReorder)
return true
}

Expand Down Expand Up @@ -16390,6 +16424,21 @@ private struct SidebarTabDropDelegate: DropDelegate {
}
}

private func syncSidebarSelection(preserving previousSelectionIds: Set<UUID>) {
let liveWorkspaceIds = tabManager.tabs.map(\.id)
let nextSelectionIds = SidebarWorkspaceSelectionSyncPolicy.reconciledSelection(
previousSelectionIds: previousSelectionIds,
liveWorkspaceIds: liveWorkspaceIds,
fallbackSelectedWorkspaceId: tabManager.selectedTabId
)
selectedTabIds = nextSelectionIds
lastSidebarSelectionIndex = SidebarWorkspaceSelectionSyncPolicy.anchorIndex(
preferredWorkspaceId: tabManager.selectedTabId,
selectedWorkspaceIds: nextSelectionIds,
liveWorkspaceIds: liveWorkspaceIds
)
}

private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String {
guard let indicator else { return "nil" }
let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end"
Expand Down
66 changes: 65 additions & 1 deletion Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5614,8 +5614,15 @@ class TabManager: ObservableObject {
}

func applyWorkspaceColor(_ color: String?, toWorkspaceIds workspaceIds: [UUID]) {
for workspaceId in workspaceIds {
guard !workspaceIds.isEmpty else { return }
if workspaceIds.count == 1, let workspaceId = workspaceIds.first {
setTabColor(tabId: workspaceId, color: color)
return
}

let targetIds = Set(workspaceIds)
for tab in tabs where targetIds.contains(tab.id) {
tab.setCustomColor(color)
}
}
Comment on lines +5623 to 5627

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The multi-item path calls tab.setCustomColor(color) directly, bypassing the setTabColor wrapper used by the single-item path. Both paths are functionally equivalent today because those wrappers do nothing beyond a lookup and a direct call. But any future side effect added to the wrappers (validation, an additional notification, a persistence hook) would be silently skipped for batch operations. Calling through the wrapper keeps both paths in sync automatically.

Suggested change
let targetIds = Set(workspaceIds)
for tab in tabs where targetIds.contains(tab.id) {
tab.setCustomColor(color)
}
}
let targetIds = Set(workspaceIds)
for tab in tabs where targetIds.contains(tab.id) {
setTabColor(tabId: tab.id, color: color)
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this path direct intentionally: calling setTabColor(tabId:) inside the multi-item loop would reintroduce the per-target lookup this PR removes. The batch method owns the one-pass mutation boundary.

— Claude Code


Expand All @@ -5629,6 +5636,19 @@ class TabManager: ObservableObject {
tab.setTerminalScrollBarHidden(hidden)
}

func setWorkspaceTerminalScrollBarHidden(hidden: Bool, forWorkspaceIds workspaceIds: [UUID]) {
guard !workspaceIds.isEmpty else { return }
if workspaceIds.count == 1, let workspaceId = workspaceIds.first {
setWorkspaceTerminalScrollBarHidden(tabId: workspaceId, hidden: hidden)
return
}

let targetIds = Set(workspaceIds)
for tab in tabs where targetIds.contains(tab.id) {
tab.setTerminalScrollBarHidden(hidden)
}
}

func togglePin(tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let tab = tabs[index]
Expand All @@ -5642,6 +5662,50 @@ class TabManager: ObservableObject {
postWorkspaceOrderDidChange(movedWorkspaceIds: [tab.id])
}

@discardableResult
func setPinned(workspaceIds: [UUID], pinned: Bool) -> [UUID] {
guard !workspaceIds.isEmpty else { return [] }
if workspaceIds.count == 1,
let workspaceId = workspaceIds.first,
let tab = tabs.first(where: { $0.id == workspaceId }) {
let changed = tab.isPinned != pinned
setPinned(tab, pinned: pinned)
return changed ? [workspaceId] : []
}

var seen = Set<UUID>()
let orderedTargetIds = workspaceIds.filter { seen.insert($0).inserted }
let targetIds = Set(orderedTargetIds)
var workspacesById: [UUID: Workspace] = [:]
var changedIdSet = Set<UUID>()

for workspace in tabs {
workspacesById[workspace.id] = workspace
guard targetIds.contains(workspace.id), workspace.isPinned != pinned else { continue }
workspace.isPinned = pinned
changedIdSet.insert(workspace.id)
}

guard !changedIdSet.isEmpty else { return [] }
let changedIds = orderedTargetIds.filter { changedIdSet.contains($0) }

let changedWorkspaces: [Workspace]
if pinned {
changedWorkspaces = changedIds.compactMap { workspacesById[$0] }
} else {
// Keep parity with reorderTabForPinnedState: each unpinned item
// is inserted at the front of the unpinned segment, so rebuilding a
// batch in one pass must reverse the changed input order.
changedWorkspaces = changedIds.reversed().compactMap { workspacesById[$0] }
}

let remainingPinned = tabs.filter { $0.isPinned && !changedIdSet.contains($0.id) }
let remainingUnpinned = tabs.filter { !$0.isPinned && !changedIdSet.contains($0.id) }
tabs = remainingPinned + changedWorkspaces + remainingUnpinned
postWorkspaceOrderDidChange(movedWorkspaceIds: changedIds)
return changedIds
Comment on lines +5692 to +5706

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Batch-unpin ordering silently replicates reorderTabForPinnedState internals

changedIds.reversed() is correct because reorderTabForPinnedState always inserts each newly-unpinned tab at position pinnedCount — the front of the unpinned segment — so the last item processed ends up first. The batch rebuild matches that by reversing the input order. The invariant is not captured anywhere that both sites share; if reorderTabForPinnedState's insertion point changes, the reversed placement here will silently diverge. Adding a comment that names the invariant being replicated, or a cross-reference to reorderTabForPinnedState, would make the coupling explicit and easier to keep in sync.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by making the reversed unpin ordering explicitly reference reorderTabForPinnedState's insert-at-front invariant.

— Claude Code

}

private func reorderTabForPinnedState(_ tab: Workspace) {
guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
tabs.remove(at: index)
Expand Down
19 changes: 7 additions & 12 deletions Sources/WorkspaceActionDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@ enum WorkspaceActionDispatcher {
in tabManager: TabManager,
target: Target
) -> PinState? {
let workspacesById = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) })
let targetWorkspaceIds = liveWorkspaceIds(in: tabManager, from: target.workspaceIds)
guard !targetWorkspaceIds.isEmpty else { return nil }

let anchorWorkspaceId = target.anchorWorkspaceId.flatMap { anchorId in
tabManager.tabs.contains { $0.id == anchorId } ? anchorId : nil
workspacesById[anchorId] == nil ? nil : anchorId
} ?? targetWorkspaceIds[0]

guard let anchorWorkspace = tabManager.tabs.first(where: { $0.id == anchorWorkspaceId }) else {
guard let anchorWorkspace = workspacesById[anchorWorkspaceId] else {
return nil
}

Expand Down Expand Up @@ -67,16 +68,10 @@ enum WorkspaceActionDispatcher {
in tabManager: TabManager
) -> PinResult {
let targetWorkspaceIds = liveWorkspaceIds(in: tabManager, from: state.targetWorkspaceIds)
var changedWorkspaceIds: [UUID] = []

for workspaceId in targetWorkspaceIds {
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { continue }
let wasPinned = workspace.isPinned
tabManager.setPinned(workspace, pinned: state.pinned)
if wasPinned != state.pinned {
changedWorkspaceIds.append(workspaceId)
}
}
let changedWorkspaceIds = tabManager.setPinned(
workspaceIds: targetWorkspaceIds,
pinned: state.pinned
)

return PinResult(
targetWorkspaceIds: targetWorkspaceIds,
Expand Down
25 changes: 25 additions & 0 deletions cmuxTests/WorkspaceActionDispatcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,31 @@ final class WorkspaceActionDispatcherTests: XCTestCase {
XCTAssertEqual(manager.tabs.map(\.id), [second.id, third.id, first.id])
}

func testPinActionUnpinsMultipleTargetsWithExistingOrdering() throws {
let manager = TabManager()
let first = try XCTUnwrap(manager.tabs.first)
let second = manager.addWorkspace()
let third = manager.addWorkspace()
manager.setPinned(first, pinned: true)
manager.setPinned(second, pinned: true)
manager.setPinned(third, pinned: true)
let target = WorkspaceActionDispatcher.Target(
workspaceIds: [second.id, third.id],
anchorWorkspaceId: second.id
)

let state = try XCTUnwrap(WorkspaceActionDispatcher.pinState(in: manager, target: target))
let result = WorkspaceActionDispatcher.performPinAction(state, in: manager)

XCTAssertFalse(state.pinned)
XCTAssertEqual(result.targetWorkspaceIds, [second.id, third.id])
XCTAssertEqual(result.changedWorkspaceIds, [second.id, third.id])
XCTAssertTrue(first.isPinned)
XCTAssertFalse(second.isPinned)
XCTAssertFalse(third.isPinned)
XCTAssertEqual(manager.tabs.map(\.id), [first.id, third.id, second.id])
}

func testCapturedPinStateKeepsLabelAndActionConsistent() throws {
let manager = TabManager()
let workspace = try XCTUnwrap(manager.tabs.first)
Expand Down
92 changes: 92 additions & 0 deletions cmuxTests/WorkspaceStressProfileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,98 @@ final class WorkspaceStressProfileTests: XCTestCase {
}
}

func testWorkspaceBatchActionsStressProfile() {
let config = StressConfig.current()
let welcomeWasShown = UserDefaults.standard.object(forKey: WelcomeSettings.shownKey)
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
defer {
if let welcomeWasShown {
UserDefaults.standard.set(welcomeWasShown, forKey: WelcomeSettings.shownKey)
} else {
UserDefaults.standard.removeObject(forKey: WelcomeSettings.shownKey)
}
}

let manager = TabManager(autoWelcomeIfNeeded: false)
for workspaceIndex in 1..<config.workspaceCount {
_ = manager.addWorkspace(
title: "Workspace \(label(for: workspaceIndex))",
select: false,
eagerLoadTerminal: false,
autoWelcomeIfNeeded: false
)
}

let workspaceIds = manager.tabs.map(\.id)
let anchorWorkspaceId = workspaceIds[0]
var colorSamples: [TimedSample] = []
var scrollBarSamples: [TimedSample] = []
var pinSamples: [TimedSample] = []
var unpinSamples: [TimedSample] = []

for pass in 0..<config.switchPasses {
timed("pass-\(label(for: pass))-color-apply", collectInto: &colorSamples) {
manager.applyWorkspaceColor("#1565C0", toWorkspaceIds: workspaceIds)
}
XCTAssertTrue(manager.tabs.allSatisfy { $0.customColor == "#1565C0" })
timed("pass-\(label(for: pass))-color-clear", collectInto: &colorSamples) {
manager.applyWorkspaceColor(nil, toWorkspaceIds: workspaceIds)
}
XCTAssertTrue(manager.tabs.allSatisfy { $0.customColor == nil })
timed("pass-\(label(for: pass))-scrollbar-hide", collectInto: &scrollBarSamples) {
manager.setWorkspaceTerminalScrollBarHidden(hidden: true, forWorkspaceIds: workspaceIds)
}
XCTAssertTrue(manager.tabs.allSatisfy { $0.terminalScrollBarHidden })
timed("pass-\(label(for: pass))-scrollbar-show", collectInto: &scrollBarSamples) {
manager.setWorkspaceTerminalScrollBarHidden(hidden: false, forWorkspaceIds: workspaceIds)
}
XCTAssertTrue(manager.tabs.allSatisfy { !$0.terminalScrollBarHidden })
let pinResult = timed("pass-\(label(for: pass))-pin", collectInto: &pinSamples) {
WorkspaceActionDispatcher.performPinAction(
WorkspaceActionDispatcher.PinState(
targetWorkspaceIds: workspaceIds,
anchorWorkspaceId: anchorWorkspaceId,
pinned: true
),
in: manager
)
}
XCTAssertFalse(pinResult.changedWorkspaceIds.isEmpty)
XCTAssertTrue(manager.tabs.allSatisfy { $0.isPinned })
let unpinResult = timed("pass-\(label(for: pass))-unpin", collectInto: &unpinSamples) {
WorkspaceActionDispatcher.performPinAction(
WorkspaceActionDispatcher.PinState(
targetWorkspaceIds: workspaceIds,
anchorWorkspaceId: anchorWorkspaceId,
pinned: false
),
in: manager
)
}
XCTAssertFalse(unpinResult.changedWorkspaceIds.isEmpty)
XCTAssertTrue(manager.tabs.allSatisfy { !$0.isPinned })
}

XCTAssertEqual(manager.tabs.count, config.workspaceCount)
XCTAssertTrue(manager.tabs.allSatisfy { !$0.isPinned })
XCTAssertTrue(manager.tabs.allSatisfy { $0.customColor == nil })
XCTAssertTrue(manager.tabs.allSatisfy { !$0.terminalScrollBarHidden })

Comment thread
coderabbitai[bot] marked this conversation as resolved.
let report = [
"Workspace batch action stress config workspaces=\(config.workspaceCount) passes=\(config.switchPasses)",
reportLine(title: "color", summary: TimingSummary(samples: colorSamples), slowest: slowest(colorSamples)),
reportLine(title: "scrollbar", summary: TimingSummary(samples: scrollBarSamples), slowest: slowest(scrollBarSamples)),
reportLine(title: "pin", summary: TimingSummary(samples: pinSamples), slowest: slowest(pinSamples)),
reportLine(title: "unpin", summary: TimingSummary(samples: unpinSamples), slowest: slowest(unpinSamples))
].joined(separator: "\n")

print(report)
let attachment = XCTAttachment(string: report)
attachment.name = "workspace-batch-actions-stress-profile"
attachment.lifetime = .keepAlways
add(attachment)
}

private func populate(workspace: Workspace, tabsPerWorkspace: Int) {
guard tabsPerWorkspace > 0 else { return }
while workspace.panels.count < tabsPerWorkspace {
Expand Down
Loading
Loading