Merge pull request #2407 from manaflow-ai/revert-2355-issue-2352-shift-enter-tmux
Revert "Map Shift+Enter to raw newline in Ghostty"
This commit is contained in:
commit
341aed8ebf
9 changed files with 26 additions and 833 deletions
|
|
@ -61,7 +61,6 @@ _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_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:-}"
|
||||
|
|
@ -265,47 +264,6 @@ _cmux_report_shell_activity_state() {
|
|||
} >/dev/null 2>&1 & disown
|
||||
}
|
||||
|
||||
_cmux_report_tmux_state_payload() {
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
|
||||
local state="outside"
|
||||
[[ -n "$TMUX" ]] && state="inside"
|
||||
|
||||
local payload="report_tmux_state $state --tab=$CMUX_TAB_ID"
|
||||
if [[ -n "$TMUX" ]]; then
|
||||
[[ -n "$_CMUX_TTY_NAME" ]] && payload+=" --tty=$_CMUX_TTY_NAME"
|
||||
else
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
payload+=" --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$payload"
|
||||
}
|
||||
|
||||
_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
|
||||
|
||||
local payload=""
|
||||
payload="$(_cmux_report_tmux_state_payload)"
|
||||
[[ -n "$payload" ]] || return 0
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
_cmux_ports_kick() {
|
||||
# Lightweight: just tell the app to run a batched scan for this panel.
|
||||
# The app coalesces kicks across all panels and runs a single ps+lsof.
|
||||
|
|
@ -543,6 +501,7 @@ _cmux_preexec_command() {
|
|||
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
|
||||
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
||||
local t
|
||||
|
|
@ -551,12 +510,8 @@ _cmux_preexec_command() {
|
|||
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
||||
fi
|
||||
|
||||
if [[ -n "$CMUX_PANEL_ID" ]]; then
|
||||
_cmux_report_shell_activity_state running
|
||||
fi
|
||||
_cmux_report_tmux_state
|
||||
_cmux_report_shell_activity_state running
|
||||
_cmux_report_tty_once
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
_cmux_ports_kick
|
||||
_cmux_stop_pr_poll_loop
|
||||
}
|
||||
|
|
@ -570,21 +525,8 @@ _cmux_prompt_command() {
|
|||
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
|
||||
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
||||
local t
|
||||
t="$(tty 2>/dev/null || true)"
|
||||
t="${t##*/}"
|
||||
[[ "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
||||
fi
|
||||
|
||||
if [[ -n "$CMUX_PANEL_ID" ]]; then
|
||||
_cmux_report_shell_activity_state prompt
|
||||
fi
|
||||
_cmux_report_tmux_state
|
||||
_cmux_report_tty_once
|
||||
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
_cmux_report_shell_activity_state prompt
|
||||
|
||||
local now=$SECONDS
|
||||
local pwd="$PWD"
|
||||
|
|
@ -601,6 +543,16 @@ _cmux_prompt_command() {
|
|||
fi
|
||||
fi
|
||||
|
||||
# Resolve TTY name once.
|
||||
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
||||
local t
|
||||
t="$(tty 2>/dev/null || true)"
|
||||
t="${t##*/}"
|
||||
[[ "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
||||
fi
|
||||
|
||||
_cmux_report_tty_once
|
||||
|
||||
# CWD: keep the app in sync with the actual shell directory.
|
||||
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
|
||||
_CMUX_PWD_LAST_PWD="$pwd"
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ 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_SIGNATURE_LAST=""
|
||||
typeset -g _CMUX_TTY_NAME=""
|
||||
typeset -g _CMUX_TTY_REPORTED=0
|
||||
typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0
|
||||
|
|
@ -370,45 +369,6 @@ _cmux_report_shell_activity_state() {
|
|||
_cmux_send_bg "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
}
|
||||
|
||||
_cmux_report_tmux_state_payload() {
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
|
||||
local state="outside"
|
||||
[[ -n "$TMUX" ]] && state="inside"
|
||||
|
||||
local payload="report_tmux_state $state --tab=$CMUX_TAB_ID"
|
||||
if [[ -n "$TMUX" ]]; then
|
||||
[[ -n "$_CMUX_TTY_NAME" ]] && payload+=" --tty=$_CMUX_TTY_NAME"
|
||||
else
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
payload+=" --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
|
||||
print -r -- "$payload"
|
||||
}
|
||||
|
||||
_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
|
||||
|
||||
local payload=""
|
||||
payload="$(_cmux_report_tmux_state_payload)"
|
||||
[[ -n "$payload" ]] || return 0
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
_cmux_ports_kick() {
|
||||
# Lightweight: just tell the app to run a batched scan for this panel.
|
||||
# The app coalesces kicks across all panels and runs a single ps+lsof.
|
||||
|
|
@ -708,7 +668,6 @@ _cmux_preexec() {
|
|||
|
||||
_CMUX_CMD_START=$EPOCHSECONDS
|
||||
_cmux_report_shell_activity_state running
|
||||
_cmux_report_tmux_state
|
||||
|
||||
# Heuristic: commands that may change git branch/dirty state without changing $PWD.
|
||||
local cmd="${1## }"
|
||||
|
|
@ -732,6 +691,8 @@ _cmux_precmd() {
|
|||
# Skip if socket doesn't exist yet
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
_cmux_report_shell_activity_state prompt
|
||||
|
||||
# Handle cases where Ghostty integration initializes after this file.
|
||||
(( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) || _cmux_patch_ghostty_semantic_redraw
|
||||
|
|
@ -743,14 +704,8 @@ _cmux_precmd() {
|
|||
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
||||
fi
|
||||
|
||||
if [[ -n "$CMUX_PANEL_ID" ]]; then
|
||||
_cmux_report_shell_activity_state prompt
|
||||
fi
|
||||
_cmux_report_tmux_state
|
||||
_cmux_report_tty_once
|
||||
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
|
||||
local now=$EPOCHSECONDS
|
||||
local pwd="$PWD"
|
||||
local cmd_start="$_CMUX_CMD_START"
|
||||
|
|
|
|||
|
|
@ -949,7 +949,6 @@ class GhosttyApp {
|
|||
private var backgroundEventCounter: UInt64 = 0
|
||||
private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped
|
||||
private var defaultBackgroundScopeSource: String = "initialize"
|
||||
private(set) var userConfigDefinesShiftEnterBinding = false
|
||||
private var lastAppearanceColorScheme: GhosttyConfig.ColorSchemePreference?
|
||||
private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher =
|
||||
// Theme chrome should track terminal theme changes in the same frame.
|
||||
|
|
@ -1394,7 +1393,6 @@ class GhosttyApp {
|
|||
loadLegacyGhosttyConfigIfNeeded(config)
|
||||
ghostty_config_load_recursive_files(config)
|
||||
loadCmuxAppSupportGhosttyConfigIfNeeded(config)
|
||||
userConfigDefinesShiftEnterBinding = Self.userConfigDefinesShiftEnterBinding()
|
||||
loadCopyOnSelectOverride(config)
|
||||
loadCJKFontFallbackIfNeeded(config)
|
||||
// cmux provides the terminal background via backgroundView (CALayer)
|
||||
|
|
@ -1600,12 +1598,6 @@ class GhosttyApp {
|
|||
) != nil
|
||||
}
|
||||
|
||||
static func userConfigDefinesShiftEnterBinding(
|
||||
configPaths: [String] = loadedCJKScanPaths()
|
||||
) -> Bool {
|
||||
userShiftEnterConfigSummary(configPaths: configPaths).containsExplicitShiftEnterDirective
|
||||
}
|
||||
|
||||
private static func configuredCTFont(
|
||||
named name: String,
|
||||
size: CGFloat = 12
|
||||
|
|
@ -1683,55 +1675,6 @@ class GhosttyApp {
|
|||
return summary
|
||||
}
|
||||
|
||||
private struct UserShiftEnterConfigSummary {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func userShiftEnterConfigSummary(
|
||||
configPaths: [String] = loadedCJKScanPaths()
|
||||
) -> UserShiftEnterConfigSummary {
|
||||
var summary = UserShiftEnterConfigSummary()
|
||||
var recursiveConfigPaths: [String] = []
|
||||
|
||||
for path in configPaths.map({ NSString(string: $0).expandingTildeInPath }) {
|
||||
scanShiftEnterConfigFile(
|
||||
atPath: path,
|
||||
summary: &summary,
|
||||
recursiveConfigPaths: &recursiveConfigPaths
|
||||
)
|
||||
}
|
||||
|
||||
var loadedRecursivePaths = Set<String>()
|
||||
var index = 0
|
||||
while index < recursiveConfigPaths.count {
|
||||
let path = NSString(string: recursiveConfigPaths[index]).expandingTildeInPath
|
||||
index += 1
|
||||
|
||||
let resolved = (path as NSString).standardizingPath
|
||||
guard !loadedRecursivePaths.contains(resolved) else { continue }
|
||||
loadedRecursivePaths.insert(resolved)
|
||||
|
||||
scanShiftEnterConfigFile(
|
||||
atPath: path,
|
||||
summary: &summary,
|
||||
recursiveConfigPaths: &recursiveConfigPaths
|
||||
)
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
/// Returns the top-level config paths that cmux will actually load before
|
||||
/// recursive `config-file` processing.
|
||||
static func loadedCJKScanPaths(
|
||||
|
|
@ -1816,37 +1759,6 @@ class GhosttyApp {
|
|||
}
|
||||
}
|
||||
|
||||
private static func scanShiftEnterConfigFile(
|
||||
atPath path: String,
|
||||
summary: inout UserShiftEnterConfigSummary,
|
||||
recursiveConfigPaths: inout [String]
|
||||
) {
|
||||
let resolved = (path as NSString).standardizingPath
|
||||
guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
let parentDir = (resolved as NSString).deletingLastPathComponent
|
||||
|
||||
for line in contents.components(separatedBy: .newlines) {
|
||||
guard let entry = parsedConfigEntry(from: line) else { continue }
|
||||
|
||||
switch entry.key {
|
||||
case "keybind":
|
||||
guard let value = entry.value else { continue }
|
||||
summary.recordKeybind(value)
|
||||
case "config-file":
|
||||
guard let value = entry.value else { continue }
|
||||
applyConfigFileDirective(
|
||||
value,
|
||||
parentDir: parentDir,
|
||||
recursiveConfigPaths: &recursiveConfigPaths
|
||||
)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func parsedConfigEntry(
|
||||
from rawLine: String
|
||||
) -> (key: String, value: String?)? {
|
||||
|
|
@ -1899,63 +1811,6 @@ class GhosttyApp {
|
|||
recursiveConfigPaths.append(absolute)
|
||||
}
|
||||
|
||||
private static func keybindDirectiveTargetsShiftEnter(_ value: String) -> Bool {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty,
|
||||
trimmed.lowercased() != "clear",
|
||||
let separatorIndex = trimmed.firstIndex(of: "=") else {
|
||||
return false
|
||||
}
|
||||
|
||||
let triggerExpression = String(trimmed[..<separatorIndex])
|
||||
for rawPart in triggerExpression.split(separator: ">") {
|
||||
var part = String(rawPart).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !part.isEmpty else { continue }
|
||||
|
||||
if let slashIndex = part.lastIndex(of: "/") {
|
||||
part = String(part[part.index(after: slashIndex)...])
|
||||
}
|
||||
|
||||
for prefix in ["all:", "global:", "unconsumed:", "performable:"] {
|
||||
while part.hasPrefix(prefix) {
|
||||
part.removeFirst(prefix.count)
|
||||
}
|
||||
}
|
||||
|
||||
let tokens = part
|
||||
.split(separator: "+")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
guard tokens.count == 2, tokens.contains("shift") else { continue }
|
||||
guard let keyToken = tokens.first(where: { $0 != "shift" }) else { continue }
|
||||
switch keyToken {
|
||||
case "enter", "return", "kp_enter", "physical:enter", "physical:return", "physical:kp_enter":
|
||||
return true
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
static func shouldRemapShiftEnterForTmux(
|
||||
keyCode: UInt16,
|
||||
modifierFlags: NSEvent.ModifierFlags,
|
||||
isInsideTmux: Bool,
|
||||
userConfigDefinesShiftEnterBinding: Bool,
|
||||
hasMarkedText: Bool
|
||||
) -> Bool {
|
||||
guard isInsideTmux else { return false }
|
||||
guard !userConfigDefinesShiftEnterBinding else { return false }
|
||||
guard !hasMarkedText else { return false }
|
||||
|
||||
let normalizedModifiers = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
|
||||
guard normalizedModifiers == [.shift] else { return false }
|
||||
return keyCode == 36 || keyCode == 76
|
||||
}
|
||||
|
||||
static func shouldLoadLegacyGhosttyConfig(
|
||||
newConfigFileSize: Int?,
|
||||
legacyConfigFileSize: Int?
|
||||
|
|
@ -5773,7 +5628,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
#endif
|
||||
|
||||
// Check if this event matches a Ghostty keybinding.
|
||||
let bindingFlags = ghosttyBindingFlags(for: event, surface: surface)
|
||||
let bindingFlags: ghostty_binding_flags_e? = {
|
||||
var keyEvent = ghosttyKeyEvent(for: event, surface: surface)
|
||||
let text = textForKeyEvent(event).flatMap { shouldSendText($0) ? $0 : nil } ?? ""
|
||||
var flags = ghostty_binding_flags_e(0)
|
||||
let isBinding = text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
return ghostty_surface_key_is_binding(surface, keyEvent, &flags)
|
||||
}
|
||||
return isBinding ? flags : nil
|
||||
}()
|
||||
|
||||
if let bindingFlags {
|
||||
let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0
|
||||
|
|
@ -5928,10 +5792,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
#if DEBUG
|
||||
keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0
|
||||
#endif
|
||||
if shouldRemapShiftEnterForTmux(event: event, surface: surface) {
|
||||
terminalSurface?.sendText("\n")
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
recordKeyLatency(path: "keyDown", event: event)
|
||||
#endif
|
||||
|
|
@ -6279,64 +6139,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
return ghostty_surface_key(surface, keyEvent)
|
||||
}
|
||||
|
||||
private func ghosttyBindingFlags(
|
||||
for event: NSEvent,
|
||||
surface: ghostty_surface_t
|
||||
) -> ghostty_binding_flags_e? {
|
||||
var keyEvent = ghosttyKeyEvent(for: event, surface: surface)
|
||||
let text = textForKeyEvent(event).flatMap { shouldSendText($0) ? $0 : nil } ?? ""
|
||||
var flags = ghostty_binding_flags_e(0)
|
||||
let isBinding = text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
return ghostty_surface_key_is_binding(surface, keyEvent, &flags)
|
||||
}
|
||||
return isBinding ? flags : nil
|
||||
}
|
||||
|
||||
private func shouldRemapShiftEnterForTmux(
|
||||
event: NSEvent,
|
||||
surface: ghostty_surface_t
|
||||
) -> Bool {
|
||||
let userConfigDefinesShiftEnterBinding = GhosttyApp.shared.userConfigDefinesShiftEnterBinding
|
||||
guard !userConfigDefinesShiftEnterBinding else { return false }
|
||||
let normalizedModifiers = terminalKeyboardCopyModeNormalizedModifiers(event.modifierFlags)
|
||||
guard normalizedModifiers == [.shift] else { return false }
|
||||
guard event.keyCode == 36 || event.keyCode == 76 else { return false }
|
||||
guard let terminalSurface else { return false }
|
||||
let tabId = terminalSurface.tabId
|
||||
let panelId = terminalSurface.id
|
||||
guard let tab = AppDelegate.shared?
|
||||
.tabManagerFor(tabId: tabId)?
|
||||
.tabs
|
||||
.first(where: { $0.id == tabId }) else {
|
||||
return false
|
||||
}
|
||||
let reportedInsideTmux = tab.panelIsInsideTmux(panelId: panelId)
|
||||
// Shell-side tmux telemetry can lag behind pane focus changes, so fall back to
|
||||
// the current foreground process on the pane TTY before deciding whether to remap.
|
||||
let detectedInsideTmux = tab.surfaceTTYNames[panelId].map {
|
||||
TerminalSSHSessionDetector.isInsideTmux(forTTY: $0)
|
||||
} ?? false
|
||||
let isInsideTmux = reportedInsideTmux || detectedInsideTmux
|
||||
if detectedInsideTmux != reportedInsideTmux {
|
||||
AppDelegate.shared?
|
||||
.tabManagerFor(tabId: tabId)?
|
||||
.updateSurfaceTmuxState(
|
||||
tabId: tabId,
|
||||
surfaceId: panelId,
|
||||
isInsideTmux: detectedInsideTmux
|
||||
)
|
||||
}
|
||||
let shouldRemap = GhosttyApp.shouldRemapShiftEnterForTmux(
|
||||
keyCode: event.keyCode,
|
||||
modifierFlags: event.modifierFlags,
|
||||
isInsideTmux: isInsideTmux,
|
||||
userConfigDefinesShiftEnterBinding: userConfigDefinesShiftEnterBinding,
|
||||
hasMarkedText: hasMarkedText()
|
||||
)
|
||||
return shouldRemap
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@discardableResult
|
||||
private func sendTimedGhosttyKey(
|
||||
|
|
|
|||
|
|
@ -2598,15 +2598,6 @@ class TabManager: ObservableObject {
|
|||
tab.updatePanelShellActivityState(panelId: surfaceId, state: state)
|
||||
}
|
||||
|
||||
func updateSurfaceTmuxState(
|
||||
tabId: UUID,
|
||||
surfaceId: UUID,
|
||||
isInsideTmux: Bool
|
||||
) {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
||||
tab.updatePanelTmuxState(panelId: surfaceId, isInsideTmux: isInsideTmux)
|
||||
}
|
||||
|
||||
private func normalizeDirectory(_ directory: String) -> String {
|
||||
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return directory }
|
||||
|
|
|
|||
|
|
@ -440,10 +440,8 @@ class TerminalController {
|
|||
private let queue = DispatchQueue(label: "com.cmux.socket-fast-path")
|
||||
private var lastReportedDirectories: [SocketSurfaceKey: String] = [:]
|
||||
private var lastReportedShellStates: [SocketSurfaceKey: Workspace.PanelShellActivityState] = [:]
|
||||
private var lastReportedTmuxStates: [SocketSurfaceKey: Bool] = [:]
|
||||
private let maxTrackedDirectories = 4096
|
||||
private let maxTrackedShellStates = 4096
|
||||
private let maxTrackedTmuxStates = 4096
|
||||
|
||||
func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool {
|
||||
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
|
||||
|
|
@ -476,24 +474,6 @@ class TerminalController {
|
|||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func shouldPublishTmuxState(
|
||||
workspaceId: UUID,
|
||||
panelId: UUID,
|
||||
isInsideTmux: Bool
|
||||
) -> Bool {
|
||||
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
|
||||
return queue.sync {
|
||||
if lastReportedTmuxStates[key] == isInsideTmux {
|
||||
return false
|
||||
}
|
||||
if lastReportedTmuxStates.count >= maxTrackedTmuxStates {
|
||||
lastReportedTmuxStates.removeAll(keepingCapacity: true)
|
||||
}
|
||||
lastReportedTmuxStates[key] = isInsideTmux
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let socketFastPathState = SocketFastPathState()
|
||||
|
|
@ -520,42 +500,6 @@ class TerminalController {
|
|||
return trimmed
|
||||
}
|
||||
|
||||
nonisolated static func normalizedReportedTTYName(_ raw: String?) -> String? {
|
||||
guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty,
|
||||
trimmed != "not a tty" else {
|
||||
return nil
|
||||
}
|
||||
let components = trimmed.split(separator: "/")
|
||||
if let last = components.last, !last.isEmpty {
|
||||
return String(last)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
static func resolvePanelIdByTTY(_ ttyName: String?, in tab: Tab) -> UUID? {
|
||||
guard let ttyName = normalizedReportedTTYName(ttyName) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let focusedPanelId = tab.focusedPanelId,
|
||||
normalizedReportedTTYName(tab.surfaceTTYNames[focusedPanelId]) == ttyName,
|
||||
tab.panels[focusedPanelId] != nil {
|
||||
return focusedPanelId
|
||||
}
|
||||
|
||||
let matches = tab.surfaceTTYNames.compactMap { (panelId, candidateTTY) -> UUID? in
|
||||
guard tab.panels[panelId] != nil,
|
||||
normalizedReportedTTYName(candidateTTY) == ttyName else {
|
||||
return nil
|
||||
}
|
||||
return panelId
|
||||
}
|
||||
|
||||
guard matches.count == 1 else { return nil }
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
@ -601,19 +545,6 @@ class TerminalController {
|
|||
}
|
||||
}
|
||||
|
||||
nonisolated static func parseReportedTmuxState(
|
||||
_ rawState: String
|
||||
) -> Bool? {
|
||||
switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "inside", "tmux", "active", "1", "true", "yes":
|
||||
return true
|
||||
case "outside", "none", "inactive", "0", "false", "no", "clear":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Update which window's TabManager receives socket commands.
|
||||
/// This is used when the user switches between multiple terminal windows.
|
||||
func setActiveTabManager(_ tabManager: TabManager?) {
|
||||
|
|
@ -1873,9 +1804,6 @@ class TerminalController {
|
|||
case "report_shell_state":
|
||||
return reportShellState(args)
|
||||
|
||||
case "report_tmux_state":
|
||||
return reportTmuxState(args)
|
||||
|
||||
case "report_pwd":
|
||||
return reportPwd(args)
|
||||
|
||||
|
|
@ -11189,7 +11117,6 @@ class TerminalController {
|
|||
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
|
||||
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
|
||||
report_shell_state <prompt|running> [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command
|
||||
report_tmux_state <inside|outside> [--tab=X] [--panel=Y] [--tty=Z] - Report whether the shell is currently inside tmux
|
||||
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
|
||||
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
|
||||
sidebar_state [--tab=X] - Dump sidebar metadata
|
||||
|
|
@ -15240,80 +15167,6 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func reportTmuxState(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard let rawState = parsed.positional.first, !rawState.isEmpty else {
|
||||
return "ERROR: Missing tmux state — usage: report_tmux_state <inside|outside> [--tab=X] [--panel=Y] [--tty=Z]"
|
||||
}
|
||||
guard let isInsideTmux = Self.parseReportedTmuxState(rawState) else {
|
||||
return "ERROR: Invalid tmux state '\(rawState)' — expected inside or outside"
|
||||
}
|
||||
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let fallbackPanelId: UUID?
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
return "ERROR: Missing panel id — usage: report_tmux_state <inside|outside> [--tab=X] [--panel=Y] [--tty=Z]"
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
return "ERROR: Invalid panel id '\(panelArg)'"
|
||||
}
|
||||
fallbackPanelId = parsedId
|
||||
} else {
|
||||
fallbackPanelId = nil
|
||||
}
|
||||
let reportedTTYName = Self.normalizedReportedTTYName(parsed.options["tty"])
|
||||
|
||||
if let scope = Self.explicitSocketScope(options: parsed.options) {
|
||||
guard Self.socketFastPathState.shouldPublishTmuxState(
|
||||
workspaceId: scope.workspaceId,
|
||||
panelId: scope.panelId,
|
||||
isInsideTmux: isInsideTmux
|
||||
) else {
|
||||
return "OK"
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return }
|
||||
tabManager.updateSurfaceTmuxState(
|
||||
tabId: scope.workspaceId,
|
||||
surfaceId: scope.panelId,
|
||||
isInsideTmux: isInsideTmux
|
||||
)
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
||||
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options)
|
||||
guard let targetTabId = tabResolution.tabId else {
|
||||
return tabResolution.error ?? "ERROR: No tab selected"
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
let tab = self.tabForSidebarMutation(id: targetTabId) else {
|
||||
return
|
||||
}
|
||||
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
|
||||
let surfaceId = fallbackPanelId
|
||||
?? Self.resolvePanelIdByTTY(reportedTTYName, in: tab)
|
||||
?? tab.focusedPanelId
|
||||
guard let surfaceId, validSurfaceIds.contains(surfaceId) else { return }
|
||||
guard Self.socketFastPathState.shouldPublishTmuxState(
|
||||
workspaceId: tab.id,
|
||||
panelId: surfaceId,
|
||||
isInsideTmux: isInsideTmux
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
tab.updatePanelTmuxState(panelId: surfaceId, isInsideTmux: isInsideTmux)
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
||||
private func clearPorts(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
var result = "OK"
|
||||
|
|
|
|||
|
|
@ -420,24 +420,6 @@ enum TerminalSSHSessionDetector {
|
|||
)
|
||||
}
|
||||
|
||||
static func isInsideTmux(forTTY ttyName: String) -> Bool {
|
||||
let normalizedTTY = normalizeTTYName(ttyName)
|
||||
guard !normalizedTTY.isEmpty else { return false }
|
||||
return isInsideTmuxForTesting(
|
||||
ttyName: normalizedTTY,
|
||||
processes: processSnapshots(forTTY: normalizedTTY)
|
||||
)
|
||||
}
|
||||
|
||||
static func isInsideTmuxForTesting(
|
||||
ttyName: String,
|
||||
processes: [ProcessSnapshot]
|
||||
) -> Bool {
|
||||
let normalizedTTY = normalizeTTYName(ttyName)
|
||||
guard !normalizedTTY.isEmpty else { return false }
|
||||
return processes.contains { isForegroundProcess($0, ttyName: normalizedTTY, executableName: "tmux") }
|
||||
}
|
||||
|
||||
static func detectForTesting(
|
||||
ttyName: String,
|
||||
processes: [ProcessSnapshot],
|
||||
|
|
@ -492,16 +474,8 @@ enum TerminalSSHSessionDetector {
|
|||
}
|
||||
|
||||
private static func isForegroundSSHProcess(_ process: ProcessSnapshot, ttyName: String) -> Bool {
|
||||
isForegroundProcess(process, ttyName: ttyName, executableName: "ssh")
|
||||
}
|
||||
|
||||
private static func isForegroundProcess(
|
||||
_ process: ProcessSnapshot,
|
||||
ttyName: String,
|
||||
executableName: String
|
||||
) -> Bool {
|
||||
normalizeTTYName(process.tty) == normalizeTTYName(ttyName) &&
|
||||
process.executableName == executableName &&
|
||||
process.executableName == "ssh" &&
|
||||
process.pgid > 0 &&
|
||||
process.tpgid > 0 &&
|
||||
process.pgid == process.tpgid
|
||||
|
|
|
|||
|
|
@ -5578,7 +5578,6 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}()
|
||||
nonisolated(unsafe) static var runSSHControlMasterCommandOverrideForTesting: (([String]) -> Void)?
|
||||
private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:]
|
||||
private var panelTmuxStates: [UUID: Bool] = [:]
|
||||
/// PIDs associated with agent status entries (e.g. claude_code), keyed by status key.
|
||||
/// Used for stale-session detection: if the PID is dead, the status entry is cleared.
|
||||
var agentPIDs: [String: pid_t] = [:]
|
||||
|
|
@ -6435,24 +6434,6 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
#endif
|
||||
}
|
||||
|
||||
func updatePanelTmuxState(panelId: UUID, isInsideTmux: Bool) {
|
||||
guard panels[panelId] != nil else { return }
|
||||
let previousState = panelTmuxStates[panelId] ?? false
|
||||
guard previousState != isInsideTmux else { return }
|
||||
panelTmuxStates[panelId] = isInsideTmux
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.tmuxState workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) from=\(previousState ? "inside" : "outside") " +
|
||||
"to=\(isInsideTmux ? "inside" : "outside")"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func panelIsInsideTmux(panelId: UUID) -> Bool {
|
||||
panelTmuxStates[panelId] ?? false
|
||||
}
|
||||
|
||||
func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool {
|
||||
Self.resolveCloseConfirmation(
|
||||
shellActivityState: panelShellActivityStates[panelId],
|
||||
|
|
@ -6652,7 +6633,6 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
|
||||
panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) }
|
||||
panelTmuxStates = panelTmuxStates.filter { validSurfaceIds.contains($0.key) }
|
||||
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
|
||||
recomputeListeningPorts()
|
||||
}
|
||||
|
|
@ -10417,7 +10397,6 @@ extension Workspace: BonsplitDelegate {
|
|||
manualUnreadMarkedAt.removeValue(forKey: panelId)
|
||||
panelSubscriptions.removeValue(forKey: panelId)
|
||||
panelShellActivityStates.removeValue(forKey: panelId)
|
||||
panelTmuxStates.removeValue(forKey: panelId)
|
||||
surfaceTTYNames.removeValue(forKey: panelId)
|
||||
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
||||
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
||||
|
|
@ -10570,7 +10549,6 @@ extension Workspace: BonsplitDelegate {
|
|||
manualUnreadPanelIds.remove(panelId)
|
||||
panelSubscriptions.removeValue(forKey: panelId)
|
||||
panelShellActivityStates.removeValue(forKey: panelId)
|
||||
panelTmuxStates.removeValue(forKey: panelId)
|
||||
surfaceTTYNames.removeValue(forKey: panelId)
|
||||
surfaceListeningPorts.removeValue(forKey: panelId)
|
||||
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
||||
|
|
|
|||
|
|
@ -2495,150 +2495,6 @@ final class GhosttyMouseFocusTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
func testUserConfigDefinesShiftEnterBindingDetectsDirectBinding() throws {
|
||||
try withTempConfig("keybind = shift+enter=text:\\x0a\n") { path in
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [path])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testUserConfigDefinesShiftEnterBindingDetectsUnbindInIncludedFile() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-test-shift-enter-unbind-\(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 = shift+enter=unbind\n"
|
||||
.write(to: included, atomically: true, encoding: .utf8)
|
||||
|
||||
let main = dir.appendingPathComponent("config")
|
||||
try "config-file = \(included.path)\n"
|
||||
.write(to: main, atomically: true, encoding: .utf8)
|
||||
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [main.path])
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
GhosttyApp.userConfigDefinesShiftEnterBinding(configPaths: [path])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testShouldRemapShiftEnterForTmuxOnlyWhenScopedToTmuxWithoutOverrides() {
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldRemapShiftEnterForTmux(
|
||||
keyCode: 36,
|
||||
modifierFlags: [.shift],
|
||||
isInsideTmux: true,
|
||||
userConfigDefinesShiftEnterBinding: false,
|
||||
hasMarkedText: false
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldRemapShiftEnterForTmux(
|
||||
keyCode: 36,
|
||||
modifierFlags: [.shift],
|
||||
isInsideTmux: false,
|
||||
userConfigDefinesShiftEnterBinding: false,
|
||||
hasMarkedText: false
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldRemapShiftEnterForTmux(
|
||||
keyCode: 36,
|
||||
modifierFlags: [.shift],
|
||||
isInsideTmux: true,
|
||||
userConfigDefinesShiftEnterBinding: true,
|
||||
hasMarkedText: false
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldRemapShiftEnterForTmux(
|
||||
keyCode: 36,
|
||||
modifierFlags: [.shift, .command],
|
||||
isInsideTmux: true,
|
||||
userConfigDefinesShiftEnterBinding: false,
|
||||
hasMarkedText: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testForegroundTmuxProcessOnTTYIsDetected() {
|
||||
let processes = [
|
||||
TerminalSSHSessionDetector.ProcessSnapshot(
|
||||
pid: 47486,
|
||||
pgid: 47486,
|
||||
tpgid: 48365,
|
||||
tty: "ttys089",
|
||||
executableName: "login"
|
||||
),
|
||||
TerminalSSHSessionDetector.ProcessSnapshot(
|
||||
pid: 47487,
|
||||
pgid: 47487,
|
||||
tpgid: 48365,
|
||||
tty: "ttys089",
|
||||
executableName: "zsh"
|
||||
),
|
||||
TerminalSSHSessionDetector.ProcessSnapshot(
|
||||
pid: 48365,
|
||||
pgid: 48365,
|
||||
tpgid: 48365,
|
||||
tty: "ttys089",
|
||||
executableName: "tmux"
|
||||
),
|
||||
]
|
||||
|
||||
XCTAssertTrue(
|
||||
TerminalSSHSessionDetector.isInsideTmuxForTesting(
|
||||
ttyName: "ttys089",
|
||||
processes: processes
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
TerminalSSHSessionDetector.isInsideTmuxForTesting(
|
||||
ttyName: "ttys090",
|
||||
processes: processes
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
TerminalSSHSessionDetector.isInsideTmuxForTesting(
|
||||
ttyName: "ttys089",
|
||||
processes: processes.filter { $0.executableName != "tmux" }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testLoadedCJKScanPathsSkipsReleaseAppSupportWhenTaggedConfigExists() throws {
|
||||
let appSupport = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-test-cjk-app-support-\(UUID().uuidString)")
|
||||
|
|
@ -3027,123 +2883,6 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
|
|||
XCTAssertEqual(output, "report_tty ttys999 --tab=11111111-1111-1111-1111-111111111111")
|
||||
}
|
||||
|
||||
func testShellIntegrationReportsTmuxStatePayload() throws {
|
||||
let output = try runInteractiveZsh(
|
||||
cmuxLoadGhosttyIntegration: false,
|
||||
cmuxLoadShellIntegration: true,
|
||||
command: """
|
||||
_CMUX_TTY_NAME=ttys999
|
||||
print -r -- "$(_cmux_report_tmux_state_payload)"
|
||||
""",
|
||||
extraEnvironment: [
|
||||
"TMUX": "/tmp/tmux-current,123,0",
|
||||
"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 --tty=ttys999"
|
||||
)
|
||||
}
|
||||
|
||||
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_TTY_NAME=ttys999
|
||||
_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 --tty=ttys999
|
||||
report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
func testShellIntegrationPrecmdReportsTmuxStateWithoutPanelScope() throws {
|
||||
let fileManager = FileManager.default
|
||||
let root = fileManager.temporaryDirectory
|
||||
.appendingPathComponent("cmux-zsh-precmd-tmux-state-\(UUID().uuidString)")
|
||||
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: root) }
|
||||
|
||||
let socketPath = root.appendingPathComponent("cmux.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_pid=$!
|
||||
sleep 0.1
|
||||
functions[_cmux_send_bg]='print -r -- "$1"'
|
||||
unset CMUX_PANEL_ID
|
||||
_CMUX_TTY_NAME=ttys999
|
||||
_CMUX_TTY_REPORTED=0
|
||||
_CMUX_TMUX_STATE_SIGNATURE_LAST=""
|
||||
_cmux_precmd
|
||||
kill $server_pid >/dev/null 2>&1
|
||||
wait $server_pid >/dev/null 2>&1
|
||||
""",
|
||||
extraEnvironment: [
|
||||
"TMUX": "/tmp/tmux-current,123,0",
|
||||
"CMUX_SOCKET_PATH": socketPath,
|
||||
"CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111",
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
output,
|
||||
"""
|
||||
report_tmux_state inside --tab=11111111-1111-1111-1111-111111111111 --tty=ttys999
|
||||
report_tty ttys999 --tab=11111111-1111-1111-1111-111111111111
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String {
|
||||
try runInteractiveZsh(
|
||||
cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration,
|
||||
|
|
|
|||
|
|
@ -254,41 +254,6 @@ final class TerminalControllerSocketSecurityTests: XCTestCase {
|
|||
XCTAssertTrue(manager.tabs.contains(where: { $0.id == pinnedWorkspace.id }))
|
||||
}
|
||||
|
||||
func testReportTmuxStateResolvesPanelByTTY() throws {
|
||||
let socketPath = makeSocketPath("tmux-tty")
|
||||
let manager = TabManager()
|
||||
let workspace = manager.addWorkspace(select: true)
|
||||
|
||||
guard let focusedPanelId = workspace.focusedPanelId else {
|
||||
XCTFail("Expected selected workspace with a focused panel")
|
||||
return
|
||||
}
|
||||
guard let targetPanel = workspace.newTerminalSplit(from: focusedPanelId, orientation: .horizontal) else {
|
||||
XCTFail("Expected split panel to be created")
|
||||
return
|
||||
}
|
||||
workspace.focusPanel(focusedPanelId)
|
||||
workspace.surfaceTTYNames[targetPanel.id] = "/dev/ttys777"
|
||||
|
||||
TerminalController.shared.start(
|
||||
tabManager: manager,
|
||||
socketPath: socketPath,
|
||||
accessMode: .allowAll
|
||||
)
|
||||
try waitForSocket(at: socketPath)
|
||||
|
||||
let responses = try sendCommands(
|
||||
["report_tmux_state inside --tab=\(workspace.id.uuidString) --tty=ttys777"],
|
||||
to: socketPath
|
||||
)
|
||||
XCTAssertEqual(responses, ["OK"])
|
||||
|
||||
try waitForCondition("tmux state routed by tty") {
|
||||
workspace.panelIsInsideTmux(panelId: targetPanel.id)
|
||||
}
|
||||
XCTAssertFalse(workspace.panelIsInsideTmux(panelId: focusedPanelId))
|
||||
}
|
||||
|
||||
private func waitForSocket(at path: String, timeout: TimeInterval = 5.0) throws {
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
|
|
@ -303,22 +268,6 @@ final class TerminalControllerSocketSecurityTests: XCTestCase {
|
|||
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT))
|
||||
}
|
||||
|
||||
private func waitForCondition(
|
||||
_ description: String,
|
||||
timeout: TimeInterval = 5.0,
|
||||
condition: @escaping () -> Bool
|
||||
) throws {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if condition() {
|
||||
return
|
||||
}
|
||||
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
||||
}
|
||||
XCTFail("Timed out waiting for \(description)")
|
||||
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT))
|
||||
}
|
||||
|
||||
private func socketMode(at path: String) throws -> UInt16 {
|
||||
var fileInfo = stat()
|
||||
guard lstat(path, &fileInfo) == 0 else {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue