diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift index 1969efa875ba..4e958f28ed2c 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift @@ -49,8 +49,8 @@ extension ControlCommandCoordinator { return surfaceSendText(request.params) case "surface.send_key": return surfaceSendKey(request.params) - case "surface.report_tty": - return surfaceReportTTY(request.params) + case "surface.report_tty": return surfaceReportTTY(request.params) + case "surface.report_pwd": return surfaceReportPWD(request.params) case "surface.report_shell_state": return surfaceReportShellState(request.params) case "surface.ports_kick": diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift index a711ff923eb5..30f89a9ad5ae 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift @@ -1,8 +1,8 @@ internal import Foundation /// The surface-domain resume (`surface.resume.*`) and reporting -/// (`surface.report_tty` / `report_shell_state` / `ports_kick`) bodies, plus the -/// shared resume-binding payload helper, split out of +/// (`surface.report_tty` / `report_pwd` / `report_shell_state` / `ports_kick`) +/// bodies, plus the shared resume-binding payload helper, split out of /// `ControlCommandCoordinator+Surface.swift` to keep each file under the 500-line /// budget. See that file's doc comment for the domain overview. extension ControlCommandCoordinator { @@ -209,6 +209,58 @@ extension ControlCommandCoordinator { } } + // MARK: - report_pwd + + /// `surface.report_pwd` — record a surface's current working directory. + func surfaceReportPWD(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let requestedSurfaceID = uuid(params, "surface_id") + if hasNonNull(params, "surface_id"), requestedSurfaceID == nil { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + // Accept compatibility aliases, but require one exact cwd value. + let candidatePaths = ["path", "directory", "cwd"] + .compactMap { rawString(params, $0) } + .filter { $0.rangeOfCharacter(from: .whitespacesAndNewlines.inverted) != nil } + guard let path = candidatePaths.first else { + return .err(code: "invalid_params", message: "Missing path", data: nil) + } + guard candidatePaths.allSatisfy({ $0 == path }) else { + return .err(code: "invalid_params", message: "Conflicting path parameters", data: nil) + } + + let resolution = context?.controlSurfaceReportPWD( + workspaceID: workspaceID, + requestedSurfaceID: requestedSurfaceID, + path: path + ) ?? .workspaceNotFound + let requestedSurfaceData = surfaceReportSurfaceFields( + workspaceID: workspaceID, + requestedSurfaceID: requestedSurfaceID + ) + switch resolution { + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: .object(requestedSurfaceData)) + case .surfaceNotFound: + return .err(code: "not_found", message: "Surface not found", data: .object(requestedSurfaceData)) + case .pending: + var payload = requestedSurfaceData + payload["path"] = .string(path) + payload["pending"] = .bool(true) + return .ok(.object(payload)) + case .recorded(let surfaceID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "path": .string(path), + ])) + } + } + // MARK: - report_shell_state /// `surface.report_shell_state` — record reported shell-activity state. diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift index 1d60cbd0ec7b..41ef6ca3a7d7 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift @@ -282,7 +282,7 @@ public protocol ControlSurfaceContext: AnyObject { expectedSource: String? ) -> ControlSurfaceResumeResolution - // MARK: - report_tty / report_shell_state / ports_kick + // MARK: - report_tty / report_pwd / report_shell_state / ports_kick /// Records a reported TTY name for `surface.report_tty`. /// @@ -297,6 +297,19 @@ public protocol ControlSurfaceContext: AnyObject { ttyName: String ) -> ControlSurfaceReportTTYResolution + /// Records a reported current working directory for `surface.report_pwd`. + /// + /// - Parameters: + /// - workspaceID: The target workspace. + /// - requestedSurfaceID: The explicit `surface_id`, or `nil` to resolve. + /// - path: The reported (trimmed, non-empty) current working directory. + /// - Returns: The report resolution. + func controlSurfaceReportPWD( + workspaceID: UUID, + requestedSurfaceID: UUID?, + path: String + ) -> ControlSurfaceReportPWDResolution + /// Parses a raw shell-activity token via the app's /// `parseReportedShellActivityState`, returning the state's raw value (the /// coordinator rejects a `nil` result as `invalid_params`). diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReportPWDResolution.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReportPWDResolution.swift new file mode 100644 index 000000000000..1ade331fd7ff --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReportPWDResolution.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// The outcome of `surface.report_pwd`, preserving the report commands' +/// distinct failures and recorded identity. +/// +/// The coordinator validates the params (workspace required, surface-if-present +/// must parse, path required) and mints refs; every case echoes the workspace id +/// plus the requested-or-resolved surface id and the path. The app records the +/// current working directory against the resolved surface and returns this. +public enum ControlSurfaceReportPWDResolution: Sendable, Equatable { + /// The workspace did not resolve (`not_found` / "Workspace not found", + /// `data` echoes the workspace + requested surface). + case workspaceNotFound + /// The surface did not resolve (`not_found` / "Surface not found", `data` + /// echoes the workspace + requested surface). + case surfaceNotFound + /// The workspace is a remote workspace with no surfaces yet, so the path was + /// remembered (`ok` with `pending: true`, echoing the requested surface). + case pending + /// The path was recorded. Carries the resolved surface id (the `.ok` payload + /// echoes the resolved surface, not the requested one). + case recorded(surfaceID: UUID) +} diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index 624f90687059..6230513a5102 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -462,11 +462,10 @@ extension ControlSurfaceContext { func controlSurfaceParseShellActivityState(_ rawState: String) -> String? { nil } func controlSurfaceParsePortScanKickReason(_ rawReason: String) -> String? { nil } - func controlSurfaceReportTTY( - workspaceID: UUID, - requestedSurfaceID: UUID?, - ttyName: String - ) -> ControlSurfaceReportTTYResolution { .workspaceNotFound } + func controlSurfaceReportTTY(workspaceID: UUID, requestedSurfaceID: UUID?, ttyName: String) + -> ControlSurfaceReportTTYResolution { .workspaceNotFound } + func controlSurfaceReportPWD(workspaceID: UUID, requestedSurfaceID: UUID?, path: String) + -> ControlSurfaceReportPWDResolution { .workspaceNotFound } func controlSurfaceReportShellState( workspaceID: UUID, diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift new file mode 100644 index 000000000000..a9bfd9f5544a --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import CmuxControlSocket + +@MainActor +@Suite("ControlCommandCoordinator surface domain") +struct ControlCommandCoordinatorSurfaceTests { + private func makeCoordinator() -> (ControlCommandCoordinator, FakeSurfaceControlCommandContext) { + let context = FakeSurfaceControlCommandContext() + return (ControlCommandCoordinator(context: context), context) + } + + private func request(_ params: [String: JSONValue]) -> ControlRequest { + ControlRequest(id: .int(1), method: "surface.report_pwd", params: params) + } + + @Test func reportPWDRejectsConflictingPathAliases() { + let (coordinator, context) = makeCoordinator() + let workspaceID = UUID() + let result = coordinator.handle(request([ + "workspace_id": .string(workspaceID.uuidString), + "path": .string("/srv/work/bar"), + "cwd": .string("/srv/work/other"), + ])) + + #expect(result == .err(code: "invalid_params", message: "Conflicting path parameters", data: nil)) + #expect(context.reportedPWD?.path == nil) + } + + @Test func reportPWDPreservesExactPathWhitespace() { + let (coordinator, context) = makeCoordinator() + let workspaceID = UUID() + _ = coordinator.handle(request([ + "workspace_id": .string(workspaceID.uuidString), + "path": .string("/srv/work/bar "), + ])) + + #expect(context.reportedPWD?.path == "/srv/work/bar ") + } +} diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSurfaceControlCommandContext.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSurfaceControlCommandContext.swift new file mode 100644 index 000000000000..545726bfa574 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSurfaceControlCommandContext.swift @@ -0,0 +1,17 @@ +import Foundation +@testable import CmuxControlSocket + +@MainActor +final class FakeSurfaceControlCommandContext: ControlCommandContext { + var reportPWDResolution: ControlSurfaceReportPWDResolution = .recorded(surfaceID: UUID()) + var reportedPWD: (workspaceID: UUID, requestedSurfaceID: UUID?, path: String)? + + func controlSurfaceReportPWD( + workspaceID: UUID, + requestedSurfaceID: UUID?, + path: String + ) -> ControlSurfaceReportPWDResolution { + reportedPWD = (workspaceID, requestedSurfaceID, path) + return reportPWDResolution + } +} diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 65b39f7a91eb..938a488cf06d 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -149,6 +149,23 @@ _cmux_report_tty_via_relay() { _cmux_relay_rpc "surface.report_tty" "$params" } +_cmux_report_pwd_via_relay() { + local pwd="$1" + _cmux_socket_uses_remote_relay || return 1 + [[ -n "$pwd" ]] || return 1 + local workspace_id="" + workspace_id="$(_cmux_relay_workspace_id)" || return 1 + + local pwd_json params + pwd_json="$(_cmux_json_escape "$pwd")" + params="{\"workspace_id\":\"$workspace_id\",\"path\":\"$pwd_json\"" + if [[ -n "$CMUX_PANEL_ID" ]]; then + params+=",\"surface_id\":\"$CMUX_PANEL_ID\"" + fi + params+="}" + _cmux_relay_rpc_bg "surface.report_pwd" "$params" +} + _cmux_ports_kick_via_relay() { local reason="${1:-command}" _cmux_socket_uses_remote_relay || return 1 @@ -1502,7 +1519,11 @@ _cmux_prompt_command() { local now now="$(_cmux_now)" + local pwd="$PWD" if (( ! cmux_has_unix_socket )); then + if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then + _cmux_report_pwd_via_relay "$pwd" && _CMUX_PWD_LAST_PWD="$pwd" + fi if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then _cmux_ports_kick refresh fi @@ -1510,7 +1531,6 @@ _cmux_prompt_command() { fi [[ -n "$CMUX_PANEL_ID" ]] || return 0 - local pwd="$PWD" _cmux_set_git_active_pwd "$pwd" # Post-wake socket writes can occasionally leave a probe process wedged. diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index b09104898493..c1a6e0963569 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -151,6 +151,23 @@ _cmux_report_tty_via_relay() { _cmux_relay_rpc "surface.report_tty" "$params" } +_cmux_report_pwd_via_relay() { + local pwd="$1" + _cmux_socket_uses_remote_relay || return 1 + [[ -n "$pwd" ]] || return 1 + local workspace_id="" + workspace_id="$(_cmux_relay_workspace_id)" || return 1 + + local pwd_json params + pwd_json="$(_cmux_json_escape "$pwd")" + params="{\"workspace_id\":\"$workspace_id\",\"path\":\"$pwd_json\"" + if [[ -n "$CMUX_PANEL_ID" ]]; then + params+=",\"surface_id\":\"$CMUX_PANEL_ID\"" + fi + params+="}" + _cmux_relay_rpc_bg "surface.report_pwd" "$params" +} + _cmux_ports_kick_via_relay() { local reason="${1:-command}" _cmux_socket_uses_remote_relay || return 1 @@ -1689,12 +1706,16 @@ _cmux_precmd() { local now="$(_cmux_now)" local cmd_start="$_CMUX_CMD_START" _CMUX_CMD_START=0 + local pwd="$PWD" local cmd_dur=0 if [[ -n "$cmd_start" && "$cmd_start" != 0 ]]; then cmd_dur=$(( now - cmd_start )) fi if (( ! cmux_has_unix_socket )); then + if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then + _cmux_report_pwd_via_relay "$pwd" && _CMUX_PWD_LAST_PWD="$pwd" + fi if (( cmd_dur >= 2 || now - _CMUX_PORTS_LAST_RUN >= 10 )); then _cmux_ports_kick refresh fi @@ -1702,7 +1723,6 @@ _cmux_precmd() { fi [[ -n "$CMUX_PANEL_ID" ]] || return 0 - local pwd="$PWD" _cmux_set_git_active_pwd "$pwd" _cmux_prompt_wrap_guard "$cmd_start" "$pwd" diff --git a/Resources/shell-integration/fish/config.fish b/Resources/shell-integration/fish/config.fish index f6ae5194276a..2e06f042b99e 100644 --- a/Resources/shell-integration/fish/config.fish +++ b/Resources/shell-integration/fish/config.fish @@ -20,6 +20,7 @@ if test "$_cmux_integration_enabled" != 0 set -g _CMUX_PORTS_LAST_RUN 0 set -g _CMUX_TTY_NAME "" set -g _CMUX_TTY_REPORTED 0 + set -g _CMUX_PWD_LAST_PWD "" function _cmux_now if test -n "$EPOCHSECONDS" @@ -107,6 +108,19 @@ if test "$_cmux_integration_enabled" != 0 _cmux_relay_rpc_bg surface.report_tty "$params" end + function _cmux_report_pwd_via_relay --argument-names pwd + _cmux_socket_uses_remote_relay; or return 1 + test -n "$pwd"; or return 1 + set -l workspace_id (_cmux_relay_workspace_id); or return 1 + set -l pwd_json (_cmux_json_escape "$pwd") + set -l params "{\"workspace_id\":\"$workspace_id\",\"path\":\"$pwd_json\"" + if test -n "$CMUX_PANEL_ID" + set params "$params,\"surface_id\":\"$CMUX_PANEL_ID\"" + end + set params "$params}" + _cmux_relay_rpc_bg surface.report_pwd "$params" + end + function _cmux_ports_kick_via_relay --argument-names reason _cmux_socket_uses_remote_relay; or return 1 set -l workspace_id (_cmux_relay_workspace_id); or return 1 @@ -281,6 +295,19 @@ if test "$_cmux_integration_enabled" != 0 _cmux_reset_terminal_keyboard_protocols _cmux_report_tty_once _cmux_report_shell_activity_state prompt + set -l pwd "$PWD" + if test "$pwd" != "$_CMUX_PWD_LAST_PWD" + if _cmux_socket_is_unix + if test -n "$CMUX_TAB_ID"; and test -n "$CMUX_PANEL_ID" + set -l qpwd (_cmux_json_escape "$pwd") + if _cmux_send_bg "report_pwd \"$qpwd\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + set -g _CMUX_PWD_LAST_PWD "$pwd" + end + end + else if _cmux_report_pwd_via_relay "$pwd" + set -g _CMUX_PWD_LAST_PWD "$pwd" + end + end set -l now (_cmux_now) if test (math "$now - $_CMUX_PORTS_LAST_RUN") -ge 5 _cmux_ports_kick refresh diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 1d07c72cc84b..a3911862bfdd 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -6259,7 +6259,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arguments.append(contentsOf: ["--session", sessionId]) } process.arguments = arguments - process.currentDirectoryURL = URL(fileURLWithPath: cwd, isDirectory: true) var environment = ProcessInfo.processInfo.environment environment["CMUX_SOCKET_PATH"] = socketPath environment["CMUX_BUNDLED_CLI_PATH"] = cliURL.path diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 2d0ae80a7521..ecdc1988f6d2 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -1078,7 +1078,7 @@ private func makeFeedNotificationPolicyContext( ), effects: effects ), - hooks: context?.cmuxConfigStore?.notificationHooks(startingFrom: cwd) ?? [], + hooks: context?.cmuxConfigStore?.notificationHooks(startingFrom: workspace?.isRemoteWorkspace == true ? nil : (normalizedFeedNotificationCWD(event.cwd) ?? workspace?.surfaceTabBarDirectory)) ?? [], globalConfigPath: context?.cmuxConfigStore?.globalConfigPath ) } diff --git a/Sources/TerminalController+ControlSurfaceContext4.swift b/Sources/TerminalController+ControlSurfaceContext4.swift index 063ebe55bd37..dda206cb984c 100644 --- a/Sources/TerminalController+ControlSurfaceContext4.swift +++ b/Sources/TerminalController+ControlSurfaceContext4.swift @@ -6,11 +6,11 @@ import Foundation import CmuxWorkspaces /// The surface-domain resume (`resume.set` / `.get` / `.clear`) and reporting -/// (`report_tty` / `report_shell_state` / `ports_kick`) witnesses, plus the token -/// parsers. Split out of `TerminalController+ControlSurfaceContext` to keep the -/// conformance readable; see that file's doc comment for the overview. The blocking -/// approval `NSAlert` and its `String(localized:)` calls resolve here, in the app -/// bundle, so translations survive. +/// (`report_tty` / `report_pwd` / `report_shell_state` / `ports_kick`) witnesses, +/// plus the token parsers. Split out of `TerminalController+ControlSurfaceContext` +/// to keep the conformance readable; see that file's doc comment for the overview. +/// The blocking approval `NSAlert` and its `String(localized:)` calls resolve +/// here, in the app bundle, so translations survive. extension TerminalController { // MARK: - resume target resolution (twin of v2ResolveSurfaceResumeTarget) @@ -315,6 +315,42 @@ extension TerminalController { return .recorded(surfaceID: surfaceId) } + // MARK: - report_pwd + + func controlSurfaceReportPWD( + workspaceID: UUID, + requestedSurfaceID: UUID?, + path: String + ) -> ControlSurfaceReportPWDResolution { + guard let tab = controlTabForSidebarMutation(id: workspaceID) else { + return .workspaceNotFound + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let surfaceId = controlResolveReportedSurfaceId( + in: tab, + requestedSurfaceId: requestedSurfaceID, + validSurfaceIds: validSurfaceIds + ) + guard let surfaceId, validSurfaceIds.contains(surfaceId) else { + if tab.isRemoteWorkspace, validSurfaceIds.isEmpty { + tab.rememberPendingRemoteSurfacePWD(path, requestedSurfaceId: requestedSurfaceID) + return .pending + } + return .surfaceNotFound + } + + if tab.isRemoteWorkspace { + _ = tab.updatePanelDirectory(panelId: surfaceId, directory: path) + } else if let tabManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceID) ?? tabManager { + tabManager.updateSurfaceDirectory(tabId: workspaceID, surfaceId: surfaceId, directory: path) + } else { + _ = tab.updatePanelDirectory(panelId: surfaceId, directory: path) + } + return .recorded(surfaceID: surfaceId) + } + // MARK: - report_shell_state func controlSurfaceReportShellState( diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 18cad89eae4c..915b2bd88897 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1837,10 +1837,9 @@ class TerminalController { // still-shared v2SurfaceMove). surface.action/tab.action and // surface.drag_to_split/surface.split_off (the latter forwarding to the // still-shared v2SurfaceSplitOff) handled by ControlCommandCoordinator too. - // surface.refresh/health/resume.set/get/clear, debug.terminals (forwards to the - // still-shared v2DebugTerminals), surface.send_text/send_key/report_tty/ - // report_shell_state/ports_kick/clear_history/trigger_flash, and surface.read_text - // handled by ControlCommandCoordinator. + // surface.refresh/health/resume.set/get/clear, debug.terminals, surface.send_text/ + // send_key/report_tty/report_pwd/report_shell_state/ports_kick/clear_history/ + // trigger_flash/read_text handled by ControlCommandCoordinator. // Panes // pane.* handled by ControlCommandCoordinator. @@ -2059,6 +2058,7 @@ class TerminalController { "surface.send_text", "surface.send_key", "surface.report_tty", + "surface.report_pwd", "surface.report_shell_state", "surface.ports_kick", "surface.read_text", diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index bba05a9cf572..756f0825c2d2 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1054,7 +1054,7 @@ final class TerminalNotificationStore: ObservableObject { isAppFocused: isAppFocused, isFocusedPanel: isFocusedPanel ), - hooks: cmuxConfigStore?.notificationHooks(startingFrom: cwd) ?? [], + hooks: cmuxConfigStore?.notificationHooks(startingFrom: workspace?.isRemoteWorkspace == true ? nil : cwd) ?? [], globalConfigPath: cmuxConfigStore?.globalConfigPath ) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 2e0d51a11257..a10d01af9e65 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2545,6 +2545,10 @@ final class Workspace: Identifiable, ObservableObject { private func scheduleExtensionSidebarProjectRootRefresh(for directory: String) { extensionSidebarProjectRootRefreshID &+= 1 let refreshID = extensionSidebarProjectRootRefreshID + guard !isRemoteWorkspace else { + extensionSidebarProjectRootPath = nil + return + } let trimmedDirectory = directory.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedDirectory.isEmpty else { extensionSidebarProjectRootPath = nil @@ -3431,6 +3435,8 @@ final class Workspace: Identifiable, ObservableObject { private var pendingRemoteSurfaceTTYSurfaceId: UUID? private var pendingRemoteSurfacePortKickReason: PortScanKickReason? private var pendingRemoteSurfacePortKickSurfaceId: UUID? + private var pendingRemoteSurfacePWD: String? + private var pendingRemoteSurfacePWDSurfaceId: UUID? // When the last live remote terminal is detached out, the source workspace may be // closed immediately after the move succeeds. That teardown must not shut down the // shared SSH control master that is still serving the moved terminal. @@ -4457,13 +4463,13 @@ final class Workspace: Identifiable, ObservableObject { } private func configTrackingDirectory(for panelId: UUID?) -> String? { - // A remote tmux mirror's directories are paths on the REMOTE host. + // A remote workspace's directories are paths on the REMOTE host. // Feeding one into local cmux.json tracking makes CmuxConfigStore walk // the ancestor chain with FileManager.fileExists on the main thread, // and stat'ing e.g. /home/… locally blocks on the autofs automounter // for hundreds of ms (measured via sample during tab-reveal stalls). // No local per-directory config can apply to a remote path — track none. - if isRemoteTmuxMirror { return nil } + if isRemoteWorkspace { return nil } if let panelId { for candidate in [ panelDirectories[panelId], @@ -4502,8 +4508,9 @@ final class Workspace: Identifiable, ObservableObject { } // Update current directory if this is the focused panel if panelId == focusedPanelId { - if surfaceTabBarDirectory != trimmed { - surfaceTabBarDirectory = trimmed + let nextSurfaceTabBarDirectory = configTrackingDirectory(for: panelId) + if surfaceTabBarDirectory != nextSurfaceTabBarDirectory { + surfaceTabBarDirectory = nextSurfaceTabBarDirectory } if currentDirectory != trimmed { currentDirectory = trimmed @@ -5730,6 +5737,8 @@ final class Workspace: Identifiable, ObservableObject { pendingRemoteSurfaceTTYSurfaceId = nil pendingRemoteSurfacePortKickReason = nil pendingRemoteSurfacePortKickSurfaceId = nil + pendingRemoteSurfacePWD = nil + pendingRemoteSurfacePWDSurfaceId = nil clearRemoteDetectedSurfacePorts() remoteDetectedPorts = [] remoteForwardedPorts = [] @@ -5801,6 +5810,7 @@ final class Workspace: Identifiable, ObservableObject { } guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + _ = applyPendingRemoteSurfacePWDIfNeeded(to: panelId) applyPendingRemoteSurfaceTTYIfNeeded(to: panelId) _ = applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) } @@ -6270,6 +6280,29 @@ final class Workspace: Identifiable, ObservableObject { pendingRemoteSurfacePortKickSurfaceId = requestedSurfaceId } + @MainActor + func rememberPendingRemoteSurfacePWD(_ path: String, requestedSurfaceId: UUID?) { + guard path.rangeOfCharacter(from: .whitespacesAndNewlines.inverted) != nil else { return } + pendingRemoteSurfacePWD = path + pendingRemoteSurfacePWDSurfaceId = requestedSurfaceId + } + + @MainActor + @discardableResult + private func applyPendingRemoteSurfacePWDIfNeeded(to panelId: UUID) -> Bool { + guard let path = pendingRemoteSurfacePWD, + path.rangeOfCharacter(from: .whitespacesAndNewlines.inverted) != nil else { + return false + } + if let requestedSurfaceId = pendingRemoteSurfacePWDSurfaceId, + requestedSurfaceId != panelId { + return false + } + pendingRemoteSurfacePWD = nil + pendingRemoteSurfacePWDSurfaceId = nil + return updatePanelDirectory(panelId: panelId, directory: path) + } + @MainActor private func applyPendingRemoteSurfaceTTYIfNeeded(to panelId: UUID) { guard let ttyName = pendingRemoteSurfaceTTYName?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ecd1f57c0ec9..3aa3cb151eb9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -675,6 +675,7 @@ REE0CA0000000000000000A2 /* RemotesClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000A1 /* RemotesClient.swift */; }; REE0CA0000000000000000B2 /* RemotesClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000B1 /* RemotesClientTests.swift */; }; EE30D6000000000000000006 /* RemoteSessionStrings+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE30D6000000000000000005 /* RemoteSessionStrings+App.swift */; }; + C0F674600000000000000001 /* RemoteShellCWDRelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0F674600000000000000002 /* RemoteShellCWDRelayTests.swift */; }; E4321000E4321000E4321001 /* RemoteShellSessionParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */; }; 0A17C0DE0A17C0DE0A17C002 /* RemoteTmuxAttachOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17C001 /* RemoteTmuxAttachOutcome.swift */; }; 0A17C0DE0A17C0DE0A17C004 /* RemoteTmuxAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */; }; @@ -1756,6 +1757,7 @@ REE0CA0000000000000000A1 /* RemotesClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RemotesClient.swift; sourceTree = ""; }; REE0CA0000000000000000B1 /* RemotesClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotesClientTests.swift; sourceTree = ""; }; EE30D6000000000000000005 /* RemoteSessionStrings+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteSessionStrings+App.swift"; sourceTree = ""; }; + C0F674600000000000000002 /* RemoteShellCWDRelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteShellCWDRelayTests.swift; sourceTree = ""; }; E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteShellSessionParsing.swift; sourceTree = ""; }; 0A17C0DE0A17C0DE0A17C001 /* RemoteTmuxAttachOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxAttachOutcome.swift; sourceTree = ""; }; 0A17C0DE0A17C0DE0A17C003 /* RemoteTmuxAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTmuxAuthTests.swift; sourceTree = ""; }; @@ -3060,6 +3062,7 @@ C0F15A000000000000000002 /* FishShellIntegrationTests.swift */, C0F16A000000000000000002 /* ShellStartupMatrixTests.swift */, C0F16C000000000000000002 /* ShellStartupMissingBundleTests.swift */, + C0F674600000000000000002 /* RemoteShellCWDRelayTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, B8E9500A0000000000000002 /* PostHogAnalyticsPropertiesTests.swift */, 604100010000000000000002 /* GhosttyDrawableSizeRetryTests.swift */, @@ -4664,6 +4667,7 @@ A64610020000000000000001 /* QuitConfirmationAlertPresenterTests.swift in Sources */, A6D1F0400000000000000002 /* ReleasingWindowControllerTests.swift in Sources */, REE0CA0000000000000000B2 /* RemotesClientTests.swift in Sources */, + C0F674600000000000000001 /* RemoteShellCWDRelayTests.swift in Sources */, 0A17C0DE0A17C0DE0A17C004 /* RemoteTmuxAuthTests.swift in Sources */, 5F5553CA5553CA5553CA0001 /* RemoteTmuxCapabilitiesTests.swift in Sources */, B2FDE62450514C4C27FBD8F1 /* RemoteTmuxControlParserTests.swift in Sources */, diff --git a/cmuxTests/FishShellIntegrationTests.swift b/cmuxTests/FishShellIntegrationTests.swift index e01c21fcdcb0..ce74ae1f0f51 100644 --- a/cmuxTests/FishShellIntegrationTests.swift +++ b/cmuxTests/FishShellIntegrationTests.swift @@ -162,6 +162,58 @@ struct FishShellIntegrationTests { ) } + @Test(.enabled(if: fishExecutablePath != nil)) + func testFishIntegrationRelayPromptReportsPWD() throws { + _ = try requireFishExecutable() + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-fish-relay-pwd-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let remoteDirectory = root.appendingPathComponent("remote-cwd", isDirectory: true) + let logPath = root.appendingPathComponent("relay.log", isDirectory: false) + let cmuxPath = binDir.appendingPathComponent("cmux", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + try fileManager.createDirectory(at: remoteDirectory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableShellFile( + at: cmuxPath, + body: """ + #!/bin/sh + printf '%s\\n' "$*" >> "\(logPath.path)" + """ + ) + + let result = try runInteractiveFish( + command: """ + printf '' > "\(logPath.path)" + cd "\(remoteDirectory.path)" + set -g _CMUX_TTY_REPORTED 1 + set -g _CMUX_PORTS_LAST_RUN (_cmux_now) + set -g _CMUX_PWD_LAST_PWD /tmp/local-launch + _cmux_prompt + for _cmux_i in (seq 1 20) + test -s "\(logPath.path)"; and break + sleep 0.05 + end + cat "\(logPath.path)" + """, + extraEnvironment: [ + "CMUX_SOCKET_PATH": "127.0.0.1:64011", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_TAB_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_BUNDLED_CLI_PATH": cmuxPath.path, + ] + ) + + expectTrue( + result.stdout.contains(#"rpc surface.report_pwd {"workspace_id":"11111111-1111-1111-1111-111111111111","path":"\#(remoteDirectory.path)","surface_id":"22222222-2222-2222-2222-222222222222"}"#), + result.stdout + ) + } + @Test func testGeneratedFishBootstrapStagesIntegrationAndPreservesUserConfigHome() throws { let fileManager = FileManager.default diff --git a/cmuxTests/RemoteShellCWDRelayTests.swift b/cmuxTests/RemoteShellCWDRelayTests.swift new file mode 100644 index 000000000000..ee55491ddd4e --- /dev/null +++ b/cmuxTests/RemoteShellCWDRelayTests.swift @@ -0,0 +1,226 @@ +import Foundation +import Testing + +@Suite(.serialized) +struct RemoteShellCWDRelayTests { + @Test + func zshRelayPromptReportsRemotePWD() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-relay-pwd-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let remoteDirectory = root.appendingPathComponent("remote-cwd", isDirectory: true) + let logPath = root.appendingPathComponent("relay.log", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + try fileManager.createDirectory(at: remoteDirectory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("cmux", isDirectory: false), + contents: """ + #!/bin/sh + printf '%s\\n' "$*" >> "\(logPath.path)" + exit 0 + """ + ) + + let output = try runInteractiveZsh( + command: """ + : > "\(logPath.path)" + cd "\(remoteDirectory.path)" + _CMUX_TTY_REPORTED=1 + _CMUX_PORTS_LAST_RUN=$(_cmux_now) + _CMUX_PWD_LAST_PWD="/tmp/local-launch" + _cmux_precmd + repeat 20; do + [[ -s "\(logPath.path)" ]] && break + sleep 0.05 + done + cat "\(logPath.path)" + """, + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "127.0.0.1:64011", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_TAB_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + ] + ) + + let expected = #"rpc surface.report_pwd {"workspace_id":"11111111-1111-1111-1111-111111111111","path":"\#(remoteDirectory.path)","surface_id":"22222222-2222-2222-2222-222222222222"}"# + #expect(output.contains(expected), Comment(rawValue: output)) + } + + @Test + func bashRelayPromptReportsRemotePWD() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-bash-relay-pwd-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let remoteDirectory = root.appendingPathComponent("remote-cwd", isDirectory: true) + let logPath = root.appendingPathComponent("relay.log", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + try fileManager.createDirectory(at: remoteDirectory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("cmux", isDirectory: false), + contents: """ + #!/bin/sh + printf '%s\\n' "$*" >> "\(logPath.path)" + exit 0 + """ + ) + + let result = try runInteractiveBash( + command: """ + : > "\(logPath.path)" + cd "\(remoteDirectory.path)" + _CMUX_TTY_REPORTED=1 + _CMUX_PORTS_LAST_RUN=$(_cmux_now) + _CMUX_PWD_LAST_PWD="/tmp/local-launch" + _cmux_prompt_command + for _cmux_i in $(seq 1 20); do + [ -s "\(logPath.path)" ] && break + sleep 0.05 + done + cat "\(logPath.path)" + """, + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "127.0.0.1:64011", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_TAB_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + ] + ) + + let expected = #"rpc surface.report_pwd {"workspace_id":"11111111-1111-1111-1111-111111111111","path":"\#(remoteDirectory.path)","surface_id":"22222222-2222-2222-2222-222222222222"}"# + #expect(result.stdout.contains(expected), Comment(rawValue: result.stdout)) + } + + private func runInteractiveZsh( + command: String, + extraEnvironment: [String: String] + ) throws -> String { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-shell-integration-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let userZdotdir = root.appendingPathComponent("zdotdir") + try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true) + var userZshEnvFileContents = "\n" + if let path = extraEnvironment["PATH"] { + let escaped = path.replacingOccurrences(of: "\"", with: "\\\"") + userZshEnvFileContents = "export PATH=\"\(escaped)\"\n" + } + try userZshEnvFileContents.write( + to: userZdotdir.appendingPathComponent(".zshenv"), + atomically: true, + encoding: .utf8 + ) + + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let cmuxZdotdir = repoRoot.appendingPathComponent("Resources/shell-integration") + let ghosttyResources = repoRoot.appendingPathComponent("ghostty/src") + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-i", "-c", command] + process.environment = [ + "HOME": root.path, + "TERM": "xterm-256color", + "SHELL": "/bin/zsh", + "USER": NSUserName(), + "ZDOTDIR": cmuxZdotdir.path, + "CMUX_ZSH_ZDOTDIR": userZdotdir.path, + "CMUX_SHELL_INTEGRATION": "1", + "CMUX_SHELL_INTEGRATION_DIR": cmuxZdotdir.path, + "GHOSTTY_RESOURCES_DIR": ghosttyResources.path, + ] + for (key, value) in extraEnvironment { + process.environment?[key] = value + } + + let output = try runProcess(process) + #expect(output.status == 0, Comment(rawValue: output.stderr)) + return output.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func runInteractiveBash( + command: String, + extraEnvironment: [String: String] + ) throws -> (stdout: String, stderr: String) { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-bash-shell-integration-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let integrationPath = repoRoot.appendingPathComponent("Resources/shell-integration/cmux-bash-integration.bash") + let rcfilePath = root.appendingPathComponent(".bashrc") + try ". \"\(integrationPath.path)\"\n".write(to: rcfilePath, atomically: true, encoding: .utf8) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["--noprofile", "--rcfile", rcfilePath.path, "-i", "-c", command] + process.environment = [ + "HOME": root.path, + "TERM": "xterm-256color", + "SHELL": "/bin/bash", + "USER": NSUserName(), + ] + for (key, value) in extraEnvironment { + process.environment?[key] = value + } + + let output = try runProcess(process) + #expect(output.status == 0, Comment(rawValue: output.stderr)) + return ( + stdout: output.stdout.trimmingCharacters(in: .whitespacesAndNewlines), + stderr: output.stderr.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + private func runProcess(_ process: Process) throws -> (status: Int32, stdout: String, stderr: String) { + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + let deadline = Date.now.addingTimeInterval(5) + while process.isRunning && Date.now < deadline { + _ = RunLoop.current.run(mode: .default, before: Date.now.addingTimeInterval(0.01)) + } + if process.isRunning { + process.terminate() + process.waitUntilExit() + throw NSError( + domain: "RemoteShellCWDRelayTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Timed out waiting for shell to exit"] + ) + } + + return ( + status: process.terminationStatus, + stdout: String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "", + stderr: String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + ) + } + + private func writeExecutableScript(at url: URL, contents: String) throws { + try contents.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } +}