From 4bb212510e75ca06ec6f61a79e4871755115ecc6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 13 Mar 2026 17:02:48 -0700 Subject: [PATCH] fix(cmux): preserve split cwd while shell cwd is stale --- Sources/GhosttyTerminalView.swift | 22 ++++++ Sources/Panels/TerminalPanel.swift | 4 + Sources/Workspace.swift | 24 ++++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 48 ++++++++++++ tests/test_split_cwd_inheritance.py | 74 +++++++++++-------- 5 files changed, 136 insertions(+), 36 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b420a07e..dd6ff94e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2515,6 +2515,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private let surfaceContext: ghostty_surface_context_e private let configTemplate: ghostty_surface_config_s? private let workingDirectory: String? + var requestedWorkingDirectory: String? { workingDirectory } private let additionalEnvironment: [String: String] let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView @@ -3030,6 +3031,27 @@ final class TerminalSurface: Identifiable, ObservableObject { } env["ZDOTDIR"] = integrationDir + } else if shellName == "bash" { + if GhosttyApp.shared.shellIntegrationMode() != "none" { + env["CMUX_LOAD_GHOSTTY_BASH_INTEGRATION"] = "1" + } + // macOS ships /bin/bash 3.2, where Ghostty's automatic bash + // integration is unsupported and HOME-based wrapper startup is + // not reliable. Bootstrap cmux bash integration on the first + // interactive prompt instead. + env["PROMPT_COMMAND"] = """ + unset PROMPT_COMMAND; \ + if [[ "${CMUX_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then \ + _cmux_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"; \ + [[ -r "$_cmux_ghostty_bash" ]] && source "$_cmux_ghostty_bash"; \ + fi; \ + if [[ "${CMUX_SHELL_INTEGRATION:-1}" != "0" && -n "${CMUX_SHELL_INTEGRATION_DIR:-}" ]]; then \ + _cmux_bash_integration="$CMUX_SHELL_INTEGRATION_DIR/cmux-bash-integration.bash"; \ + [[ -r "$_cmux_bash_integration" ]] && source "$_cmux_bash_integration"; \ + fi; \ + unset _cmux_ghostty_bash _cmux_bash_integration; \ + if declare -F _cmux_prompt_command >/dev/null 2>&1; then _cmux_prompt_command; fi + """ } } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 7a9cd103..9e02e2d5 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -63,6 +63,10 @@ final class TerminalPanel: Panel, ObservableObject { surface.hostedView } + var requestedWorkingDirectory: String? { + surface.requestedWorkingDirectory + } + init(workspaceId: UUID, surface: TerminalSurface) { self.id = surface.id self.workspaceId = workspaceId diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index aedb0657..007bc227 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2074,12 +2074,26 @@ final class Workspace: Identifiable, ObservableObject { let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) // Inherit working directory: prefer the source panel's reported cwd, - // fall back to the workspace's current directory. - let splitWorkingDirectory: String? = panelDirectories[panelId] - ?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? nil : currentDirectory) + // then its requested startup cwd if shell integration has not reported + // back yet, and finally fall back to the workspace's current directory. + let splitWorkingDirectory: String? = { + if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), + !panelDirectory.isEmpty { + return panelDirectory + } + if let requestedWorkingDirectory = terminalPanel(for: panelId)? + .requestedWorkingDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines), + !requestedWorkingDirectory.isEmpty { + return requestedWorkingDirectory + } + let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + return workspaceDirectory.isEmpty ? nil : workspaceDirectory + }() #if DEBUG - dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")") + dlog( + "split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")" + ) #endif // Create the new terminal panel. diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index c01ae047..058467b7 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5229,6 +5229,54 @@ final class WorkspaceTeardownTests: XCTestCase { } } +@MainActor +final class WorkspaceSplitWorkingDirectoryTests: XCTestCase { + func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() { + let workspace = Workspace() + guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else { + XCTFail("Expected focused pane in new workspace") + return + } + + let staleCurrentDirectory = workspace.currentDirectory + let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)" + guard let sourcePanel = workspace.newTerminalSurface( + inPane: sourcePaneId, + focus: false, + workingDirectory: requestedDirectory + ) else { + XCTFail("Expected source terminal panel to be created") + return + } + + XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory) + XCTAssertNil( + workspace.panelDirectories[sourcePanel.id], + "Expected requested cwd to exist before shell integration reports a live cwd" + ) + XCTAssertEqual( + workspace.currentDirectory, + staleCurrentDirectory, + "Expected focused workspace cwd to remain stale before panel directory updates" + ) + + guard let splitPanel = workspace.newTerminalSplit( + from: sourcePanel.id, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected split terminal panel to be created") + return + } + + XCTAssertEqual( + splitPanel.requestedWorkingDirectory, + requestedDirectory, + "Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet" + ) + } +} + @MainActor final class TabManagerWorkspaceOwnershipTests: XCTestCase { func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() { diff --git a/tests/test_split_cwd_inheritance.py b/tests/test_split_cwd_inheritance.py index 6677ee8e..80a5733b 100644 --- a/tests/test_split_cwd_inheritance.py +++ b/tests/test_split_cwd_inheritance.py @@ -59,24 +59,29 @@ def _wait_for_focused_cwd( client: cmux, expected: str, timeout: float = 12.0, - exclude_panel: str | None = None, + panel: str | None = None, + tab: str | None = None, ) -> dict[str, str]: """Wait for focused_cwd to match expected. - If exclude_panel is given, also require that focused_panel differs from - that value — ensuring we're checking the *new* pane, not the original. + If panel is given, also require that focused_panel matches that panel. + If tab is given, also require that the selected tab matches that tab. """ def pred(): state = _parse_sidebar_state(client.sidebar_state()) cwd = state.get("focused_cwd", "") if cwd != expected: return None - if exclude_panel and state.get("focused_panel", "") == exclude_panel: + if panel and state.get("focused_panel", "") != panel: + return None + if tab and state.get("tab", "") != tab: return None return state label = f"focused_cwd={expected!r}" - if exclude_panel: - label += f" (panel != {exclude_panel})" + if panel: + label += f" (panel == {panel})" + if tab: + label += f" (tab == {tab})" return _wait_for(pred, timeout=timeout, interval=0.3, label=label) @@ -84,12 +89,25 @@ def _send_cd_and_wait( client: cmux, target: str, timeout: float = 12.0, + surface: str | int | None = None, ) -> dict[str, str]: """cd to target and wait for sidebar focused_cwd to reflect it.""" - client.send(f"cd {target}\n") + if surface is None: + client.send(f"cd {target}\n") + else: + client.send_surface(surface, f"cd {target}\n") return _wait_for_focused_cwd(client, target, timeout=timeout) +def _focus_first_surface(client: cmux) -> str: + surfaces = client.list_surfaces() + if not surfaces: + raise AssertionError("Current tab has no surfaces") + surface_id = surfaces[0][1] + client.focus_surface(surface_id) + return surface_id + + def main() -> int: tag = os.environ.get("CMUX_TAG", "") @@ -119,17 +137,22 @@ def main() -> int: print("=== Split CWD Inheritance Tests ===") + print(" [setup] creating isolated workspace tab...") + setup_tab = client.new_tab() + client.select_tab(setup_tab) + time.sleep(1.0) + setup_surface = _focus_first_surface(client) + time.sleep(0.5) + # --- Setup: cd to test_dir_a in workspace 1 --- print(" [setup] cd to test_dir_a and wait for shell integration...") - _send_cd_and_wait(client, test_dir_a) + _send_cd_and_wait(client, test_dir_a, surface=setup_surface) state = _parse_sidebar_state(client.sidebar_state()) check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a, f"got {state.get('focused_cwd')!r}") # --- Test 1: New split inherits test_dir_a --- print(" [test1] creating right split from test_dir_a...") - # Record the original panel so we can verify focus moves to the NEW pane. - original_panel = state.get("focused_panel", "") split_result = client.new_split("right") if not split_result: check("split created", False) @@ -138,15 +161,15 @@ def main() -> int: return 1 check("split created", True) - # Wait for the NEW pane (different panel ID) to report test_dir_a. + # Socket split commands should not steal focus; focus the returned pane + # explicitly, then assert that pane inherited the source cwd. + new_panel = split_result.strip() + client.focus_surface_by_panel(new_panel) time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND try: state = _wait_for_focused_cwd( - client, test_dir_a, timeout=15.0, exclude_panel=original_panel, + client, test_dir_a, timeout=15.0, panel=new_panel, ) - new_panel = state.get("focused_panel", "") - check("test1: focus moved to new pane", new_panel != original_panel, - f"original={original_panel!r}, current={new_panel!r}") check("test1: split inherited test_dir_a", state.get("focused_cwd") == test_dir_a, f"focused_cwd={state.get('focused_cwd')!r}") @@ -159,8 +182,6 @@ def main() -> int: # First cd to test_dir_b so we have a different dir to inherit print(" [test2] cd to test_dir_b, then creating new workspace tab...") _send_cd_and_wait(client, test_dir_b) - state = _parse_sidebar_state(client.sidebar_state()) - original_tab = state.get("tab", "") tab_result = client.new_tab() if not tab_result: @@ -170,23 +191,14 @@ def main() -> int: return 1 check("new tab created", True) - # New workspace should be a different tab AND inherit test_dir_b + # Focus the returned workspace explicitly, then assert it inherited cwd. + new_tab = tab_result.strip() + client.select_tab(new_tab) time.sleep(4) try: - def _new_tab_with_cwd(): - s = _parse_sidebar_state(client.sidebar_state()) - tab_id = s.get("tab", "") - cwd = s.get("focused_cwd", "") - if tab_id != original_tab and cwd == test_dir_b: - return s - return None - - state = _wait_for( - _new_tab_with_cwd, timeout=15.0, interval=0.3, - label=f"new tab with focused_cwd={test_dir_b!r}", + state = _wait_for_focused_cwd( + client, test_dir_b, timeout=15.0, tab=new_tab, ) - check("test2: focus moved to new tab", state.get("tab") != original_tab, - f"original={original_tab!r}, current={state.get('tab')!r}") check("test2: new workspace inherited test_dir_b", state.get("focused_cwd") == test_dir_b, f"focused_cwd={state.get('focused_cwd')!r}")