diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 6df6abaf3ab2..ded46156f911 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3261,9 +3261,36 @@ class TabManager: ObservableObject { /// Resize split - not directly supported by bonsplit, but we can adjust divider positions func resizeSplit(tabId: UUID, surfaceId: UUID, direction: ResizeDirection, amount: UInt16) -> Bool { - // Bonsplit handles resize through its own divider dragging - // This is a no-op for now as bonsplit manages divider positions internally - return false + guard amount > 0, + let tab = tabs.first(where: { $0.id == tabId }), + let paneId = tab.paneId(forPanelId: surfaceId) else { return false } + + let paneUUID = paneId.id + guard tab.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else { + return false + } + + var candidates: [ResizeSplitCandidate] = [] + let trace = resizeSplitCollectCandidates( + node: tab.bonsplitController.treeSnapshot(), + targetPaneId: paneUUID.uuidString, + candidates: &candidates + ) + guard trace.containsTarget else { return false } + + let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation } + guard !orientationMatches.isEmpty else { return false } + + guard let candidate = orientationMatches.first(where: { + $0.paneInFirstChild == direction.requiresPaneInFirstChild + }) else { + return false + } + + let delta = CGFloat(amount) / candidate.axisPixels + let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta) + let clamped = min(max(requested, 0.1), 0.9) + return tab.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) } /// Equalize splits - not directly supported by bonsplit @@ -3330,6 +3357,68 @@ class TabManager: ObservableObject { } } + private struct ResizeSplitCandidate { + let splitId: UUID + let orientation: String + let paneInFirstChild: Bool + let dividerPosition: CGFloat + let axisPixels: CGFloat + } + + private struct ResizeSplitTrace { + let containsTarget: Bool + let bounds: CGRect + } + + private func resizeSplitCollectCandidates( + node: ExternalTreeNode, + targetPaneId: String, + candidates: inout [ResizeSplitCandidate] + ) -> ResizeSplitTrace { + switch node { + case .pane(let pane): + let bounds = CGRect( + x: pane.frame.x, + y: pane.frame.y, + width: pane.frame.width, + height: pane.frame.height + ) + return ResizeSplitTrace(containsTarget: pane.id == targetPaneId, bounds: bounds) + + case .split(let split): + let first = resizeSplitCollectCandidates( + node: split.first, + targetPaneId: targetPaneId, + candidates: &candidates + ) + let second = resizeSplitCollectCandidates( + node: split.second, + targetPaneId: targetPaneId, + candidates: &candidates + ) + + let combinedBounds = first.bounds.union(second.bounds) + let containsTarget = first.containsTarget || second.containsTarget + + if containsTarget, + let splitUUID = UUID(uuidString: split.id) { + let orientation = split.orientation.lowercased() + let axisPixels: CGFloat = orientation == "horizontal" + ? combinedBounds.width + : combinedBounds.height + candidates.append(ResizeSplitCandidate( + splitId: splitUUID, + orientation: orientation, + paneInFirstChild: first.containsTarget, + dividerPosition: CGFloat(split.dividerPosition), + axisPixels: max(axisPixels, 1) + )) + } + + return ResizeSplitTrace(containsTarget: containsTarget, bounds: combinedBounds) + } + } + /// Close a surface/panel func closeSurface(tabId: UUID, surfaceId: UUID) -> Bool { guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } @@ -5015,6 +5104,31 @@ enum SplitDirection { /// Resize direction for backwards compatibility enum ResizeDirection { case left, right, up, down + + var splitOrientation: String { + switch self { + case .left, .right: + return "horizontal" + case .up, .down: + return "vertical" + } + } + + /// A split controls the target pane's right/bottom edge when the target is + /// the first child, and left/top edge when the target is the second child. + var requiresPaneInFirstChild: Bool { + switch self { + case .right, .down: + return true + case .left, .up: + return false + } + } + + /// Positive values move the divider toward the second child (right/down). + var dividerDeltaSign: CGFloat { + requiresPaneInFirstChild ? 1 : -1 + } } extension Notification.Name { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b2c0ca68d2f4..1fb475680532 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -228,6 +228,7 @@ extension Workspace { entry.paneId, snapshot: entry.snapshot, panelSnapshotsById: panelSnapshotsById, + workspaceFocusedPanelId: snapshot.focusedPanelId, oldToNewPanelIds: &oldToNewPanelIds ) } @@ -492,6 +493,7 @@ extension Workspace { _ paneId: PaneID, snapshot: SessionPaneLayoutSnapshot, panelSnapshotsById: [UUID: SessionPanelSnapshot], + workspaceFocusedPanelId: UUID?, oldToNewPanelIds: inout [UUID: UUID] ) { let existingPanelIds = bonsplitController @@ -502,7 +504,11 @@ extension Workspace { var createdPanelIds: [UUID] = [] for oldPanelId in desiredOldPanelIds { guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } - guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } + guard let createdPanelId = createPanel( + from: panelSnapshot, + inPane: paneId, + workspaceFocusedPanelId: workspaceFocusedPanelId + ) else { continue } createdPanelIds.append(createdPanelId) oldToNewPanelIds[oldPanelId] = createdPanelId } @@ -531,10 +537,16 @@ extension Workspace { } } - private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { + private func createPanel( + from snapshot: SessionPanelSnapshot, + inPane paneId: PaneID, + workspaceFocusedPanelId: UUID? + ) -> UUID? { switch snapshot.type { case .terminal: - let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory + let workingDirectory = snapshot.terminal?.workingDirectory + ?? snapshot.directory + ?? (snapshot.id == workspaceFocusedPanelId ? currentDirectory : nil) let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( for: snapshot.terminal?.scrollback ) diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 8c00c0c16ef0..e675456e873d 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -41,6 +41,124 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(restored.panelTitle(panelId: restoredPanelId), "Readme") } + @MainActor + func testWorkspaceSessionSnapshotPersistsFocusedTerminalWorkingDirectory() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-restore-cwd-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let requestedDirectory = root.appendingPathComponent("requested", isDirectory: true) + let focusedDirectory = root.appendingPathComponent("focused", isDirectory: true) + let unfocusedDirectory = root.appendingPathComponent("unfocused", isDirectory: true) + try FileManager.default.createDirectory(at: requestedDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: focusedDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: unfocusedDirectory, withIntermediateDirectories: true) + + let workspace = Workspace(title: "Terminal", workingDirectory: requestedDirectory.path, portOrdinal: 0) + let focusedPanelId = try XCTUnwrap(workspace.focusedPanelId) + workspace.updatePanelDirectory(panelId: focusedPanelId, directory: focusedDirectory.path) + let unfocusedPanel = try XCTUnwrap( + workspace.newTerminalSplit(from: focusedPanelId, orientation: .horizontal, focus: false) + ) + workspace.updatePanelDirectory(panelId: unfocusedPanel.id, directory: unfocusedDirectory.path) + workspace.focusPanel(focusedPanelId) + + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + let persistedFocusedPanel = try XCTUnwrap( + snapshot.panels.first(where: { $0.id == snapshot.focusedPanelId }) + ) + let persistedUnfocusedPanel = try XCTUnwrap( + snapshot.panels.first(where: { $0.id == unfocusedPanel.id }) + ) + + XCTAssertEqual(persistedFocusedPanel.terminal?.workingDirectory, focusedDirectory.path) + XCTAssertEqual(persistedUnfocusedPanel.terminal?.workingDirectory, unfocusedDirectory.path) + XCTAssertEqual(snapshot.currentDirectory, focusedDirectory.path) + } + + @MainActor + func testWorkspaceSessionSnapshotRestoresFocusedTerminalWorkingDirectoryWhenPanelDirectoryIsMissing() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-restore-fallback-cwd-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let requestedDirectory = root.appendingPathComponent("requested", isDirectory: true) + let restoredDirectory = root.appendingPathComponent("restored", isDirectory: true) + let explicitDirectory = root.appendingPathComponent("explicit", isDirectory: true) + try FileManager.default.createDirectory(at: requestedDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: restoredDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: explicitDirectory, withIntermediateDirectories: true) + + var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion).windows[0].tabManager.workspaces[0] + let focusedPanelId = UUID() + let unfocusedPanelId = UUID() + snapshot.focusedPanelId = focusedPanelId + snapshot.currentDirectory = restoredDirectory.path + snapshot.layout = .pane( + SessionPaneLayoutSnapshot( + panelIds: [focusedPanelId, unfocusedPanelId], + selectedPanelId: focusedPanelId + ) + ) + snapshot.panels = [ + SessionPanelSnapshot( + id: focusedPanelId, + type: .terminal, + title: "Focused Terminal", + customTitle: nil, + directory: nil, + isPinned: false, + isManuallyUnread: false, + gitBranch: nil, + listeningPorts: [], + ttyName: nil, + terminal: SessionTerminalPanelSnapshot( + workingDirectory: nil, + scrollback: nil + ), + browser: nil, + markdown: nil + ), + SessionPanelSnapshot( + id: unfocusedPanelId, + type: .terminal, + title: "Unfocused Terminal", + customTitle: nil, + directory: explicitDirectory.path, + isPinned: false, + isManuallyUnread: false, + gitBranch: nil, + listeningPorts: [], + ttyName: nil, + terminal: SessionTerminalPanelSnapshot( + workingDirectory: nil, + scrollback: nil + ), + browser: nil, + markdown: nil + ) + ] + + let restored = Workspace(title: "Terminal", workingDirectory: requestedDirectory.path, portOrdinal: 0) + restored.restoreSessionSnapshot(snapshot) + + let restoredFocusedPanel = try XCTUnwrap(restored.terminalPanel(for: try XCTUnwrap(restored.focusedPanelId))) + let restoredUnfocusedPanel = try XCTUnwrap(restored.terminalPanel(for: unfocusedPanelId)) + XCTAssertEqual(restored.currentDirectory, restoredDirectory.path) + XCTAssertEqual( + restoredFocusedPanel.requestedWorkingDirectory, + restoredDirectory.path, + "Expected the focused restored terminal to use the workspace snapshot cwd when no panel-specific cwd was persisted" + ) + XCTAssertEqual( + restoredUnfocusedPanel.requestedWorkingDirectory, + explicitDirectory.path, + "Expected non-focused terminals to keep their own persisted directory instead of inheriting the workspace cwd" + ) + } + func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index d69423788ccd..b9fe6f0c8eaf 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -23,6 +23,15 @@ func drainMainQueue() { XCTWaiter().wait(for: [expectation], timeout: 1.0) } +private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { + switch node { + case .pane: + return [] + case .split(let split): + return [split] + splitNodes(in: split.first) + splitNodes(in: split.second) + } +} + @MainActor final class TabManagerChildExitCloseTests: XCTestCase { func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() { @@ -736,14 +745,248 @@ final class TabManagerEqualizeSplitsTests: XCTestCase { XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.000_1) } } +} + +@MainActor +final class TabManagerResizeSplitsTests: XCTestCase { + func testResizeSplitMovesHorizontalDividerRightForFirstChildPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + "Expected to seed divider position" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: leftPanelId, direction: .right, amount: 120), + "Expected resizeSplit to succeed for the right edge of the left pane" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertGreaterThan( + updatedSplit.dividerPosition, + 0.5, + "Expected resizing the left pane to the right to move the divider toward the second child" + ) + } + + func testResizeSplitMovesHorizontalDividerLeftForSecondChildPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + "Expected to seed divider position" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: rightPanel.id, direction: .left, amount: 120), + "Expected resizeSplit to succeed for the left edge of the right pane" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertLessThan( + updatedSplit.dividerPosition, + 0.5, + "Expected resizing the right pane to the left to move the divider toward the first child" + ) + } - private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { - switch node { - case .pane: - return [] - case .split(let split): - return [split] + splitNodes(in: split.first) + splitNodes(in: split.second) + func testResizeSplitMovesVerticalDividerDownForFirstChildPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let topPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) != nil else { + XCTFail("Expected split setup to succeed") + return } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + "Expected to seed divider position" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: topPanelId, direction: .down, amount: 120), + "Expected resizeSplit to succeed for the bottom edge of the top pane" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertGreaterThan( + updatedSplit.dividerPosition, + 0.5, + "Expected resizing the top pane downward to move the divider toward the second child" + ) + } + + func testResizeSplitMovesVerticalDividerUpForSecondChildPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let topPanelId = workspace.focusedPanelId, + let bottomPanel = workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + "Expected to seed divider position" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: bottomPanel.id, direction: .up, amount: 120), + "Expected resizeSplit to succeed for the top edge of the bottom pane" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertLessThan( + updatedSplit.dividerPosition, + 0.5, + "Expected resizing the bottom pane upward to move the divider toward the first child" + ) + } + + func testResizeSplitReturnsFalseWhenPaneHasNoBorderInDirection() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertFalse( + manager.resizeSplit(tabId: workspace.id, surfaceId: leftPanelId, direction: .left, amount: 120), + "Expected resizeSplit to fail when the pane has no adjacent border in that direction" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + XCTAssertEqual(updatedSplit.dividerPosition, split.dividerPosition, accuracy: 0.000_1) + } + + func testResizeSplitClampsDividerPositionAtUpperBound() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.89, forSplit: splitId), + "Expected to seed divider position near upper bound" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: leftPanelId, direction: .right, amount: 10_000), + "Expected resizeSplit to clamp instead of failing" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertEqual(updatedSplit.dividerPosition, 0.9, accuracy: 0.000_1) + } + + func testResizeSplitClampsDividerPositionAtLowerBound() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let topPanelId = workspace.focusedPanelId, + let bottomPanel = workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.11, forSplit: splitId), + "Expected to seed divider position near lower bound" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: bottomPanel.id, direction: .up, amount: 10_000), + "Expected resizeSplit to clamp instead of failing" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertEqual(updatedSplit.dividerPosition, 0.1, accuracy: 0.000_1) } }