fix: honor shell state for close confirmation
This commit is contained in:
parent
cfa7b1d1a6
commit
a99ee15672
5 changed files with 186 additions and 5 deletions
|
|
@ -51,6 +51,7 @@ _CMUX_PR_FORCE="${_CMUX_PR_FORCE:-0}"
|
|||
_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_TTY_NAME="${_CMUX_TTY_NAME:-}"
|
||||
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
|
||||
|
||||
|
|
@ -103,6 +104,19 @@ _cmux_report_tty_once() {
|
|||
} >/dev/null 2>&1 & disown
|
||||
}
|
||||
|
||||
_cmux_report_shell_activity_state() {
|
||||
local state="$1"
|
||||
[[ -n "$state" ]] || return 0
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
[[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0
|
||||
_CMUX_SHELL_ACTIVITY_LAST="$state"
|
||||
{
|
||||
_cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
} >/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.
|
||||
|
|
@ -291,10 +305,33 @@ _cmux_bash_cleanup() {
|
|||
_cmux_stop_pr_poll_loop
|
||||
}
|
||||
|
||||
_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
|
||||
t="$(tty 2>/dev/null || true)"
|
||||
t="${t##*/}"
|
||||
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
||||
fi
|
||||
|
||||
_cmux_report_shell_activity_state running
|
||||
_cmux_report_tty_once
|
||||
_cmux_ports_kick
|
||||
_cmux_stop_pr_poll_loop
|
||||
}
|
||||
|
||||
_cmux_bash_preexec_hook() {
|
||||
_cmux_preexec_command
|
||||
}
|
||||
|
||||
_cmux_prompt_command() {
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
_cmux_report_shell_activity_state prompt
|
||||
|
||||
local now=$SECONDS
|
||||
local pwd="$PWD"
|
||||
|
|
@ -439,6 +476,17 @@ _cmux_install_prompt_command() {
|
|||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then
|
||||
builtin readonly _CMUX_BASH_PS0='${ _cmux_bash_preexec_hook; }'
|
||||
else
|
||||
builtin readonly _CMUX_BASH_PS0='$(_cmux_bash_preexec_hook >/dev/null)'
|
||||
fi
|
||||
if [[ "$PS0" != *"${_CMUX_BASH_PS0}"* ]]; then
|
||||
PS0=$PS0"${_CMUX_BASH_PS0}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure Resources/bin is at the front of PATH, and remove the app's
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20
|
|||
|
||||
typeset -g _CMUX_PORTS_LAST_RUN=0
|
||||
typeset -g _CMUX_CMD_START=0
|
||||
typeset -g _CMUX_SHELL_ACTIVITY_LAST=""
|
||||
typeset -g _CMUX_TTY_NAME=""
|
||||
typeset -g _CMUX_TTY_REPORTED=0
|
||||
|
||||
|
|
@ -110,6 +111,19 @@ _cmux_report_tty_once() {
|
|||
} >/dev/null 2>&1 &!
|
||||
}
|
||||
|
||||
_cmux_report_shell_activity_state() {
|
||||
local state="$1"
|
||||
[[ -n "$state" ]] || return 0
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
[[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0
|
||||
_CMUX_SHELL_ACTIVITY_LAST="$state"
|
||||
{
|
||||
_cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
} >/dev/null 2>&1 &!
|
||||
}
|
||||
|
||||
_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.
|
||||
|
|
@ -361,6 +375,7 @@ _cmux_preexec() {
|
|||
fi
|
||||
|
||||
_CMUX_CMD_START=$EPOCHSECONDS
|
||||
_cmux_report_shell_activity_state running
|
||||
|
||||
# Heuristic: commands that may change git branch/dirty state without changing $PWD.
|
||||
local cmd="${1## }"
|
||||
|
|
@ -384,6 +399,7 @@ _cmux_precmd() {
|
|||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
_cmux_report_shell_activity_state prompt
|
||||
|
||||
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
||||
local t
|
||||
|
|
|
|||
|
|
@ -1404,6 +1404,15 @@ class TabManager: ObservableObject {
|
|||
tab.updatePanelDirectory(panelId: surfaceId, directory: normalized)
|
||||
}
|
||||
|
||||
func updateSurfaceShellActivity(
|
||||
tabId: UUID,
|
||||
surfaceId: UUID,
|
||||
state: Workspace.PanelShellActivityState
|
||||
) {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
||||
tab.updatePanelShellActivityState(panelId: surfaceId, state: state)
|
||||
}
|
||||
|
||||
private func normalizeDirectory(_ directory: String) -> String {
|
||||
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return directory }
|
||||
|
|
@ -1797,7 +1806,7 @@ class TabManager: ObservableObject {
|
|||
guard tab.panels[surfaceId] != nil else { return }
|
||||
|
||||
if let terminalPanel = tab.terminalPanel(for: surfaceId),
|
||||
terminalPanel.needsConfirmClose() {
|
||||
tab.panelNeedsConfirmClose(panelId: surfaceId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
guard confirmClose(
|
||||
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
|
||||
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
|
||||
|
|
|
|||
|
|
@ -320,7 +320,9 @@ class TerminalController {
|
|||
private final class SocketFastPathState: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "com.cmux.socket-fast-path")
|
||||
private var lastReportedDirectories: [SocketSurfaceKey: String] = [:]
|
||||
private var lastReportedShellStates: [SocketSurfaceKey: Workspace.PanelShellActivityState] = [:]
|
||||
private let maxTrackedDirectories = 4096
|
||||
private let maxTrackedShellStates = 4096
|
||||
|
||||
func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool {
|
||||
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
|
||||
|
|
@ -335,6 +337,24 @@ class TerminalController {
|
|||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func shouldPublishShellActivity(
|
||||
workspaceId: UUID,
|
||||
panelId: UUID,
|
||||
state: Workspace.PanelShellActivityState
|
||||
) -> Bool {
|
||||
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
|
||||
return queue.sync {
|
||||
if lastReportedShellStates[key] == state {
|
||||
return false
|
||||
}
|
||||
if lastReportedShellStates.count >= maxTrackedShellStates {
|
||||
lastReportedShellStates.removeAll(keepingCapacity: true)
|
||||
}
|
||||
lastReportedShellStates[key] = state
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let socketFastPathState = SocketFastPathState()
|
||||
|
|
@ -362,6 +382,21 @@ class TerminalController {
|
|||
return trimmed
|
||||
}
|
||||
|
||||
nonisolated static func parseReportedShellActivityState(
|
||||
_ rawState: String
|
||||
) -> Workspace.PanelShellActivityState? {
|
||||
switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "prompt", "idle":
|
||||
return .promptIdle
|
||||
case "running", "busy", "command":
|
||||
return .commandRunning
|
||||
case "unknown", "clear":
|
||||
return .unknown
|
||||
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?) {
|
||||
|
|
@ -1456,6 +1491,9 @@ class TerminalController {
|
|||
case "ports_kick":
|
||||
return portsKick(args)
|
||||
|
||||
case "report_shell_state":
|
||||
return reportShellState(args)
|
||||
|
||||
case "report_pwd":
|
||||
return reportPwd(args)
|
||||
|
||||
|
|
@ -9701,6 +9739,7 @@ class TerminalController {
|
|||
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
|
||||
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_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
|
||||
|
|
@ -13599,6 +13638,72 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func reportShellState(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard let rawState = parsed.positional.first, !rawState.isEmpty else {
|
||||
return "ERROR: Missing shell state — usage: report_shell_state <prompt|running> [--tab=X] [--panel=Y]"
|
||||
}
|
||||
guard let state = Self.parseReportedShellActivityState(rawState) else {
|
||||
return "ERROR: Invalid shell state '\(rawState)' — expected prompt or running"
|
||||
}
|
||||
|
||||
if let scope = Self.explicitSocketScope(options: parsed.options) {
|
||||
guard Self.socketFastPathState.shouldPublishShellActivity(
|
||||
workspaceId: scope.workspaceId,
|
||||
panelId: scope.panelId,
|
||||
state: state
|
||||
) else {
|
||||
return "OK"
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return }
|
||||
tabManager.updateSurfaceShellActivity(tabId: scope.workspaceId, surfaceId: scope.panelId, state: state)
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
||||
guard let tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: report_shell_state <prompt|running> [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
surfaceId = parsedId
|
||||
} else {
|
||||
guard let focused = tab.focusedPanelId else {
|
||||
result = "ERROR: Missing panel id (no focused surface)"
|
||||
return
|
||||
}
|
||||
surfaceId = focused
|
||||
}
|
||||
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
|
||||
tabManager.updateSurfaceShellActivity(tabId: tab.id, surfaceId: surfaceId, state: state)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func clearPorts(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
var result = "OK"
|
||||
|
|
|
|||
|
|
@ -1838,6 +1838,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
|
||||
panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) }
|
||||
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
|
||||
recomputeListeningPorts()
|
||||
}
|
||||
|
|
@ -3526,9 +3527,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
/// Check if any panel needs close confirmation
|
||||
func needsConfirmClose() -> Bool {
|
||||
for panel in panels.values {
|
||||
for (panelId, panel) in panels {
|
||||
if let terminalPanel = panel as? TerminalPanel,
|
||||
terminalPanel.needsConfirmClose() {
|
||||
panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -4617,7 +4618,7 @@ extension Workspace: BonsplitDelegate {
|
|||
// If confirmation is required, Bonsplit will call into this delegate and we must return false.
|
||||
// Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass
|
||||
// this gating on the second pass.
|
||||
if terminalPanel.needsConfirmClose() {
|
||||
if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
|
||||
if pendingCloseConfirmTabIds.contains(tab.id) {
|
||||
return false
|
||||
|
|
@ -4717,6 +4718,7 @@ extension Workspace: BonsplitDelegate {
|
|||
manualUnreadPanelIds.remove(panelId)
|
||||
manualUnreadMarkedAt.removeValue(forKey: panelId)
|
||||
panelSubscriptions.removeValue(forKey: panelId)
|
||||
panelShellActivityStates.removeValue(forKey: panelId)
|
||||
surfaceTTYNames.removeValue(forKey: panelId)
|
||||
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
||||
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
||||
|
|
@ -4896,6 +4898,7 @@ extension Workspace: BonsplitDelegate {
|
|||
pinnedPanelIds.remove(panelId)
|
||||
manualUnreadPanelIds.remove(panelId)
|
||||
panelSubscriptions.removeValue(forKey: panelId)
|
||||
panelShellActivityStates.removeValue(forKey: panelId)
|
||||
surfaceTTYNames.removeValue(forKey: panelId)
|
||||
surfaceListeningPorts.removeValue(forKey: panelId)
|
||||
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
||||
|
|
@ -4933,7 +4936,7 @@ extension Workspace: BonsplitDelegate {
|
|||
if forceCloseTabIds.contains(tab.id) { continue }
|
||||
if let panelId = panelIdFromSurfaceId(tab.id),
|
||||
let terminalPanel = terminalPanel(for: panelId),
|
||||
terminalPanel.needsConfirmClose() {
|
||||
panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
|
||||
pendingPaneClosePanelIds.removeValue(forKey: pane.id)
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue