Add Cmd+Option+W to close other pane tabs with confirmation (#475)
* Add Cmd+Option+W close-other-tabs confirmation * Match close-other-tabs shortcut to Cmd+Option+T
This commit is contained in:
parent
5e2177dfb9
commit
eaabaad3d3
4 changed files with 239 additions and 0 deletions
|
|
@ -4301,6 +4301,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
guard panel.isVisible, let root = panel.contentView else { return false }
|
||||
return findStaticText(in: root, equals: "Close workspace?")
|
||||
|| findStaticText(in: root, equals: "Close tab?")
|
||||
|| findStaticText(in: root, equals: "Close other tabs?")
|
||||
}
|
||||
if let closeConfirmationPanel {
|
||||
// Special-case: Cmd+D should confirm destructive close on alerts.
|
||||
|
|
@ -4563,6 +4564,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
if normalizedFlags == [.command, .option], (chars == "t" || event.keyCode == 17) {
|
||||
if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
|
||||
targetWindow.identifier?.rawValue == "cmux.settings" {
|
||||
targetWindow.performClose(nil)
|
||||
} else {
|
||||
let responder = event.window?.firstResponder
|
||||
?? NSApp.keyWindow?.firstResponder
|
||||
?? NSApp.mainWindow?.firstResponder
|
||||
if let ghosttyView = cmuxOwningGhosttyView(for: responder),
|
||||
let workspaceId = ghosttyView.tabId,
|
||||
let manager = tabManagerFor(tabId: workspaceId) ?? tabManager {
|
||||
manager.closeOtherTabsInFocusedPaneWithConfirmation()
|
||||
} else {
|
||||
tabManager?.closeOtherTabsInFocusedPaneWithConfirmation()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Cmd+W must close the focused panel even if first-responder momentarily lags on a
|
||||
// browser NSTextView during split focus transitions.
|
||||
if normalizedFlags == [.command], (chars == "w" || event.keyCode == 13) {
|
||||
|
|
|
|||
|
|
@ -1050,6 +1050,27 @@ class TabManager: ObservableObject {
|
|||
closePanelWithConfirmation(tab: tab, panelId: focusedPanelId)
|
||||
}
|
||||
|
||||
func canCloseOtherTabsInFocusedPane() -> Bool {
|
||||
closeOtherTabsInFocusedPanePlan() != nil
|
||||
}
|
||||
|
||||
func closeOtherTabsInFocusedPaneWithConfirmation() {
|
||||
guard let plan = closeOtherTabsInFocusedPanePlan() else { return }
|
||||
|
||||
let count = plan.panelIds.count
|
||||
let titleLines = plan.titles.map { "• \($0)" }.joined(separator: "\n")
|
||||
let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)"
|
||||
guard confirmClose(
|
||||
title: "Close other tabs?",
|
||||
message: message,
|
||||
acceptCmdD: false
|
||||
) else { return }
|
||||
|
||||
for panelId in plan.panelIds {
|
||||
_ = plan.workspace.closePanel(panelId, force: true)
|
||||
}
|
||||
}
|
||||
|
||||
func closeCurrentWorkspaceWithConfirmation() {
|
||||
#if DEBUG
|
||||
UITestRecorder.incrementInt("closeTabInvocations")
|
||||
|
|
@ -1097,6 +1118,54 @@ class TabManager: ObservableObject {
|
|||
return alert.runModal() == .alertFirstButtonReturn
|
||||
}
|
||||
|
||||
private struct CloseOtherTabsInFocusedPanePlan {
|
||||
let workspace: Workspace
|
||||
let panelIds: [UUID]
|
||||
let titles: [String]
|
||||
}
|
||||
|
||||
private func closeOtherTabsInFocusedPanePlan() -> CloseOtherTabsInFocusedPanePlan? {
|
||||
guard let workspace = selectedWorkspace else { return nil }
|
||||
guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let tabsInPane = workspace.bonsplitController.tabs(inPane: paneId)
|
||||
guard !tabsInPane.isEmpty else { return nil }
|
||||
guard let selectedTabId = workspace.bonsplitController.selectedTab(inPane: paneId)?.id ?? tabsInPane.first?.id else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var targetPanelIds: [UUID] = []
|
||||
var targetTitles: [String] = []
|
||||
for tab in tabsInPane where tab.id != selectedTabId {
|
||||
guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue }
|
||||
if workspace.isPanelPinned(panelId) {
|
||||
continue
|
||||
}
|
||||
targetPanelIds.append(panelId)
|
||||
targetTitles.append(closeOtherTabsDisplayTitle(workspace.panelTitle(panelId: panelId)))
|
||||
}
|
||||
|
||||
guard !targetPanelIds.isEmpty else { return nil }
|
||||
return CloseOtherTabsInFocusedPanePlan(
|
||||
workspace: workspace,
|
||||
panelIds: targetPanelIds,
|
||||
titles: targetTitles
|
||||
)
|
||||
}
|
||||
|
||||
private func closeOtherTabsDisplayTitle(_ title: String?) -> String {
|
||||
let collapsed = title?
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
.replacingOccurrences(of: "\r", with: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let collapsed, !collapsed.isEmpty {
|
||||
return collapsed
|
||||
}
|
||||
return "Untitled Tab"
|
||||
}
|
||||
|
||||
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) {
|
||||
let willCloseWindow = tabs.count <= 1
|
||||
if workspaceNeedsConfirmClose(workspace),
|
||||
|
|
|
|||
|
|
@ -409,6 +409,12 @@ struct cmuxApp: App {
|
|||
}
|
||||
.keyboardShortcut("w", modifiers: .command)
|
||||
|
||||
Button("Close Other Tabs in Pane") {
|
||||
closeOtherTabsInFocusedPane()
|
||||
}
|
||||
.keyboardShortcut("t", modifiers: [.command, .option])
|
||||
.disabled(!activeTabManager.canCloseOtherTabsInFocusedPane())
|
||||
|
||||
// Cmd+Shift+W closes the current workspace (with confirmation if needed). If this
|
||||
// is the last workspace, it closes the window.
|
||||
splitCommandButton(title: "Close Workspace", shortcut: closeWorkspaceMenuShortcut) {
|
||||
|
|
@ -784,6 +790,10 @@ struct cmuxApp: App {
|
|||
activeTabManager.closeCurrentPanelWithConfirmation()
|
||||
}
|
||||
|
||||
private func closeOtherTabsInFocusedPane() {
|
||||
activeTabManager.closeOtherTabsInFocusedPaneWithConfirmation()
|
||||
}
|
||||
|
||||
private func closeTabOrWindow() {
|
||||
activeTabManager.closeCurrentTabWithConfirmation()
|
||||
}
|
||||
|
|
|
|||
140
tests/test_cmd_option_t_close_other_tabs_in_pane.py
Normal file
140
tests/test_cmd_option_t_close_other_tabs_in_pane.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: Cmd+Option+T closes all other tabs in the focused pane
|
||||
after an explicit confirmation.
|
||||
|
||||
Run this against an app launched with CMUX_SOCKET_MODE=allowAll.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05) -> bool:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval_s)
|
||||
return False
|
||||
|
||||
|
||||
def _pane_state(client: cmux) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
for index, panel_id, title, selected in client.list_pane_surfaces():
|
||||
rows.append(
|
||||
{
|
||||
"index": index,
|
||||
"panel_id": panel_id,
|
||||
"title": title,
|
||||
"selected": selected,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _send_shortcut_via_system_events(key: str, modifiers: str) -> None:
|
||||
script = f'tell application "System Events" to keystroke "{key}" using {{{modifiers}}}'
|
||||
try:
|
||||
subprocess.run(["osascript", "-e", script], check=True, capture_output=True, text=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = (exc.stderr or "").strip()
|
||||
raise cmuxError(
|
||||
"Failed to send keyboard shortcut via System Events. "
|
||||
f"Ensure macOS Accessibility automation is enabled. stderr={stderr}"
|
||||
) from exc
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
if not client.ping():
|
||||
raise cmuxError(
|
||||
f"Socket ping failed on {SOCKET_PATH}. "
|
||||
"Launch Debug app with CMUX_SOCKET_MODE=allowAll for this test."
|
||||
)
|
||||
|
||||
workspace_id = client.new_workspace()
|
||||
try:
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.25)
|
||||
client.activate_app()
|
||||
time.sleep(0.15)
|
||||
|
||||
# Create two additional tabs in the current focused pane.
|
||||
client.new_surface()
|
||||
client.new_surface()
|
||||
time.sleep(0.25)
|
||||
|
||||
before = _pane_state(client)
|
||||
if len(before) < 3:
|
||||
raise cmuxError(f"Expected >=3 tabs before shortcut, got {before}")
|
||||
|
||||
selected_rows = [row for row in before if row["selected"]]
|
||||
if len(selected_rows) != 1:
|
||||
raise cmuxError(f"Expected exactly one selected tab before shortcut, got {before}")
|
||||
selected_panel_id = selected_rows[0]["panel_id"]
|
||||
|
||||
expected_to_close = [row for row in before if row["panel_id"] != selected_panel_id]
|
||||
if len(expected_to_close) < 2:
|
||||
raise cmuxError(
|
||||
f"Expected at least two non-selected tabs before shortcut, got {before}"
|
||||
)
|
||||
|
||||
# Trigger shortcut via real OS key event; this should open the confirmation dialog.
|
||||
_send_shortcut_via_system_events("t", "command down, option down")
|
||||
time.sleep(0.25)
|
||||
after_trigger = _pane_state(client)
|
||||
if len(after_trigger) != len(before):
|
||||
raise cmuxError(
|
||||
"Cmd+Option+T should require confirmation before closing.\n"
|
||||
f"before={before}\n"
|
||||
f"after_trigger={after_trigger}"
|
||||
)
|
||||
|
||||
# Confirm the dialog with Cmd+D (wired to click the destructive "Close" button).
|
||||
_send_shortcut_via_system_events("d", "command down")
|
||||
closed = _wait_until(lambda: len(_pane_state(client)) == 1, timeout_s=5.0, interval_s=0.05)
|
||||
if not closed:
|
||||
raise cmuxError(
|
||||
"Timed out waiting for tabs to close after confirming Cmd+Option+T.\n"
|
||||
f"before={before}\n"
|
||||
f"after_trigger={after_trigger}\n"
|
||||
f"after_confirm={_pane_state(client)}"
|
||||
)
|
||||
|
||||
after_confirm = _pane_state(client)
|
||||
if len(after_confirm) != 1:
|
||||
raise cmuxError(
|
||||
f"Expected one remaining tab after confirmation, got {after_confirm}"
|
||||
)
|
||||
remaining = after_confirm[0]
|
||||
if remaining["panel_id"] != selected_panel_id:
|
||||
raise cmuxError(
|
||||
"Expected selected tab to remain after closing others.\n"
|
||||
f"expected_selected={selected_panel_id}\n"
|
||||
f"remaining={remaining}\n"
|
||||
f"before={before}"
|
||||
)
|
||||
|
||||
print("PASS: Cmd+Option+T closed all other tabs in focused pane.")
|
||||
print(f"workspace={workspace_id}")
|
||||
print(f"selected_panel={selected_panel_id}")
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue