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
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
///
Expand All @@ -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`).
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 ")
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
22 changes: 21 additions & 1 deletion Resources/shell-integration/cmux-bash-integration.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1502,15 +1519,18 @@ _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
return 0
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.
Expand Down
22 changes: 21 additions & 1 deletion Resources/shell-integration/cmux-zsh-integration.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1689,20 +1706,23 @@ _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
return 0
fi

[[ -n "$CMUX_PANEL_ID" ]] || return 0
local pwd="$PWD"
_cmux_set_git_active_pwd "$pwd"

_cmux_prompt_wrap_guard "$cmd_start" "$pwd"
Expand Down
27 changes: 27 additions & 0 deletions Resources/shell-integration/fish/config.fish
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/Feed/FeedCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down
Loading
Loading