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
143 changes: 134 additions & 9 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10107,6 +10107,93 @@ final class Workspace: Identifiable, ObservableObject {
return nil
}

// Single terminal split lifecycle entry point: every TerminalPanel that enters a
// newly assigned split pane must publish its model event and start the layout
// follow-up here so portal attachment, Ghostty geometry, and focus converge together.
private func beginTerminalSplitPaneLifecycle(
newPaneId: PaneID,
sourcePaneId: PaneID,
orientation: SplitOrientation,
panelId: UUID,
origin: String,
focused: Bool,
layoutReason: String,
terminalFocusPanelId: UUID?
) {
publishCmuxSplitCreated(
newPaneId,
sourcePaneId: sourcePaneId,
orientation: orientation,
surfaceId: panelId,
kind: "terminal",
origin: origin,
focused: focused
)
// Brand-new terminal split panes must enter the shared layout follow-up here so
// their portal attachment, Ghostty geometry, and optional focus converge together.
beginEventDrivenLayoutFollowUp(
reason: layoutReason,
terminalFocusPanelId: terminalFocusPanelId,
includeGeometry: true
)
}

private func beginTerminalSplitSurfaceLifecycle(
panelId: UUID,
paneId: PaneID,
origin: String,
focused: Bool,
layoutReason: String,
terminalFocusPanelId: UUID?
) {
publishCmuxSurfaceCreated(
panelId,
paneId: paneId,
kind: "terminal",
origin: origin,
focused: focused
)
beginEventDrivenLayoutFollowUp(
reason: layoutReason,
terminalFocusPanelId: terminalFocusPanelId,
includeGeometry: true
)
}

private func commitNewTerminalSplitPane(
sourcePaneId: PaneID,
orientation: SplitOrientation,
newTab: Bonsplit.Tab,
panelId: UUID,
insertFirst: Bool,
initialDividerPosition: CGFloat? = nil,
origin: String,
focused: Bool,
layoutReason: String,
terminalFocusPanelId: UUID?
) -> PaneID? {
guard let newPaneId = bonsplitController.splitPane(
sourcePaneId,
orientation: orientation,
withTab: newTab,
insertFirst: insertFirst
) else {
return nil
}
applyInitialSplitDividerPosition(initialDividerPosition, sourcePaneId: sourcePaneId, newPaneId: newPaneId)
beginTerminalSplitPaneLifecycle(
newPaneId: newPaneId,
sourcePaneId: sourcePaneId,
orientation: orientation,
panelId: panelId,
origin: origin,
focused: focused,
layoutReason: layoutReason,
terminalFocusPanelId: terminalFocusPanelId
)
return newPaneId
}

