Skip to content
Closed
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
120 changes: 117 additions & 3 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 15 additions & 3 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ extension Workspace {
entry.paneId,
snapshot: entry.snapshot,
panelSnapshotsById: panelSnapshotsById,
workspaceFocusedPanelId: snapshot.focusedPanelId,
oldToNewPanelIds: &oldToNewPanelIds
)
}
Expand Down Expand Up @@ -492,6 +493,7 @@ extension Workspace {
_ paneId: PaneID,
snapshot: SessionPaneLayoutSnapshot,
panelSnapshotsById: [UUID: SessionPanelSnapshot],
workspaceFocusedPanelId: UUID?,
oldToNewPanelIds: inout [UUID: UUID]
) {
let existingPanelIds = bonsplitController
Expand All @@ -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
}
Expand Down Expand Up @@ -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
)
Expand Down
118 changes: 118 additions & 0 deletions cmuxTests/SessionPersistenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve restored panel ID before asserting unfocused cwd

restoreSessionSnapshot creates new panel UUIDs and tracks old→new IDs internally, so unfocusedPanelId from the snapshot is stale after restore. Looking up restored.terminalPanel(for: unfocusedPanelId) will almost always return nil, causing this regression test to fail before it validates the non-focused cwd behavior; the assertion should use a restored panel ID (for example via the restored layout/selection) instead of the pre-restore UUID.

Useful? React with 👍 / 👎.

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"
)
}
Comment on lines +87 to +160

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.

P1 Regression test likely passes without the fix (violates two-commit policy)

testWorkspaceSessionSnapshotRestoresFocusedTerminalWorkingDirectoryWhenPanelDirectoryIsMissing builds a snapshot where terminal.workingDirectory == nil and directory == nil, then asserts that restoredTerminal.requestedWorkingDirectory == restoredDirectory.path.

In restoreSessionSnapshot, currentDirectory is updated to snapshot.currentDirectory (restoredDirectory.path) before createPanel is called. The original pre-PR fallback chain was:

let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory

With both optional fields nil, this already resolves to currentDirectory — which is already restoredDirectory.path at that point. The assertion in the test would therefore pass against the unmodified code, meaning this test does not go red on the first commit as CLAUDE.md's regression-test policy requires.

Either the test is covering the wrong scenario (it should simulate the case where currentDirectory was not updated, or where snapshot.currentDirectory is empty), or the actual bug lies elsewhere and the fix should target that path. Consider revisiting the test setup so it reliably fails on the old code.


func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
Expand Down
Loading