diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 419276d6..35af3c73 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2315,6 +2315,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWindow)) { + guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return true + } + targetWindow.performClose(nil) + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameTab)) { + // Keep Cmd+R browser reload behavior when a browser panel is focused. + if tabManager?.focusedBrowserPanel != nil { + return false + } + let targetWindow = activeCommandPaletteWindow() ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) + return true + } + // Numeric shortcuts for specific sidebar tabs: Cmd+1-9 (9 = last workspace) if flags == [.command], let manager = tabManager, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8359f265..7817856d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2380,7 +2380,7 @@ struct ContentView: View { TextField(commandPaletteSearchPlaceholder, text: $commandPaletteQuery) .textFieldStyle(.plain) .font(.system(size: 13, weight: .regular)) - .tint(.blue) + .tint(.white) .focused($isCommandPaletteSearchFocused) .onSubmit { runSelectedCommandPaletteResult(visibleResults: visibleResults) @@ -2582,7 +2582,7 @@ struct ContentView: View { TextField(target.placeholder, text: $commandPaletteRenameDraft) .textFieldStyle(.plain) .font(.system(size: 13, weight: .regular)) - .tint(.blue) + .tint(.white) .focused($isCommandPaletteRenameFocused) .backport.onKeyPress(.delete) { modifiers in handleCommandPaletteRenameDeleteBackward(modifiers: modifiers) @@ -2941,7 +2941,7 @@ struct ContentView: View { rank: nextRank, title: contribution.title(context), subtitle: contribution.subtitle(context), - shortcutHint: commandPaletteShortcutHint(for: contribution), + shortcutHint: commandPaletteShortcutHint(for: contribution, context: context), keywords: contribution.keywords, dismissOnRun: contribution.dismissOnRun, action: action @@ -2953,7 +2953,15 @@ struct ContentView: View { return commands } - private func commandPaletteShortcutHint(for contribution: CommandPaletteCommandContribution) -> String? { + private func commandPaletteShortcutHint( + for contribution: CommandPaletteCommandContribution, + context: CommandPaletteContextSnapshot + ) -> String? { + // Preserve browser reload semantics for Cmd+R when a browser tab is focused. + if contribution.commandId == "palette.renameTab", + context.bool(CommandPaletteContextKeys.panelIsBrowser) { + return nil + } if let action = commandPaletteShortcutAction(for: contribution.commandId) { return KeyboardShortcutSettings.shortcut(for: action).displayString } @@ -2967,16 +2975,22 @@ struct ContentView: View { switch commandId { case "palette.newWorkspace": return .newTab + case "palette.newWindow": + return .newWindow case "palette.newTerminalTab": return .newSurface case "palette.newBrowserTab": return .openBrowser + case "palette.closeWindow": + return .closeWindow case "palette.toggleSidebar": return .toggleSidebar case "palette.showNotifications": return .showNotifications case "palette.jumpUnread": return .jumpToUnread + case "palette.renameTab": + return .renameTab case "palette.renameWorkspace": return .renameWorkspace case "palette.nextWorkspace": @@ -3108,6 +3122,14 @@ struct ContentView: View { keywords: ["create", "new", "workspace"] ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newWindow", + title: constant("New Window"), + subtitle: constant("Window"), + keywords: ["create", "new", "window"] + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.newTerminalTab", @@ -3144,6 +3166,14 @@ struct ContentView: View { keywords: ["close", "workspace"] ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWindow", + title: constant("Close Window"), + subtitle: constant("Window"), + keywords: ["close", "window"] + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.reopenClosedBrowserTab", @@ -3543,6 +3573,9 @@ struct ContentView: View { registry.register(commandId: "palette.newWorkspace") { tabManager.addWorkspace() } + registry.register(commandId: "palette.newWindow") { + AppDelegate.shared?.openNewMainWindow(nil) + } registry.register(commandId: "palette.newTerminalTab") { tabManager.newSurface() } @@ -3555,6 +3588,13 @@ struct ContentView: View { registry.register(commandId: "palette.closeWorkspace") { tabManager.closeCurrentWorkspaceWithConfirmation() } + registry.register(commandId: "palette.closeWindow") { + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return + } + window.performClose(nil) + } registry.register(commandId: "palette.reopenClosedBrowserTab") { _ = tabManager.reopenMostRecentlyClosedBrowserPanel() } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 4316a41e..61d7b799 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -8,6 +8,7 @@ enum KeyboardShortcutSettings { case toggleSidebar case newTab case newWindow + case closeWindow case showNotifications case jumpToUnread case triggerFlash @@ -17,6 +18,7 @@ enum KeyboardShortcutSettings { case prevSurface case nextSidebarTab case prevSidebarTab + case renameTab case renameWorkspace case closeWorkspace case newSurface @@ -43,6 +45,7 @@ enum KeyboardShortcutSettings { case .toggleSidebar: return "Toggle Sidebar" case .newTab: return "New Workspace" case .newWindow: return "New Window" + case .closeWindow: return "Close Window" case .showNotifications: return "Show Notifications" case .jumpToUnread: return "Jump to Latest Unread" case .triggerFlash: return "Flash Focused Panel" @@ -50,6 +53,7 @@ enum KeyboardShortcutSettings { case .prevSurface: return "Previous Surface" case .nextSidebarTab: return "Next Workspace" case .prevSidebarTab: return "Previous Workspace" + case .renameTab: return "Rename Tab" case .renameWorkspace: return "Rename Workspace" case .closeWorkspace: return "Close Workspace" case .newSurface: return "New Surface" @@ -72,11 +76,13 @@ enum KeyboardShortcutSettings { case .toggleSidebar: return "shortcut.toggleSidebar" case .newTab: return "shortcut.newTab" case .newWindow: return "shortcut.newWindow" + case .closeWindow: return "shortcut.closeWindow" case .showNotifications: return "shortcut.showNotifications" case .jumpToUnread: return "shortcut.jumpToUnread" case .triggerFlash: return "shortcut.triggerFlash" case .nextSidebarTab: return "shortcut.nextSidebarTab" case .prevSidebarTab: return "shortcut.prevSidebarTab" + case .renameTab: return "shortcut.renameTab" case .renameWorkspace: return "shortcut.renameWorkspace" case .closeWorkspace: return "shortcut.closeWorkspace" case .focusLeft: return "shortcut.focusLeft" @@ -104,6 +110,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) case .newWindow: return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false) + case .closeWindow: + return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true) case .showNotifications: return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false) case .jumpToUnread: @@ -114,6 +122,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true) case .prevSidebarTab: return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true) + case .renameTab: + return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false) case .renameWorkspace: return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false) case .closeWorkspace: diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3622c596..9b392625 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8230,6 +8230,37 @@ class TerminalController { } #if DEBUG + private func debugShortcutName(for action: KeyboardShortcutSettings.Action) -> String { + let snakeCase = action.rawValue.replacingOccurrences( + of: "([a-z0-9])([A-Z])", + with: "$1_$2", + options: .regularExpression + ) + return snakeCase.lowercased() + } + + private func debugShortcutAction(named rawName: String) -> KeyboardShortcutSettings.Action? { + let normalized = rawName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "-", with: "_") + + for action in KeyboardShortcutSettings.Action.allCases { + let snakeCaseName = debugShortcutName(for: action) + if normalized == snakeCaseName || normalized == snakeCaseName.replacingOccurrences(of: "_", with: "") { + return action + } + } + return nil + } + + private func debugShortcutSupportedNames() -> String { + KeyboardShortcutSettings.Action.allCases + .map(debugShortcutName(for:)) + .sorted() + .joined(separator: ", ") + } + private func setShortcut(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) @@ -8237,29 +8268,15 @@ class TerminalController { return "ERROR: Usage: set_shortcut " } - let name = parts[0].lowercased() + let name = parts[0] let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - let defaultsKey: String? - switch name { - case "focus_left", "focusleft": - defaultsKey = KeyboardShortcutSettings.focusLeftKey - case "focus_right", "focusright": - defaultsKey = KeyboardShortcutSettings.focusRightKey - case "focus_up", "focusup": - defaultsKey = KeyboardShortcutSettings.focusUpKey - case "focus_down", "focusdown": - defaultsKey = KeyboardShortcutSettings.focusDownKey - default: - defaultsKey = nil - } - - guard let defaultsKey else { - return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down" + guard let action = debugShortcutAction(named: name) else { + return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())" } if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { - UserDefaults.standard.removeObject(forKey: defaultsKey) + UserDefaults.standard.removeObject(forKey: action.defaultsKey) return "OK" } @@ -8277,7 +8294,7 @@ class TerminalController { guard let data = try? JSONEncoder().encode(shortcut) else { return "ERROR: Failed to encode shortcut" } - UserDefaults.standard.set(data, forKey: defaultsKey) + UserDefaults.standard.set(data, forKey: action.defaultsKey) return "OK" } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a9a8eba6..8341c5f1 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -555,6 +555,30 @@ final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { } final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { + func testRenameTabShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.label, "Rename Tab") + XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.defaultsKey, "shortcut.renameTab") + + let shortcut = KeyboardShortcutSettings.Action.renameTab.defaultShortcut + XCTAssertEqual(shortcut.key, "r") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testCloseWindowShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.label, "Close Window") + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.defaultsKey, "shortcut.closeWindow") + + let shortcut = KeyboardShortcutSettings.Action.closeWindow.defaultShortcut + XCTAssertEqual(shortcut.key, "w") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertTrue(shortcut.control) + } + func testRenameWorkspaceShortcutDefaultsAndMetadata() { XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.label, "Rename Workspace") XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey, "shortcut.renameWorkspace") diff --git a/tests/test_lint_swiftui_patterns.py b/tests/test_lint_swiftui_patterns.py index f5d82c14..685480eb 100644 --- a/tests/test_lint_swiftui_patterns.py +++ b/tests/test_lint_swiftui_patterns.py @@ -9,6 +9,7 @@ This test checks for: from __future__ import annotations +import re import subprocess import sys from pathlib import Path @@ -94,6 +95,48 @@ def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, s return violations +def check_command_palette_caret_tint(repo_root: Path) -> List[str]: + """Ensure command palette text inputs keep a white caret tint.""" + content_view = repo_root / "Sources" / "ContentView.swift" + if not content_view.exists(): + return [f"Missing expected file: {content_view}"] + + try: + content = content_view.read_text() + except Exception as e: + return [f"Could not read {content_view}: {e}"] + + checks = [ + ( + "search input", + r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P.*?)" + r"\.focused\(\$isCommandPaletteSearchFocused\)", + ), + ( + "rename input", + r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P.*?)" + r"\.focused\(\$isCommandPaletteRenameFocused\)", + ), + ] + + violations: List[str] = [] + for label, pattern in checks: + match = re.search(pattern, content, flags=re.DOTALL) + if not match: + violations.append( + f"Could not locate command palette {label} TextField block in Sources/ContentView.swift" + ) + continue + + body = match.group("body") + if ".tint(.white)" not in body: + violations.append( + f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift" + ) + + return violations + + def main(): """Run the lint checks.""" repo_root = get_repo_root() @@ -102,15 +145,18 @@ def main(): print(f"Checking {len(swift_files)} Swift files for performance issues...") # Check for auto-updating Text styles - violations = check_autoupdating_text_styles(swift_files) + style_violations = check_autoupdating_text_styles(swift_files) + tint_violations = check_command_palette_caret_tint(repo_root) + has_failures = False - if violations: + if style_violations: + has_failures = True print("\n❌ LINT FAILURES: Auto-updating Text styles found") print("=" * 60) print("These patterns cause continuous SwiftUI view updates and high CPU usage:") print() - for file_path, line_num, line in violations: + for file_path, line_num, line in style_violations: rel_path = file_path.relative_to(repo_root) print(f" {rel_path}:{line_num}") print(f" {line}") @@ -120,9 +166,23 @@ def main(): print(" Instead of: Text(date, style: .time)") print(" Use: Text(date.formatted(date: .omitted, time: .shortened))") print() + + if tint_violations: + has_failures = True + print("\n❌ LINT FAILURES: Command palette caret tint drifted") + print("=" * 60) + print("The command palette search and rename text fields must keep a white caret:") + print() + for message in tint_violations: + print(f" {message}") + print() + print("FIX: Set command palette TextField tint modifiers to `.white`.") + print() + + if has_failures: return 1 - print("✅ No auto-updating Text style patterns found") + print("✅ No linted SwiftUI pattern regressions found") return 0 diff --git a/tests_v2/test_command_palette_shortcut_hint_sync.py b/tests_v2/test_command_palette_shortcut_hint_sync.py new file mode 100644 index 00000000..c6acc01a --- /dev/null +++ b/tests_v2/test_command_palette_shortcut_hint_sync.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Regression test: command-palette shortcut hints stay in sync with editable shortcuts. + +Validates: +- New Window / Close Window / Rename Tab commands are present in command mode. +- Their displayed shortcut hints reflect the current KeyboardShortcutSettings values. +""" + +import os +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=4.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"command palette did not become visible={visible}", + ) + + +def _palette_results(client: cmux, window_id: str, limit=12) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _open_palette_and_rows(client: cmux, window_id: str, limit: int = 80) -> list: + _set_palette_visible(client, window_id, False) + _set_palette_visible(client, window_id, True) + payload = _palette_results(client, window_id, limit=limit) + rows = payload.get("results") or [] + if not rows: + raise cmuxError(f"command palette returned no rows: {payload}") + return rows + + +def _assert_shortcut_hint(rows: list, command_id: str, expected_hint: str) -> None: + row = next((row for row in rows if str((row or {}).get("command_id") or "") == command_id), None) + if row is None: + raise cmuxError(f"missing command palette row for {command_id!r}; rows={rows}") + shortcut_hint = str((row or {}).get("shortcut_hint") or "") + if shortcut_hint != expected_hint: + raise cmuxError( + f"unexpected shortcut hint for {command_id}: expected {expected_hint!r}, got {shortcut_hint!r} row={row}" + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + shortcut_names = ["new_window", "close_window", "rename_tab"] + try: + rows = _open_palette_and_rows(client, window_id) + _assert_shortcut_hint(rows, "palette.newWindow", "⇧⌘N") + _assert_shortcut_hint(rows, "palette.closeWindow", "⌃⌘W") + _assert_shortcut_hint(rows, "palette.renameTab", "⌘R") + + client.set_shortcut("new_window", "cmd+opt+n") + client.set_shortcut("close_window", "cmd+opt+w") + client.set_shortcut("rename_tab", "cmd+ctrl+r") + + rows = _open_palette_and_rows(client, window_id) + _assert_shortcut_hint(rows, "palette.newWindow", "⌥⌘N") + _assert_shortcut_hint(rows, "palette.closeWindow", "⌥⌘W") + _assert_shortcut_hint(rows, "palette.renameTab", "⌃⌘R") + finally: + for name in shortcut_names: + try: + client.set_shortcut(name, "clear") + except cmuxError: + pass + _set_palette_visible(client, window_id, False) + + print("PASS: command-palette shortcut hints track editable shortcuts for new/close/rename window-tab actions") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_lint_swiftui_patterns.py b/tests_v2/test_lint_swiftui_patterns.py index f5d82c14..685480eb 100644 --- a/tests_v2/test_lint_swiftui_patterns.py +++ b/tests_v2/test_lint_swiftui_patterns.py @@ -9,6 +9,7 @@ This test checks for: from __future__ import annotations +import re import subprocess import sys from pathlib import Path @@ -94,6 +95,48 @@ def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, s return violations +def check_command_palette_caret_tint(repo_root: Path) -> List[str]: + """Ensure command palette text inputs keep a white caret tint.""" + content_view = repo_root / "Sources" / "ContentView.swift" + if not content_view.exists(): + return [f"Missing expected file: {content_view}"] + + try: + content = content_view.read_text() + except Exception as e: + return [f"Could not read {content_view}: {e}"] + + checks = [ + ( + "search input", + r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P.*?)" + r"\.focused\(\$isCommandPaletteSearchFocused\)", + ), + ( + "rename input", + r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P.*?)" + r"\.focused\(\$isCommandPaletteRenameFocused\)", + ), + ] + + violations: List[str] = [] + for label, pattern in checks: + match = re.search(pattern, content, flags=re.DOTALL) + if not match: + violations.append( + f"Could not locate command palette {label} TextField block in Sources/ContentView.swift" + ) + continue + + body = match.group("body") + if ".tint(.white)" not in body: + violations.append( + f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift" + ) + + return violations + + def main(): """Run the lint checks.""" repo_root = get_repo_root() @@ -102,15 +145,18 @@ def main(): print(f"Checking {len(swift_files)} Swift files for performance issues...") # Check for auto-updating Text styles - violations = check_autoupdating_text_styles(swift_files) + style_violations = check_autoupdating_text_styles(swift_files) + tint_violations = check_command_palette_caret_tint(repo_root) + has_failures = False - if violations: + if style_violations: + has_failures = True print("\n❌ LINT FAILURES: Auto-updating Text styles found") print("=" * 60) print("These patterns cause continuous SwiftUI view updates and high CPU usage:") print() - for file_path, line_num, line in violations: + for file_path, line_num, line in style_violations: rel_path = file_path.relative_to(repo_root) print(f" {rel_path}:{line_num}") print(f" {line}") @@ -120,9 +166,23 @@ def main(): print(" Instead of: Text(date, style: .time)") print(" Use: Text(date.formatted(date: .omitted, time: .shortened))") print() + + if tint_violations: + has_failures = True + print("\n❌ LINT FAILURES: Command palette caret tint drifted") + print("=" * 60) + print("The command palette search and rename text fields must keep a white caret:") + print() + for message in tint_violations: + print(f" {message}") + print() + print("FIX: Set command palette TextField tint modifiers to `.white`.") + print() + + if has_failures: return 1 - print("✅ No auto-updating Text style patterns found") + print("✅ No linted SwiftUI pattern regressions found") return 0