/// Create a new split with a terminal panel
@discardableResult
func newTerminalSplit(
Expand Down Expand Up @@ -10233,7 +10320,18 @@ final class Workspace: Identifiable, ObservableObject {
// Create the split with the new tab already present in the new pane.
isProgrammaticSplit = true
defer { isProgrammaticSplit = false }
guard let newPaneId = bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) else {
guard commitNewTerminalSplitPane(
sourcePaneId: paneId,
orientation: orientation,
newTab: newTab,
panelId: newPanel.id,
insertFirst: insertFirst,
initialDividerPosition: initialDividerPosition,
origin: "terminal_split",
focused: focus,
layoutReason: "terminal.split",
terminalFocusPanelId: focus ? newPanel.id : nil
) != nil else {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
surfaceIdToPanelId.removeValue(forKey: newTab.id)
Expand All @@ -10243,8 +10341,6 @@ final class Workspace: Identifiable, ObservableObject {
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return nil
}
applyInitialSplitDividerPosition(initialDividerPosition, sourcePaneId: paneId, newPaneId: newPaneId)
publishCmuxSplitCreated(newPaneId, sourcePaneId: paneId, orientation: orientation, surfaceId: newPanel.id, kind: "terminal", origin: "terminal_split", focused: focus)

#if DEBUG
cmuxDebugLog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)")
Expand Down Expand Up @@ -13283,7 +13379,17 @@ final class Workspace: Identifiable, ObservableObject {

isProgrammaticSplit = true
defer { isProgrammaticSplit = false }
guard let newPaneId = bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) else {
guard commitNewTerminalSplitPane(
sourcePaneId: paneId,
orientation: orientation,
newTab: newTab,
panelId: newPanel.id,
insertFirst: insertFirst,
origin: "terminal_split",
focused: true,
layoutReason: "terminal.splitPaneWithNewTerminal",
terminalFocusPanelId: newPanel.id
) != nil else {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
surfaceIdToPanelId.removeValue(forKey: newTab.id)
Expand All @@ -13293,7 +13399,6 @@ final class Workspace: Identifiable, ObservableObject {
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return nil
}
publishCmuxSplitCreated(newPaneId, sourcePaneId: paneId, orientation: orientation, surfaceId: newPanel.id, kind: "terminal", origin: "terminal_split", focused: true)

bonsplitController.selectTab(newTab.id)
newPanel.focus()
Expand Down Expand Up @@ -14406,7 +14511,14 @@ extension Workspace: BonsplitDelegate {
isLoading: false,
isPinned: false
)
publishCmuxSurfaceCreated(replacementPanel.id, paneId: originalPane, kind: "terminal", origin: "placeholder_repair", focused: false)
beginTerminalSplitSurfaceLifecycle(
panelId: replacementPanel.id,
paneId: originalPane,
origin: "placeholder_repair",
focused: false,
layoutReason: "terminal.placeholderRepair",
terminalFocusPanelId: nil
)

for extraPlaceholder in placeholderTabs.dropFirst() {
bonsplitController.closeTab(extraPlaceholder.id)
Expand All @@ -14418,7 +14530,12 @@ extension Workspace: BonsplitDelegate {
"fallback=createTerminalAndDropPlaceholders"
)
#endif
_ = newTerminalSurface(inPane: originalPane, focus: false)
if newTerminalSurface(inPane: originalPane, focus: false) != nil {
beginEventDrivenLayoutFollowUp(
reason: "terminal.placeholderRepairFallback",
includeGeometry: true
)
}
for tab in controller.tabs(inPane: originalPane) {
if panelIdFromSurfaceId(tab.id) == nil {
bonsplitController.closeTab(tab.id)
Expand Down Expand Up @@ -14477,7 +14594,16 @@ extension Workspace: BonsplitDelegate {

surfaceIdToPanelId[newTabId] = newPanel.id
normalizePinnedTabs(in: newPane)
publishCmuxSplitCreated(newPane, sourcePaneId: originalPane, orientation: orientation, surfaceId: newPanel.id, kind: "terminal", origin: "ui_split", focused: true)
beginTerminalSplitPaneLifecycle(
newPaneId: newPane,
sourcePaneId: originalPane,
orientation: orientation,
panelId: newPanel.id,
origin: "ui_split",
focused: true,
layoutReason: "terminal.uiSplit",
terminalFocusPanelId: newPanel.id
)
#if DEBUG
cmuxDebugLog(
"split.didSplit.autoCreate.done pane=\(newPane.id.uuidString.prefix(5)) " +
Expand All @@ -14492,7 +14618,6 @@ extension Workspace: BonsplitDelegate {
if self.bonsplitController.focusedPaneId == newPane {
self.bonsplitController.selectTab(newTabId)
}
self.scheduleTerminalGeometryReconcile()
self.scheduleFocusReconcile()
}
}
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */; };
C0DE31390000000000000105 /* CMUXCLIErrorOutputRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */; };
C0DE31390000000000000103 /* FilePreviewReviewFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000104 /* FilePreviewReviewFeedbackTests.swift */; };
C33860010000000000000001 /* TerminalControllerSurfaceSplitOffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33860010000000000000002 /* TerminalControllerSurfaceSplitOffTests.swift */; };
D3664002D3664002D3664002 /* MarkdownPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3664001D3664001D3664001 /* MarkdownPanelTests.swift */; };
C0DEF0A10000000000000001 /* CmuxConfigUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A10000000000000002 /* CmuxConfigUI.swift */; };
C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */; };
Expand Down Expand Up @@ -595,6 +596,7 @@
43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthEnvironment.swift; sourceTree = "<group>"; };
C3677001000000000000002 /* CmuxSSHURLRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxSSHURLRequestTests.swift; sourceTree = "<group>"; };
491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = "<group>"; };
C33860010000000000000002 /* TerminalControllerSurfaceSplitOffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSurfaceSplitOffTests.swift; sourceTree = "<group>"; };
51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarIdentifierFormattingTests.swift; sourceTree = "<group>"; };
D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceDropPlannerTests.swift; sourceTree = "<group>"; };
D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewVisibilityPolicyTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1601,6 +1603,7 @@
D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */,
C3677001000000000000002 /* CmuxSSHURLRequestTests.swift */,
491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */,
C33860010000000000000002 /* TerminalControllerSurfaceSplitOffTests.swift */,
9C1BEA3D2E6F49709A71C021 /* TerminalControllerSocketWriteTests.swift */,
E7E000000000000000000004 /* CmuxEventBusTests.swift */,
A5A5A504A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift */,
Expand Down Expand Up @@ -2376,6 +2379,7 @@
D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */,
C3677001000000000000001 /* CmuxSSHURLRequestTests.swift in Sources */,
8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */,
C33860010000000000000001 /* TerminalControllerSurfaceSplitOffTests.swift in Sources */,
9C1BEA3D2E6F49709A71C020 /* TerminalControllerSocketWriteTests.swift in Sources */,
E7E000000000000000000003 /* CmuxEventBusTests.swift in Sources */,
A5A5A503A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift in Sources */,
Expand Down
89 changes: 89 additions & 0 deletions cmuxTests/TerminalControllerSurfaceSplitOffTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import XCTest
import AppKit

