Merge pull request #2355 from manaflow-ai/issue-2352-shift-enter-tmux
Map Shift+Enter to raw newline in Ghostty
This commit is contained in:
commit
2d2d8da1c7
9 changed files with 833 additions and 26 deletions
|
|
@ -61,6 +61,7 @@ _CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}"
|
||||||
|
|
||||||
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
|
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
|
||||||
_CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}"
|
_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_NAME="${_CMUX_TTY_NAME:-}"
|
||||||
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
|
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
|
||||||
_CMUX_TMUX_PUSH_SIGNATURE="${_CMUX_TMUX_PUSH_SIGNATURE:-}"
|
_CMUX_TMUX_PUSH_SIGNATURE="${_CMUX_TMUX_PUSH_SIGNATURE:-}"
|
||||||
|
|
@ -264,6 +265,47 @@ _cmux_report_shell_activity_state() {
|
||||||
} >/dev/null 2>&1 & disown
|
} >/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() {
|
_cmux_ports_kick() {
|
||||||
# Lightweight: just tell the app to run a batched scan for this panel.
|
# 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.
|
# The app coalesces kicks across all panels and runs a single ps+lsof.
|
||||||
|
|
@ -501,7 +543,6 @@ _cmux_preexec_command() {
|
||||||
|
|
||||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
|
||||||
|
|
||||||
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
||||||
local t
|
local t
|
||||||
|
|
@ -510,8 +551,12 @@ _cmux_preexec_command() {
|
||||||
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$CMUX_PANEL_ID" ]]; then
|
||||||
_cmux_report_shell_activity_state running
|
_cmux_report_shell_activity_state running
|
||||||
|
fi
|
||||||
|
_cmux_report_tmux_state
|
||||||
_cmux_report_tty_once
|
_cmux_report_tty_once
|
||||||
|
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||||
_cmux_ports_kick
|
_cmux_ports_kick
|
||||||
_cmux_stop_pr_poll_loop
|
_cmux_stop_pr_poll_loop
|
||||||
}
|
}
|
||||||
|
|
@ -525,8 +570,21 @@ _cmux_prompt_command() {
|
||||||
|
|
||||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||||
[[ -n "$CMUX_PANEL_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
|
_cmux_report_shell_activity_state prompt
|
||||||
|
fi
|
||||||
|
_cmux_report_tmux_state
|
||||||
|
_cmux_report_tty_once
|
||||||
|
|
||||||
|
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||||
|
|
||||||
local now=$SECONDS
|
local now=$SECONDS
|
||||||
local pwd="$PWD"
|
local pwd="$PWD"
|
||||||
|
|
@ -543,16 +601,6 @@ _cmux_prompt_command() {
|
||||||
fi
|
fi
|
||||||
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.
|
# CWD: keep the app in sync with the actual shell directory.
|
||||||
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
|
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
|
||||||
_CMUX_PWD_LAST_PWD="$pwd"
|
_CMUX_PWD_LAST_PWD="$pwd"
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20
|
||||||
typeset -g _CMUX_PORTS_LAST_RUN=0
|
typeset -g _CMUX_PORTS_LAST_RUN=0
|
||||||
typeset -g _CMUX_CMD_START=0
|
typeset -g _CMUX_CMD_START=0
|
||||||
typeset -g _CMUX_SHELL_ACTIVITY_LAST=""
|
typeset -g _CMUX_SHELL_ACTIVITY_LAST=""
|
||||||
|
typeset -g _CMUX_TMUX_STATE_SIGNATURE_LAST=""
|
||||||
typeset -g _CMUX_TTY_NAME=""
|
typeset -g _CMUX_TTY_NAME=""
|
||||||
typeset -g _CMUX_TTY_REPORTED=0
|
typeset -g _CMUX_TTY_REPORTED=0
|
||||||
typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0
|
typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0
|
||||||
|
|
@ -369,6 +370,45 @@ _cmux_report_shell_activity_state() {
|
||||||
_cmux_send_bg "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
_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() {
|
_cmux_ports_kick() {
|
||||||
# Lightweight: just tell the app to run a batched scan for this panel.
|
# 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.
|
# The app coalesces kicks across all panels and runs a single ps+lsof.
|
||||||
|
|
@ -668,6 +708,7 @@ _cmux_preexec() {
|
||||||
|
|
||||||
_CMUX_CMD_START=$EPOCHSECONDS
|
_CMUX_CMD_START=$EPOCHSECONDS
|
||||||
_cmux_report_shell_activity_state running
|
_cmux_report_shell_activity_state running
|
||||||
|
_cmux_report_tmux_state
|
||||||
|
|
||||||
# Heuristic: commands that may change git branch/dirty state without changing $PWD.
|
# Heuristic: commands that may change git branch/dirty state without changing $PWD.
|
||||||
local cmd="${1## }"
|
local cmd="${1## }"
|
||||||
|
|
@ -691,8 +732,6 @@ _cmux_precmd() {
|
||||||
# Skip if socket doesn't exist yet
|
# Skip if socket doesn't exist yet
|
||||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||||
[[ -n "$CMUX_TAB_ID" ]] || 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.
|
# Handle cases where Ghostty integration initializes after this file.
|
||||||
(( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) || _cmux_patch_ghostty_semantic_redraw
|
(( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) || _cmux_patch_ghostty_semantic_redraw
|
||||||
|
|
@ -704,8 +743,14 @@ _cmux_precmd() {
|
||||||
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$CMUX_PANEL_ID" ]]; then
|
||||||
|
_cmux_report_shell_activity_state prompt
|
||||||
|
fi
|
||||||
|
_cmux_report_tmux_state
|
||||||
_cmux_report_tty_once
|
_cmux_report_tty_once
|
||||||
|
|
||||||
|
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||||
|
|
||||||
local now=$EPOCHSECONDS
|
local now=$EPOCHSECONDS
|
||||||
local pwd="$PWD"
|
local pwd="$PWD"
|
||||||
local cmd_start="$_CMUX_CMD_START"
|
local cmd_start="$_CMUX_CMD_START"
|
||||||
|
|
|
||||||
|
|
@ -949,6 +949,7 @@ class GhosttyApp {
|
||||||
private var backgroundEventCounter: UInt64 = 0
|
private var backgroundEventCounter: UInt64 = 0
|
||||||
private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped
|
private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped
|
||||||
private var defaultBackgroundScopeSource: String = "initialize"
|
private var defaultBackgroundScopeSource: String = "initialize"
|
||||||
|
private(set) var userConfigDefinesShiftEnterBinding = false
|
||||||
private var lastAppearanceColorScheme: GhosttyConfig.ColorSchemePreference?
|
private var lastAppearanceColorScheme: GhosttyConfig.ColorSchemePreference?
|
||||||
private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher =
|
private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher =
|
||||||
// Theme chrome should track terminal theme changes in the same frame.
|
// Theme chrome should track terminal theme changes in the same frame.
|
||||||
|
|
@ -1387,6 +1388,7 @@ class GhosttyApp {
|
||||||
loadLegacyGhosttyConfigIfNeeded(config)
|
loadLegacyGhosttyConfigIfNeeded(config)
|
||||||
ghostty_config_load_recursive_files(config)
|
ghostty_config_load_recursive_files(config)
|
||||||
loadCmuxAppSupportGhosttyConfigIfNeeded(config)
|
loadCmuxAppSupportGhosttyConfigIfNeeded(config)
|
||||||
|
userConfigDefinesShiftEnterBinding = Self.userConfigDefinesShiftEnterBinding()
|
||||||
loadCopyOnSelectOverride(config)
|
loadCopyOnSelectOverride(config)
|
||||||
loadCJKFontFallbackIfNeeded(config)
|
loadCJKFontFallbackIfNeeded(config)
|
||||||
ghostty_config_finalize(config)
|
ghostty_config_finalize(config)
|
||||||
|
|
@ -1583,6 +1585,12 @@ class GhosttyApp {
|
||||||
) != nil
|
) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func userConfigDefinesShiftEnterBinding(
|
||||||
|
configPaths: [String] = loadedCJKScanPaths()
|
||||||
|
) -> Bool {
|
||||||
|
userShiftEnterConfigSummary(configPaths: configPaths).containsExplicitShiftEnterDirective
|
||||||
|
}
|
||||||
|
|
||||||
private static func configuredCTFont(
|
private static func configuredCTFont(
|
||||||
named name: String,
|
named name: String,
|
||||||
size: CGFloat = 12
|
size: CGFloat = 12
|
||||||
|
|
@ -1660,6 +1668,55 @@ class GhosttyApp {
|
||||||
return summary
|
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
|
/// Returns the top-level config paths that cmux will actually load before
|
||||||
/// recursive `config-file` processing.
|
/// recursive `config-file` processing.
|
||||||
static func loadedCJKScanPaths(
|
static func loadedCJKScanPaths(
|
||||||
|
|
@ -1744,6 +1801,37 @@ 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(
|
private static func parsedConfigEntry(
|
||||||
from rawLine: String
|
from rawLine: String
|
||||||
) -> (key: String, value: String?)? {
|
) -> (key: String, value: String?)? {
|
||||||
|
|
@ -1796,6 +1884,63 @@ class GhosttyApp {
|
||||||
recursiveConfigPaths.append(absolute)
|
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(
|
static func shouldLoadLegacyGhosttyConfig(
|
||||||
newConfigFileSize: Int?,
|
newConfigFileSize: Int?,
|
||||||
legacyConfigFileSize: Int?
|
legacyConfigFileSize: Int?
|
||||||
|
|
@ -5613,16 +5758,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Check if this event matches a Ghostty keybinding.
|
// Check if this event matches a Ghostty keybinding.
|
||||||
let bindingFlags: ghostty_binding_flags_e? = {
|
let bindingFlags = ghosttyBindingFlags(for: event, surface: surface)
|
||||||
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 {
|
if let bindingFlags {
|
||||||
let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0
|
let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0
|
||||||
|
|
@ -5777,6 +5913,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0
|
keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0
|
||||||
#endif
|
#endif
|
||||||
|
if shouldRemapShiftEnterForTmux(event: event, surface: surface) {
|
||||||
|
terminalSurface?.sendText("\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
recordKeyLatency(path: "keyDown", event: event)
|
recordKeyLatency(path: "keyDown", event: event)
|
||||||
#endif
|
#endif
|
||||||
|
|
@ -6124,6 +6264,64 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||||
return ghostty_surface_key(surface, keyEvent)
|
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
|
#if DEBUG
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func sendTimedGhosttyKey(
|
private func sendTimedGhosttyKey(
|
||||||
|
|
|
||||||
|
|
@ -2598,6 +2598,15 @@ class TabManager: ObservableObject {
|
||||||
tab.updatePanelShellActivityState(panelId: surfaceId, state: state)
|
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 {
|
private func normalizeDirectory(_ directory: String) -> String {
|
||||||
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return directory }
|
guard !trimmed.isEmpty else { return directory }
|
||||||
|
|
|
||||||
|
|
@ -440,8 +440,10 @@ class TerminalController {
|
||||||
private let queue = DispatchQueue(label: "com.cmux.socket-fast-path")
|
private let queue = DispatchQueue(label: "com.cmux.socket-fast-path")
|
||||||
private var lastReportedDirectories: [SocketSurfaceKey: String] = [:]
|
private var lastReportedDirectories: [SocketSurfaceKey: String] = [:]
|
||||||
private var lastReportedShellStates: [SocketSurfaceKey: Workspace.PanelShellActivityState] = [:]
|
private var lastReportedShellStates: [SocketSurfaceKey: Workspace.PanelShellActivityState] = [:]
|
||||||
|
private var lastReportedTmuxStates: [SocketSurfaceKey: Bool] = [:]
|
||||||
private let maxTrackedDirectories = 4096
|
private let maxTrackedDirectories = 4096
|
||||||
private let maxTrackedShellStates = 4096
|
private let maxTrackedShellStates = 4096
|
||||||
|
private let maxTrackedTmuxStates = 4096
|
||||||
|
|
||||||
func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool {
|
func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool {
|
||||||
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
|
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
|
||||||
|
|
@ -474,6 +476,24 @@ class TerminalController {
|
||||||
return true
|
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()
|
private static let socketFastPathState = SocketFastPathState()
|
||||||
|
|
@ -500,6 +520,42 @@ class TerminalController {
|
||||||
return trimmed
|
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? {
|
nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? {
|
||||||
guard let raw else { return nil }
|
guard let raw else { return nil }
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
@ -545,6 +601,19 @@ 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.
|
/// Update which window's TabManager receives socket commands.
|
||||||
/// This is used when the user switches between multiple terminal windows.
|
/// This is used when the user switches between multiple terminal windows.
|
||||||
func setActiveTabManager(_ tabManager: TabManager?) {
|
func setActiveTabManager(_ tabManager: TabManager?) {
|
||||||
|
|
@ -1804,6 +1873,9 @@ class TerminalController {
|
||||||
case "report_shell_state":
|
case "report_shell_state":
|
||||||
return reportShellState(args)
|
return reportShellState(args)
|
||||||
|
|
||||||
|
case "report_tmux_state":
|
||||||
|
return reportTmuxState(args)
|
||||||
|
|
||||||
case "report_pwd":
|
case "report_pwd":
|
||||||
return reportPwd(args)
|
return reportPwd(args)
|
||||||
|
|
||||||
|
|
@ -11117,6 +11189,7 @@ class TerminalController {
|
||||||
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
|
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
|
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_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
|
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
|
||||||
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
|
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
|
||||||
sidebar_state [--tab=X] - Dump sidebar metadata
|
sidebar_state [--tab=X] - Dump sidebar metadata
|
||||||
|
|
@ -15167,6 +15240,80 @@ class TerminalController {
|
||||||
return result
|
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 {
|
private func clearPorts(_ args: String) -> String {
|
||||||
let parsed = parseOptions(args)
|
let parsed = parseOptions(args)
|
||||||
var result = "OK"
|
var result = "OK"
|
||||||
|
|
|
||||||
|
|
@ -420,6 +420,24 @@ 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(
|
static func detectForTesting(
|
||||||
ttyName: String,
|
ttyName: String,
|
||||||
processes: [ProcessSnapshot],
|
processes: [ProcessSnapshot],
|
||||||
|
|
@ -474,8 +492,16 @@ enum TerminalSSHSessionDetector {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isForegroundSSHProcess(_ process: ProcessSnapshot, ttyName: String) -> Bool {
|
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) &&
|
normalizeTTYName(process.tty) == normalizeTTYName(ttyName) &&
|
||||||
process.executableName == "ssh" &&
|
process.executableName == executableName &&
|
||||||
process.pgid > 0 &&
|
process.pgid > 0 &&
|
||||||
process.tpgid > 0 &&
|
process.tpgid > 0 &&
|
||||||
process.pgid == process.tpgid
|
process.pgid == process.tpgid
|
||||||
|
|
|
||||||
|
|
@ -5578,6 +5578,7 @@ final class Workspace: Identifiable, ObservableObject {
|
||||||
}()
|
}()
|
||||||
nonisolated(unsafe) static var runSSHControlMasterCommandOverrideForTesting: (([String]) -> Void)?
|
nonisolated(unsafe) static var runSSHControlMasterCommandOverrideForTesting: (([String]) -> Void)?
|
||||||
private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:]
|
private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:]
|
||||||
|
private var panelTmuxStates: [UUID: Bool] = [:]
|
||||||
/// PIDs associated with agent status entries (e.g. claude_code), keyed by status key.
|
/// 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.
|
/// Used for stale-session detection: if the PID is dead, the status entry is cleared.
|
||||||
var agentPIDs: [String: pid_t] = [:]
|
var agentPIDs: [String: pid_t] = [:]
|
||||||
|
|
@ -6434,6 +6435,24 @@ final class Workspace: Identifiable, ObservableObject {
|
||||||
#endif
|
#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 {
|
func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool {
|
||||||
Self.resolveCloseConfirmation(
|
Self.resolveCloseConfirmation(
|
||||||
shellActivityState: panelShellActivityStates[panelId],
|
shellActivityState: panelShellActivityStates[panelId],
|
||||||
|
|
@ -6633,6 +6652,7 @@ final class Workspace: Identifiable, ObservableObject {
|
||||||
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
|
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
|
||||||
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
|
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
|
||||||
panelShellActivityStates = panelShellActivityStates.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) }
|
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
|
||||||
recomputeListeningPorts()
|
recomputeListeningPorts()
|
||||||
}
|
}
|
||||||
|
|
@ -10397,6 +10417,7 @@ extension Workspace: BonsplitDelegate {
|
||||||
manualUnreadMarkedAt.removeValue(forKey: panelId)
|
manualUnreadMarkedAt.removeValue(forKey: panelId)
|
||||||
panelSubscriptions.removeValue(forKey: panelId)
|
panelSubscriptions.removeValue(forKey: panelId)
|
||||||
panelShellActivityStates.removeValue(forKey: panelId)
|
panelShellActivityStates.removeValue(forKey: panelId)
|
||||||
|
panelTmuxStates.removeValue(forKey: panelId)
|
||||||
surfaceTTYNames.removeValue(forKey: panelId)
|
surfaceTTYNames.removeValue(forKey: panelId)
|
||||||
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
||||||
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
||||||
|
|
@ -10549,6 +10570,7 @@ extension Workspace: BonsplitDelegate {
|
||||||
manualUnreadPanelIds.remove(panelId)
|
manualUnreadPanelIds.remove(panelId)
|
||||||
panelSubscriptions.removeValue(forKey: panelId)
|
panelSubscriptions.removeValue(forKey: panelId)
|
||||||
panelShellActivityStates.removeValue(forKey: panelId)
|
panelShellActivityStates.removeValue(forKey: panelId)
|
||||||
|
panelTmuxStates.removeValue(forKey: panelId)
|
||||||
surfaceTTYNames.removeValue(forKey: panelId)
|
surfaceTTYNames.removeValue(forKey: panelId)
|
||||||
surfaceListeningPorts.removeValue(forKey: panelId)
|
surfaceListeningPorts.removeValue(forKey: panelId)
|
||||||
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
||||||
|
|
|
||||||
|
|
@ -2495,6 +2495,150 @@ 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 {
|
func testLoadedCJKScanPathsSkipsReleaseAppSupportWhenTaggedConfigExists() throws {
|
||||||
let appSupport = FileManager.default.temporaryDirectory
|
let appSupport = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("cmux-test-cjk-app-support-\(UUID().uuidString)")
|
.appendingPathComponent("cmux-test-cjk-app-support-\(UUID().uuidString)")
|
||||||
|
|
@ -2883,6 +3027,123 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
|
||||||
XCTAssertEqual(output, "report_tty ttys999 --tab=11111111-1111-1111-1111-111111111111")
|
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 {
|
private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String {
|
||||||
try runInteractiveZsh(
|
try runInteractiveZsh(
|
||||||
cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration,
|
cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration,
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,41 @@ final class TerminalControllerSocketSecurityTests: XCTestCase {
|
||||||
XCTAssertTrue(manager.tabs.contains(where: { $0.id == pinnedWorkspace.id }))
|
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 {
|
private func waitForSocket(at path: String, timeout: TimeInterval = 5.0) throws {
|
||||||
let expectation = XCTNSPredicateExpectation(
|
let expectation = XCTNSPredicateExpectation(
|
||||||
predicate: NSPredicate { _, _ in
|
predicate: NSPredicate { _, _ in
|
||||||
|
|
@ -268,6 +303,22 @@ final class TerminalControllerSocketSecurityTests: XCTestCase {
|
||||||
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT))
|
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 {
|
private func socketMode(at path: String) throws -> UInt16 {
|
||||||
var fileInfo = stat()
|
var fileInfo = stat()
|
||||||
guard lstat(path, &fileInfo) == 0 else {
|
guard lstat(path, &fileInfo) == 0 else {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue