-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Fix Cmd+N workspace creation snapshot crashes #2173
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2178,34 +2178,73 @@ class TabManager: ObservableObject { | |
| private func workspaceCreationSnapshot() -> WorkspaceCreationSnapshot { | ||
| let currentTabs = tabs | ||
| let currentSelectedTabId = selectedTabId | ||
| let tabSnapshots = currentTabs.map { WorkspaceCreationTabSnapshot(workspace: $0) } | ||
| let selectedTabSnapshot = currentSelectedTabId.flatMap { selectedTabId in | ||
| tabSnapshots.first(where: { $0.id == selectedTabId }) | ||
| } | ||
| let selectedWorkspace = currentSelectedTabId.flatMap { selectedTabId in | ||
| currentTabs.first(where: { $0.id == selectedTabId }) | ||
| } | ||
|
|
||
| return WorkspaceCreationSnapshot( | ||
| tabs: currentTabs.map { WorkspaceCreationTabSnapshot(workspace: $0) }, | ||
| tabs: tabSnapshots, | ||
| selectedTabId: currentSelectedTabId, | ||
| selectedTabWasPinned: selectedWorkspace?.isPinned ?? false, | ||
| selectedTabWasPinned: selectedTabSnapshot?.isPinned ?? false, | ||
| preferredWorkingDirectory: preferredWorkingDirectoryForNewTab(workspace: selectedWorkspace), | ||
| inheritedTerminalConfig: inheritedTerminalConfigForNewWorkspace(workspace: selectedWorkspace) | ||
| ) | ||
| } | ||
|
|
||
| private func orderedLiveWorkspaceCreationTabs( | ||
| from snapshot: WorkspaceCreationSnapshot | ||
| ) -> [WorkspaceCreationTabSnapshot]? { | ||
| let currentTabs = tabs | ||
| let snapshotTabsById = Dictionary(uniqueKeysWithValues: snapshot.tabs.map { ($0.id, $0) }) | ||
| var orderedTabs: [WorkspaceCreationTabSnapshot] = [] | ||
| orderedTabs.reserveCapacity(currentTabs.count) | ||
|
|
||
| for workspace in currentTabs { | ||
| guard let tabSnapshot = snapshotTabsById[workspace.id] else { | ||
| #if DEBUG | ||
| dlog( | ||
| "workspace.create.reentrantSnapshotFallback " + | ||
| "snapshotCount=\(snapshot.tabs.count) liveCount=\(currentTabs.count)" | ||
| ) | ||
| #endif | ||
| return nil | ||
| } | ||
| orderedTabs.append(tabSnapshot) | ||
| } | ||
|
|
||
| return orderedTabs | ||
| } | ||
|
|
||
| private func terminalPanelForWorkspaceConfigInheritanceSource( | ||
| workspace: Workspace? | ||
| ) -> TerminalPanel? { | ||
| guard let workspace else { return nil } | ||
| if let focusedTerminal = workspace.focusedTerminalPanel { | ||
| return focusedTerminal | ||
| // Prefer cached/published panel state here instead of walking live Bonsplit focus | ||
| // during Cmd+N; rapid workspace creation can observe transient pane/tab selection. | ||
| let panels = workspace.panels | ||
| var candidates: [TerminalPanel] = [] | ||
| var seen: Set<UUID> = [] | ||
|
|
||
| func appendCandidate(_ panel: TerminalPanel?) { | ||
| guard let panel, seen.insert(panel.id).inserted else { return } | ||
| candidates.append(panel) | ||
| } | ||
| if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() { | ||
| return rememberedTerminal | ||
|
|
||
| appendCandidate(workspace.lastRememberedTerminalPanelForConfigInheritance()) | ||
| for terminalPanel in panels.values | ||
| .compactMap({ $0 as? TerminalPanel }) | ||
| .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { | ||
| appendCandidate(terminalPanel) | ||
| } | ||
| if let focusedPaneId = workspace.bonsplitController.focusedPaneId, | ||
| let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) { | ||
| return paneTerminal | ||
|
|
||
| if let livePanel = candidates.first(where: { $0.surface.hasLiveSurface && $0.surface.surface != nil }) { | ||
| return livePanel | ||
| } | ||
| return workspace.terminalPanelForConfigInheritance() | ||
| return candidates.first | ||
| } | ||
|
|
||
| private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? { | ||
|
|
@@ -2247,7 +2286,7 @@ class TabManager: ObservableObject { | |
| placementOverride: NewWorkspacePlacement? = nil | ||
| ) -> Int { | ||
| let placement = placementOverride ?? WorkspacePlacementSettings.current() | ||
| let liveTabs = tabs.map { WorkspaceCreationTabSnapshot(workspace: $0) } | ||
| let liveTabs = orderedLiveWorkspaceCreationTabs(from: snapshot) ?? snapshot.tabs | ||
| let pinnedCount = liveTabs.reduce(into: 0) { partial, tab in | ||
| if tab.isPinned { | ||
| partial += 1 | ||
|
|
@@ -2284,12 +2323,15 @@ class TabManager: ObservableObject { | |
| guard let workspace else { | ||
| return nil | ||
| } | ||
| let focusedDirectory = workspace.focusedPanelId | ||
| .flatMap { workspace.panelDirectories[$0] } | ||
| let candidate = focusedDirectory ?? workspace.currentDirectory | ||
| let normalized = normalizeDirectory(candidate) | ||
| let trimmed = normalized.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| return trimmed.isEmpty ? nil : normalized | ||
| // Use cached directory state only; avoiding live focus traversal keeps workspace | ||
| // creation resilient when Bonsplit is in the middle of a rapid Cmd+N churn. | ||
| if let currentDirectory = normalizedWorkingDirectory(workspace.currentDirectory) { | ||
| return currentDirectory | ||
| } | ||
|
|
||
| return workspace.panelDirectories.values.lazy.compactMap { directory in | ||
| self.normalizedWorkingDirectory(directory) | ||
| }.first | ||
|
Comment on lines
+2332
to
+2334
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The original code used |
||
| } | ||
|
|
||
| func moveTabToTop(_ tabId: UUID) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -448,6 +448,58 @@ final class WorkspaceCreationPlacementTests: XCTestCase { | |
| XCTAssertEqual(manager.selectedTabId, inserted.id) | ||
| } | ||
|
|
||
| func testAddWorkspaceAfterCurrentUsesSnapshotPinnedStateWhenPinningMutatesAfterSnapshot() { | ||
| let manager = SnapshotMutatingTabManager() | ||
| guard let first = manager.tabs.first else { | ||
| XCTFail("Expected initial workspace") | ||
| return | ||
| } | ||
|
|
||
| manager.setPinned(first, pinned: true) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This regression test setup is non-discriminating: unpinning the selected pinned workspace yields the same insertion index for both snapshot and live pin-state logic, so the intended bug can regress without failing this test. Prompt for AI agents |
||
| let second = manager.addWorkspace() | ||
| let third = manager.addWorkspace() | ||
| manager.selectWorkspace(first) | ||
| let baselineOrder = manager.tabs.map(\.id) | ||
|
|
||
| manager.afterCaptureWorkspaceCreationSnapshot = { | ||
| manager.setPinned(first, pinned: false) | ||
| } | ||
|
|
||
| let inserted = manager.addWorkspace(placementOverride: .afterCurrent) | ||
|
|
||
| XCTAssertEqual(manager.tabs.map(\.id).filter { $0 != inserted.id }, baselineOrder) | ||
| XCTAssertEqual(manager.tabs.map(\.id), [first.id, inserted.id, second.id, third.id]) | ||
| XCTAssertEqual(manager.selectedTabId, inserted.id) | ||
| } | ||
|
|
||
| func testAddWorkspaceAfterCurrentFollowsLiveReorderUsingSnapshotTabValues() { | ||
| let manager = SnapshotMutatingTabManager() | ||
| guard let first = manager.tabs.first else { | ||
| XCTFail("Expected initial workspace") | ||
| return | ||
| } | ||
|
|
||
| let second = manager.addWorkspace() | ||
| let third = manager.addWorkspace() | ||
| manager.selectWorkspace(second) | ||
|
|
||
| manager.afterCaptureWorkspaceCreationSnapshot = { | ||
| XCTAssertTrue( | ||
| manager.reorderWorkspace(tabId: third.id, toIndex: 0), | ||
| "Expected to reorder live workspaces after the snapshot is captured" | ||
| ) | ||
| } | ||
|
|
||
| let inserted = manager.addWorkspace(placementOverride: .afterCurrent) | ||
|
|
||
| XCTAssertEqual( | ||
| manager.tabs.map(\.id).filter { $0 != inserted.id }, | ||
| [third.id, first.id, second.id] | ||
| ) | ||
| XCTAssertEqual(manager.tabs.map(\.id), [third.id, first.id, second.id, inserted.id]) | ||
| XCTAssertEqual(manager.selectedTabId, inserted.id) | ||
| } | ||
|
|
||
| private func makeManagerWithThreeWorkspaces() -> TabManager { | ||
| let manager = TabManager() | ||
| _ = manager.addWorkspace() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorting
panels.valuesbyid.uuidStringproduces a stable, deterministic order across a single run, but because UUIDs are generated randomly the "first" panel in UUID-string order has no semantic relationship to which pane the user was working in. In a workspace with multiple splits, this means config inheritance (font size, environment, etc.) could be drawn from an arbitrary pane rather than the most-recently-used one.The original code addressed this through a layered semantic priority; the new code collapses all non-remembered panels into a single UUID-sorted bucket. It might be worth preserving at least a coarser semantic ordering — e.g., prefer remembered panel, then any panel with a live surface sorted by most-recently-created (if a creation timestamp is available), before falling back to UUID-order — so the regression under rapid Cmd+N is limited to the crash-avoidance path rather than becoming the normal path for all multi-panel workspaces.
If the UUID sort is intentionally kept as a simple, safe fallback and the remembered-panel path is expected to cover the common case, a short comment to that effect here would help future readers.