#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif

@MainActor
final class TerminalControllerSurfaceSplitOffTests: XCTestCase {
func testSurfaceSplitOffRejectsOnlyTabSourcePane() throws {
_ = NSApplication.shared
let previousAppDelegate = AppDelegate.shared
let app = AppDelegate()
defer { AppDelegate.shared = previousAppDelegate }

let windowId = UUID()
let window = makeMainWindow(id: windowId)
let manager = TabManager()
app.registerMainWindow(
window,
windowId: windowId,
tabManager: manager,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState(),
fileExplorerState: FileExplorerState()
)
TerminalController.shared.setActiveTabManager(manager)
defer {
TerminalController.shared.setActiveTabManager(nil)
app.unregisterMainWindowContextForTesting(windowId: windowId)
window.orderOut(nil)
}

let workspace = try XCTUnwrap(manager.selectedWorkspace)
let terminalPanel = try XCTUnwrap(workspace.focusedTerminalPanel)
let sourcePane = try XCTUnwrap(workspace.paneId(forPanelId: terminalPanel.id))
XCTAssertEqual(workspace.bonsplitController.tabs(inPane: sourcePane).count, 1)
XCTAssertEqual(workspace.bonsplitController.allPaneIds.count, 1)

let envelope = try v2Envelope(
method: "surface.split_off",
params: [
"surface_id": terminalPanel.id.uuidString,
"direction": "right",
"focus": false
]
)

XCTAssertEqual(envelope["ok"] as? Bool, false)
let error = try XCTUnwrap(envelope["error"] as? [String: Any])
XCTAssertEqual(error["code"] as? String, "invalid_state")
let data = try XCTUnwrap(error["data"] as? [String: Any])
XCTAssertEqual(data["surface_id"] as? String, terminalPanel.id.uuidString)
XCTAssertEqual(data["pane_id"] as? String, sourcePane.id.uuidString)
XCTAssertEqual(workspace.bonsplitController.tabs(inPane: sourcePane).count, 1)
XCTAssertEqual(workspace.bonsplitController.allPaneIds.count, 1)
}

private func makeMainWindow(id: UUID) -> NSWindow {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)")
return window
}

private func v2Envelope(
method: String,
params: [String: Any],
file: StaticString = #filePath,
line: UInt = #line
) throws -> [String: Any] {
let request: [String: Any] = [
"id": method,
"method": method,
"params": params
]
let requestData = try JSONSerialization.data(withJSONObject: request)
let requestLine = try XCTUnwrap(String(data: requestData, encoding: .utf8), file: file, line: line)
let response = TerminalController.shared.handleSocketLine(requestLine)
let responseData = try XCTUnwrap(response.data(using: .utf8), file: file, line: line)
return try XCTUnwrap(JSONSerialization.jsonObject(with: responseData) as? [String: Any], file: file, line: line)
}
}
Loading
Loading