diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 99de51f3..0f1826cc 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -61,7 +61,7 @@ _CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}" -_CMUX_TMUX_STATE_LAST="${_CMUX_TMUX_STATE_LAST:-}" +_CMUX_TMUX_STATE_SIGNATURE_LAST="${_CMUX_TMUX_STATE_SIGNATURE_LAST:-}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" _CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}" _CMUX_TMUX_PUSH_SIGNATURE="${_CMUX_TMUX_PUSH_SIGNATURE:-}" @@ -275,6 +275,13 @@ _cmux_report_tmux_state_payload() { printf '%s\n' "report_tmux_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } +_cmux_tmux_state_report_signature() { + local payload="$1" + [[ -n "$payload" ]] || return 0 + [[ -n "$CMUX_SOCKET_PATH" ]] || return 0 + printf '%s\037%s\n' "$CMUX_SOCKET_PATH" "$payload" +} + _cmux_report_tmux_state() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 @@ -282,10 +289,11 @@ _cmux_report_tmux_state() { payload="$(_cmux_report_tmux_state_payload)" [[ -n "$payload" ]] || return 0 - local state="${payload#report_tmux_state }" - state="${state%% *}" - [[ "$_CMUX_TMUX_STATE_LAST" == "$state" ]] && return 0 - _CMUX_TMUX_STATE_LAST="$state" + local signature="" + signature="$(_cmux_tmux_state_report_signature "$payload")" + [[ -n "$signature" ]] || return 0 + [[ "$_CMUX_TMUX_STATE_SIGNATURE_LAST" == "$signature" ]] && return 0 + _CMUX_TMUX_STATE_SIGNATURE_LAST="$signature" { _cmux_send "$payload" } >/dev/null 2>&1 & disown diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index d47bcc3d..87a0b07c 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -74,7 +74,7 @@ typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20 typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_SHELL_ACTIVITY_LAST="" -typeset -g _CMUX_TMUX_STATE_LAST="" +typeset -g _CMUX_TMUX_STATE_SIGNATURE_LAST="" typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0 @@ -380,6 +380,13 @@ _cmux_report_tmux_state_payload() { print -r -- "report_tmux_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } +_cmux_tmux_state_report_signature() { + local payload="$1" + [[ -n "$payload" ]] || return 0 + [[ -n "$CMUX_SOCKET_PATH" ]] || return 0 + print -r -- "${CMUX_SOCKET_PATH}"$'\x1f'"${payload}" +} + _cmux_report_tmux_state() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 @@ -387,10 +394,11 @@ _cmux_report_tmux_state() { payload="$(_cmux_report_tmux_state_payload)" [[ -n "$payload" ]] || return 0 - local state="${payload#report_tmux_state }" - state="${state%% *}" - [[ "$_CMUX_TMUX_STATE_LAST" == "$state" ]] && return 0 - _CMUX_TMUX_STATE_LAST="$state" + local signature="" + signature="$(_cmux_tmux_state_report_signature "$payload")" + [[ -n "$signature" ]] || return 0 + [[ "$_CMUX_TMUX_STATE_SIGNATURE_LAST" == "$signature" ]] && return 0 + _CMUX_TMUX_STATE_SIGNATURE_LAST="$signature" _cmux_send_bg "$payload" } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8c09d27d..368e09b4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1672,6 +1672,11 @@ class GhosttyApp { var containsExplicitShiftEnterDirective = false mutating func recordKeybind(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.isEmpty || trimmed == "clear" { + containsExplicitShiftEnterDirective = false + return + } if GhosttyApp.keybindDirectiveTargetsShiftEnter(value) { containsExplicitShiftEnterDirective = true } @@ -1694,7 +1699,7 @@ class GhosttyApp { var loadedRecursivePaths = Set() var index = 0 - while index < recursiveConfigPaths.count && !summary.containsExplicitShiftEnterDirective { + while index < recursiveConfigPaths.count { let path = NSString(string: recursiveConfigPaths[index]).expandingTildeInPath index += 1 @@ -1814,9 +1819,6 @@ class GhosttyApp { case "keybind": guard let value = entry.value else { continue } summary.recordKeybind(value) - if summary.containsExplicitShiftEnterDirective { - return - } case "config-file": guard let value = entry.value else { continue } applyConfigFileDirective( diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 82fe7755..a058b958 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2522,6 +2522,28 @@ final class GhosttyMouseFocusTests: XCTestCase { ) } + func testUserConfigDefinesShiftEnterBindingHonorsLaterClearInIncludedFile() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-shift-enter-clear-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let included = dir.appendingPathComponent("bindings.conf") + try "keybind = clear\n" + .write(to: included, atomically: true, encoding: .utf8) + + let main = dir.appendingPathComponent("config") + try """ + keybind = shift+enter=text:\\x0a + config-file = \(included.path) + """ + .write(to: main, atomically: true, encoding: .utf8) + + XCTAssertFalse( + GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [main.path]) + ) + } + func testUserConfigDefinesShiftEnterBindingIgnoresOtherModifierCombinations() throws { try withTempConfig("keybind = cmd+shift+enter=text:\\x0a\n") { path in XCTAssertFalse( @@ -2993,6 +3015,58 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { ) } + func testShellIntegrationResendsTmuxStateWhenSocketTargetChanges() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-tmux-state-resend-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let socketA = root.appendingPathComponent("cmux-a.sock").path + let socketB = root.appendingPathComponent("cmux-b.sock").path + + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: """ + python3 -c 'import os, socket, sys, time; path = sys.argv[1]; \ + os.path.exists(path) and os.unlink(path); \ + s = socket.socket(socket.AF_UNIX); s.bind(path); s.listen(1); time.sleep(3)' "$CMUX_SOCKET_PATH" & + server_a=$! + sleep 0.1 + functions[_cmux_send_bg]='print -r -- "$1"' + _CMUX_TMUX_STATE_SIGNATURE_LAST="" + _cmux_report_tmux_state + kill $server_a >/dev/null 2>&1 + wait $server_a >/dev/null 2>&1 + + export CMUX_SOCKET_PATH="\(socketB)" + python3 -c 'import os, socket, sys, time; path = sys.argv[1]; \ + os.path.exists(path) and os.unlink(path); \ + s = socket.socket(socket.AF_UNIX); s.bind(path); s.listen(1); time.sleep(3)' "$CMUX_SOCKET_PATH" & + server_b=$! + sleep 0.1 + _cmux_report_tmux_state + kill $server_b >/dev/null 2>&1 + wait $server_b >/dev/null 2>&1 + """, + extraEnvironment: [ + "TMUX": "/tmp/tmux-current,123,0", + "CMUX_SOCKET_PATH": socketA, + "CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_PANEL_ID": "99999999-9999-9999-9999-999999999999", + ] + ) + + XCTAssertEqual( + output, + """ + report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --panel=99999999-9999-9999-9999-999999999999 + report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --panel=99999999-9999-9999-9999-999999999999 + """ + ) + } + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { try runInteractiveZsh( cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration,