Skip to content
Merged
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
22 changes: 22 additions & 0 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
"""
}
}

Expand Down
4 changes: 4 additions & 0 deletions Sources/Panels/TerminalPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1587,6 +1587,7 @@ class TabManager: ObservableObject {
if let confirmCloseHandler {
return confirmCloseHandler(title, message, acceptCmdD)
}
_ = acceptCmdD

let alert = NSAlert()
alert.messageText = title
Expand All @@ -1596,16 +1597,17 @@ class TabManager: ObservableObject {
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))

if let closeButton = alert.buttons.first {
// Keep Return/Enter bound to the primary destructive action for all close prompts.
closeButton.keyEquivalent = "\r"
closeButton.keyEquivalentModifierMask = []
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
alert.window.initialFirstResponder = closeButton
}
if let cancelButton = alert.buttons.dropFirst().first {
cancelButton.keyEquivalent = "\u{1b}"
}

// macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save").
// We only opt into this for the "close last workspace => close window" path to avoid
// conflicting with app-level Cmd+D (split right) during normal usage.
if acceptCmdD, let closeButton = alert.buttons.first {
closeButton.keyEquivalent = "d"
closeButton.keyEquivalentModifierMask = [.command]
if NSApp.activationPolicy() == .regular {
NSApp.activate(ignoringOtherApps: true)
}

return alert.runModal() == .alertFirstButtonReturn
Expand Down
30 changes: 25 additions & 5 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -4148,7 +4162,13 @@ extension Workspace: BonsplitDelegate {
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))

if let closeButton = alert.buttons.first {
closeButton.keyEquivalent = "\r"
closeButton.keyEquivalentModifierMask = []
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
alert.window.initialFirstResponder = closeButton
}
if let cancelButton = alert.buttons.dropFirst().first {
cancelButton.keyEquivalent = "\u{1b}"
}

// Prefer a sheet if we can find a window, otherwise fall back to modal.
Expand Down
48 changes: 48 additions & 0 deletions cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
74 changes: 43 additions & 31 deletions tests/test_split_cwd_inheritance.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,37 +59,55 @@ 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)


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", "")

Expand Down Expand Up @@ -119,17 +137,22 @@ def check(name: str, condition: bool, detail: str = ""):

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)
Expand All @@ -138,15 +161,15 @@ def check(name: str, condition: bool, detail: str = ""):
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}")
Expand All @@ -159,8 +182,6 @@ def check(name: str, condition: bool, detail: str = ""):
# 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:
Expand All @@ -170,23 +191,14 @@ def check(name: str, condition: bool, detail: str = ""):
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}")
Expand Down
Loading