diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 014c6f77..7866ccaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -689,7 +689,7 @@ jobs: PROGRAMA_LAG_MAX_CHURN_P95_MS=35 \ PROGRAMA_LAG_KEY_EVENTS=180 \ PROGRAMA_LAG_KEY_COMBO="${{ inputs.lag_combo || 'up' }}" \ - python3 tests/test_workspace_churn_up_arrow_lag.py + python3 tests_v2/test_workspace_churn_up_arrow_lag.py # On-demand only. This measures the refresh call itself with an in-process # timer rather than an end-to-end socket round trip, because that round trip @@ -739,7 +739,7 @@ jobs: # where nobody can read it. That cost a full four-run cycle to learn nothing. if ! PROGRAMA_SOCKET_PATH="$SOCK" \ PROGRAMA_REFRESH_COST_KEY_EVENTS=400 \ - python3 tests/test_text_input_refresh_cost.py | tee -a "$GITHUB_STEP_SUMMARY"; then + python3 tests_v2/test_text_input_refresh_cost.py | tee -a "$GITHUB_STEP_SUMMARY"; then echo "--- app still running? ---" pgrep -x "Programa DEV" >/dev/null && echo "yes" || echo "NO - it exited" echo "--- socket present at $SOCK? ---" diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index daccbfa1..bfd42ffd 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -6903,36 +6903,36 @@ } } }, - "dialog.cmuxConfig.confirmCommand.trustDirectory": { + "dialog.cmuxConfig.confirmCommand.truncated": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Always trust commands from this folder" + "value": "… (truncated)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このフォルダのコマンドを常に信頼する" + "value": "…(省略)" } } } }, - "dialog.cmuxConfig.confirmCommand.truncated": { + "dialog.cmuxConfig.confirmCommand.trustDirectory": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "… (truncated)" + "value": "Always trust commands from this folder" } }, "ja": { "stringUnit": { "state": "translated", - "value": "…(省略)" + "value": "このフォルダのコマンドを常に信頼する" } } } @@ -8515,39 +8515,6 @@ } } }, - "settings.app.closeWorkspaceOnLastSurfaceShortcut": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Keep Workspace Open When Closing Last Surface" - } - } - } - }, - "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "When the focused surface is the last one in its workspace, the close-surface shortcut also closes the workspace." - } - } - } - }, - "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "When the focused surface is the last one in its workspace, the close-surface shortcut closes only the surface and keeps the workspace open. Use the close-workspace shortcut to close the workspace explicitly." - } - } - } - }, "settings.app.commandPaletteSearchAllSurfaces": { "extractionState": "manual", "localizations": { @@ -8625,39 +8592,6 @@ } } }, - "settings.app.paneFirstClickFocus": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Focus Pane on First Click" - } - } - } - }, - "settings.app.paneFirstClickFocus.subtitleOff": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "When Programa is inactive, the first click only activates the window. Click again to focus the pane." - } - } - } - }, - "settings.app.paneFirstClickFocus.subtitleOn": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "When Programa is inactive, clicking a pane activates the window and focuses that pane in one click." - } - } - } - }, "settings.app.persistScrollback": { "extractionState": "manual", "localizations": { @@ -8709,39 +8643,6 @@ } } }, - "settings.app.renameSelectsName": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rename Selects Existing Name" - } - } - } - }, - "settings.app.renameSelectsName.subtitleOff": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Command Palette rename keeps the caret at the end." - } - } - } - }, - "settings.app.renameSelectsName.subtitleOn": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Command Palette rename starts with all text selected." - } - } - } - }, "settings.app.reorderOnNotification": { "extractionState": "manual", "localizations": { @@ -9789,284 +9690,275 @@ } } }, - "settings.notifications.longCommandThreshold.subtitle": { + "settings.notifications.command.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Notify when a command finishes in a pane you're not looking at, if it ran at least this long. Set to 0 to disable." + "value": "say \"done\"" } }, "ja": { "stringUnit": { "state": "translated", - "value": "見ていないペインでコマンドが完了したとき、実行時間がこの秒数以上であれば通知します。0にすると無効になります。" + "value": "say \"done\"" } } } }, - "settings.notifications.longCommandThreshold.title": { + "settings.notifications.command.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Long Command Notification" + "value": "Run a shell command when a notification arrives. $PROGRAMA_NOTIFICATION_TITLE, $PROGRAMA_NOTIFICATION_SUBTITLE, $PROGRAMA_NOTIFICATION_BODY are set." } }, "ja": { "stringUnit": { "state": "translated", - "value": "長時間コマンドの通知" + "value": "通知が届いたときにシェルコマンドを実行します。$PROGRAMA_NOTIFICATION_TITLE、$PROGRAMA_NOTIFICATION_SUBTITLE、$PROGRAMA_NOTIFICATION_BODY が設定されます。" } } } }, - "settings.notifications.longCommandThreshold.unit": { + "settings.notifications.command.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "sec" + "value": "Notification Command" } }, "ja": { "stringUnit": { "state": "translated", - "value": "秒" + "value": "通知コマンド" } } } }, - "settings.notifications.sound.custom.choose.button": { + "settings.notifications.desktop.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Choose..." + "value": "Desktop Notifications" } - } - } - }, - "settings.notifications.sound.custom.choose.prompt": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Choose" + "value": "デスクトップ通知" } } } }, - "settings.notifications.sound.custom.choose.title": { + "settings.notifications.longCommandThreshold.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Choose Notification Sound" + "value": "Notify when a command finishes in a pane you're not looking at, if it ran at least this long. Set to 0 to disable." } - } - } - }, - "settings.notifications.sound.custom.clear.button": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Clear" + "value": "見ていないペインでコマンドが完了したとき、実行時間がこの秒数以上であれば通知します。0にすると無効になります。" } } } }, - "settings.notifications.sound.custom.error.title": { + "settings.notifications.longCommandThreshold.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Custom Notification Sound Error" + "value": "Long Command Notification" } - } - } - }, - "settings.notifications.sound.custom.file.none": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "No file selected" + "value": "長時間コマンドの通知" } } } }, - "settings.notifications.sound.custom.status.empty": { + "settings.notifications.longCommandThreshold.unit": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Choose a custom audio file first." + "value": "sec" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "秒" } } } }, - "settings.notifications.sound.custom.status.missingExtensionPrefix": { + "settings.notifications.sound.custom.choose.button": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "File needs an extension: " + "value": "Choose..." } } } }, - "settings.notifications.sound.custom.status.missingFilePrefix": { + "settings.notifications.sound.custom.choose.prompt": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "File not found: " + "value": "Choose" } } } }, - "settings.notifications.sound.custom.status.prepareFailed": { + "settings.notifications.sound.custom.choose.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Could not prepare this file for notifications. Try WAV, AIFF, or CAF." + "value": "Choose Notification Sound" } } } }, - "settings.notifications.sound.custom.status.ready": { + "settings.notifications.sound.custom.clear.button": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Ready for notifications." + "value": "Clear" } } } }, - "settings.notifications.sound.custom.status.readyConverted": { + "settings.notifications.sound.custom.error.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Prepared for notifications (converted to CAF)." + "value": "Custom Notification Sound Error" } } } }, - "settings.notifications.sound.subtitle": { + "settings.notifications.sound.custom.file.none": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sound played when a notification arrives." + "value": "No file selected" } } } }, - "settings.notifications.sound.title": { + "settings.notifications.sound.custom.status.empty": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Notification Sound" + "value": "Choose a custom audio file first." } } } }, - "settings.preset.hudGlass": { + "settings.notifications.sound.custom.status.missingExtensionPrefix": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "HUD Glass" + "value": "File needs an extension: " } } } }, - "settings.preset.nativeSidebar": { + "settings.notifications.sound.custom.status.missingFilePrefix": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Native Sidebar" + "value": "File not found: " } } } }, - "settings.preset.popoverGlass": { + "settings.notifications.sound.custom.status.prepareFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Popover Glass" + "value": "Could not prepare this file for notifications. Try WAV, AIFF, or CAF." } } } }, - "settings.preset.raycastGray": { + "settings.notifications.sound.custom.status.ready": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Raycast Gray" + "value": "Ready for notifications." } } } }, - "settings.preset.softBlur": { + "settings.notifications.sound.custom.status.readyConverted": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Soft Blur" + "value": "Prepared for notifications (converted to CAF)." } } } }, - "settings.preset.underWindow": { + "settings.notifications.sound.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Under Window" + "value": "Sound played when a notification arrives." } } } }, - "settings.reset.resetAll": { + "settings.notifications.sound.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reset All Settings" + "value": "Notification Sound" } } } @@ -10326,104 +10218,130 @@ } } }, - "settings.phone.pair.manualFallback": { + "settings.phone.pair.scanLabel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Can't scan? Paste the payload and token manually" + "value": "Scan this with the Programa iOS app's “Scan QR Code” button." } }, "ja": { "stringUnit": { "state": "translated", - "value": "スキャンできない場合は、ペイロードとトークンを手動で貼り付け" + "value": "Programa iOSアプリの「QRコードをスキャン」ボタンでこれをスキャンしてください。" } } } }, - "settings.phone.pair.scanLabel": { + "settings.phone.pair.subtitleV2": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Scan this with the Programa iOS app's “Scan QR Code” button." + "value": "Opens a single-use, 5-minute pairing window. Scan the QR code with the Programa iOS app, or copy the code it shows." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Programa iOSアプリの「QRコードをスキャン」ボタンでこれをスキャンしてください。" + "value": "一度だけ使える5分間のペアリング枠を開きます。Programa iOSアプリでQRコードをスキャンするか、表示されたコードをコピーしてください。" } } } }, - "settings.phone.pair.subtitle": { + "settings.phone.pair.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Opens a single-use, 5-minute pairing window. Scan the QR code with the Programa iOS app, or enter the payload and token manually." + "value": "Pair a Device" } }, "ja": { "stringUnit": { "state": "translated", - "value": "5分間有効な、1回限りのペア設定ウィンドウを開きます。Programa iOSアプリでQRコードをスキャンするか、ペイロードとトークンを手動で入力してください。" + "value": "デバイスをペア設定" } } } }, - "settings.phone.pair.ticketLabel": { + "settings.preset.hudGlass": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pairing Payload" + "value": "HUD Glass" } - }, - "ja": { + } + } + }, + "settings.preset.nativeSidebar": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "ペア設定ペイロード" + "value": "Native Sidebar" } } } }, - "settings.phone.pair.title": { + "settings.preset.popoverGlass": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pair a Device" + "value": "Popover Glass" } - }, - "ja": { + } + } + }, + "settings.preset.raycastGray": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "デバイスをペア設定" + "value": "Raycast Gray" } } } }, - "settings.phone.pair.tokenLabel": { + "settings.preset.softBlur": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pairing Token" + "value": "Soft Blur" } - }, - "ja": { + } + } + }, + "settings.preset.underWindow": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "ペア設定トークン" + "value": "Under Window" + } + } + } + }, + "settings.reset.resetAll": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset All Settings" } } } @@ -10610,17 +10528,6 @@ } } }, - "settings.sidebarAppearance.defaultLabel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Default" - } - } - } - }, "settings.sidebarAppearance.matchTerminalBackground": { "extractionState": "manual", "localizations": { @@ -10643,39 +10550,6 @@ } } }, - "settings.sidebarAppearance.reset": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reset Sidebar Tint" - } - } - } - }, - "settings.sidebarAppearance.reset.button": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reset" - } - } - } - }, - "settings.sidebarAppearance.reset.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Restore default sidebar appearance." - } - } - } - }, "settings.sidebarAppearance.showClaudeQuota": { "extractionState": "manual", "localizations": { @@ -10710,211 +10584,159 @@ } } }, - "settings.sidebarAppearance.tintColorDark": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Dark Mode Tint" - } - } - } - }, - "settings.sidebarAppearance.tintColorDark.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Sidebar tint color when using dark appearance." - } - } - } - }, - "settings.sidebarAppearance.tintColorLight": { + "settings.state.active": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Light Mode Tint" + "value": "Active" } } } }, - "settings.sidebarAppearance.tintColorLight.subtitle": { + "settings.state.followWindow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sidebar tint color when using light appearance." + "value": "Follow Window" } } } }, - "settings.sidebarAppearance.tintOpacity": { + "settings.state.inactive": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tint Opacity" + "value": "Inactive" } } } }, - "settings.sidebarAppearance.tintOpacity.subtitle": { + "settings.tab.appearance": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "How strongly the tint color shows over the sidebar material." + "value": "Appearance" } - } - } - }, - "settings.state.active": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Active" + "value": "外観" } } } }, - "settings.state.followWindow": { + "settings.tab.automation": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Follow Window" + "value": "Automation" } - } - } - }, - "settings.state.inactive": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Inactive" + "value": "自動化" } } } }, - "settings.title": { + "settings.tab.browser": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Settings" + "value": "Browser" } - } - } - }, - "settings.workspaceColors.base": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Base: %@" + "value": "ブラウザ" } } } }, - "settings.workspaceColors.customEntry": { + "settings.tab.general": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Named palette entry." + "value": "General" } - } - } - }, - "settings.workspaceColors.dictionaryNote": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." + "value": "一般" } } } }, - "settings.workspaceColors.emptyPalette": { + "settings.tab.phone": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." + "value": "Phone" } - } - } - }, - "settings.workspaceColors.indicator": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Workspace Color Indicator" + "value": "スマートフォン" } } } }, - "settings.workspaceColors.remove": { + "settings.tab.shortcuts": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Remove" + "value": "Shortcuts" } - } - } - }, - "settings.workspaceColors.resetPalette": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Reset Palette" + "value": "ショートカット" } } } }, - "settings.workspaceColors.resetPalette.button": { + "settings.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reset" + "value": "Settings" } } } }, - "settings.workspaceColors.resetPalette.subtitleV2": { + "settings.workspaceColors.indicator": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Restore the built-in palette and remove extra named colors." + "value": "Workspace Color Indicator" } } } @@ -13182,4 +13004,4 @@ } }, "version": "1.0" -} \ No newline at end of file +} diff --git a/Sources/CommandPaletteController.swift b/Sources/CommandPaletteController.swift index c12ebba8..f03aaf7a 100644 --- a/Sources/CommandPaletteController.swift +++ b/Sources/CommandPaletteController.swift @@ -65,8 +65,6 @@ final class CommandPaletteController: ObservableObject { @Published var commandPalettePendingActivation: ContentView.CommandPalettePendingActivation? @Published var commandPaletteResultsRevision: UInt64 = 0 @Published var commandPaletteUsageHistoryByCommandId: [String: ContentView.CommandPaletteUsageEntry] = [:] - @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces @Published var commandPaletteShouldFocusWorkspaceDescriptionEditor = false diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b330cf66..8d69c099 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -181,10 +181,6 @@ struct ContentView: View { get { commandPaletteController.commandPaletteUsageHistoryByCommandId } nonmutating set { commandPaletteController.commandPaletteUsageHistoryByCommandId = newValue } } - private var commandPaletteRenameSelectAllOnFocus: Bool { - get { commandPaletteController.commandPaletteRenameSelectAllOnFocus } - nonmutating set { commandPaletteController.commandPaletteRenameSelectAllOnFocus = newValue } - } private var commandPaletteSearchAllSurfaces: Bool { get { commandPaletteController.commandPaletteSearchAllSurfaces } nonmutating set { commandPaletteController.commandPaletteSearchAllSurfaces = newValue } @@ -5493,13 +5489,12 @@ struct ContentView: View { } private func commandPaletteRenameInputFocusPolicy() -> CommandPaletteInputFocusPolicy { - let selectAllOnFocus = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled() - let selectionBehavior: CommandPaletteTextSelectionBehavior = selectAllOnFocus - ? .selectAll - : .caretAtEnd + // Rename always opens with the existing name selected, so typing replaces + // it. This used to be a preference. Other focus policies still use + // .caretAtEnd, so only this call site is fixed. return CommandPaletteInputFocusPolicy( focusTarget: .rename, - selectionBehavior: selectionBehavior + selectionBehavior: .selectAll ) } diff --git a/Sources/GhosttyTerminalView+Accessibility.swift b/Sources/GhosttyTerminalView+Accessibility.swift index 4b086836..690b0bed 100644 --- a/Sources/GhosttyTerminalView+Accessibility.swift +++ b/Sources/GhosttyTerminalView+Accessibility.swift @@ -126,10 +126,6 @@ extension GhosttyNSView { _ = inputContext.perform(updateSelectionSelector) } - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - PaneFirstClickFocusSettings.isEnabled() - } - override var acceptsFirstResponder: Bool { true } override func becomeFirstResponder() -> Bool { diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index 71ce8f44..382307d9 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -339,16 +339,9 @@ final class MarkdownPanelPointerObserverView: NSView { } } - override func hitTest(_ point: NSPoint) -> NSView? { - guard PaneFirstClickFocusSettings.isEnabled(), - window?.isKeyWindow != true, - bounds.contains(point) else { return nil } - return self - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - PaneFirstClickFocusSettings.isEnabled() - } + // This overlay never takes part in hit-testing. It only ever did so while + // the first-click-focus preference was on, and that preference is gone. + override func hitTest(_ point: NSPoint) -> NSView? { nil } override func mouseDown(with event: NSEvent) { onPointerDown?() @@ -370,9 +363,6 @@ final class MarkdownPanelPointerObserverView: NSView { let window, event.window === window, !isHiddenOrHasHiddenAncestor else { return false } - if PaneFirstClickFocusSettings.isEnabled(), window.isKeyWindow != true { - return false - } let point = convert(event.locationInWindow, from: nil) return bounds.contains(point) } diff --git a/Sources/Panels/ProgramaWebView.swift b/Sources/Panels/ProgramaWebView.swift index b4b810af..1101632c 100644 --- a/Sources/Panels/ProgramaWebView.swift +++ b/Sources/Panels/ProgramaWebView.swift @@ -175,10 +175,6 @@ final class ProgramaWebView: WKWebView { } var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth } - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - PaneFirstClickFocusSettings.isEnabled() - } - override func becomeFirstResponder() -> Bool { guard allowsFirstResponderAcquisitionEffective else { #if DEBUG diff --git a/Sources/ProgramaSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift index c30637c0..3942d412 100644 --- a/Sources/ProgramaSettingsFileStore.swift +++ b/Sources/ProgramaSettingsFileStore.swift @@ -339,12 +339,6 @@ final class ProgramaSettingsFileStore { let mode = value ? WorkspacePresentationModeSettings.Mode.minimal : .standard snapshot.managedUserDefaults[WorkspacePresentationModeSettings.modeKey] = .string(mode.rawValue) } - if let value = jsonBool(section["keepWorkspaceOpenWhenClosingLastSurface"]) { - snapshot.managedUserDefaults[LastSurfaceCloseShortcutSettings.key] = .bool(!value) - } - if let value = jsonBool(section["focusPaneOnFirstClick"]) { - snapshot.managedUserDefaults[PaneFirstClickFocusSettings.enabledKey] = .bool(value) - } if let value = jsonString(section["preferredEditor"]) { snapshot.managedUserDefaults[PreferredEditorSettings.key] = .string(value) } @@ -354,9 +348,6 @@ final class ProgramaSettingsFileStore { if let value = jsonBool(section["warnBeforeQuit"]) { snapshot.managedUserDefaults[QuitWarningSettings.warnBeforeQuitKey] = .bool(value) } - if let value = jsonBool(section["renameSelectsExistingName"]) { - snapshot.managedUserDefaults[CommandPaletteRenameSelectionSettings.selectAllOnFocusKey] = .bool(value) - } if let value = jsonBool(section["commandPaletteSearchesAllSurfaces"]) { snapshot.managedUserDefaults[CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey] = .bool(value) } @@ -1210,12 +1201,9 @@ final class ProgramaSettingsFileStore { "appearance": AppearanceSettings.defaultMode.rawValue, "newWorkspacePlacement": WorkspacePlacementSettings.defaultPlacement.rawValue, "minimalMode": WorkspacePresentationModeSettings.defaultMode == .minimal, - "keepWorkspaceOpenWhenClosingLastSurface": !LastSurfaceCloseShortcutSettings.defaultValue, - "focusPaneOnFirstClick": PaneFirstClickFocusSettings.defaultEnabled, "preferredEditor": "", "reorderOnNotification": WorkspaceAutoReorderSettings.defaultValue, "warnBeforeQuit": QuitWarningSettings.defaultWarnBeforeQuit, - "renameSelectsExistingName": CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus, "commandPaletteSearchesAllSurfaces": CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces, ], ], diff --git a/Sources/SettingsModels.swift b/Sources/SettingsModels.swift index dcfdd8e2..a32ed9d4 100644 --- a/Sources/SettingsModels.swift +++ b/Sources/SettingsModels.swift @@ -28,16 +28,6 @@ enum WorkspacePresentationModeSettings { } } -enum PaneFirstClickFocusSettings { - static let enabledKey = "paneFirstClickFocus.enabled" - static let defaultEnabled = false - private static let flag = UserDefaultsFlag(key: enabledKey, defaultValue: defaultEnabled) - - static func isEnabled(defaults: UserDefaults = .standard) -> Bool { - flag.isEnabled(defaults: defaults) - } -} - enum AppearanceMode: String, CaseIterable, Identifiable { case system case light @@ -115,16 +105,6 @@ enum ScrollbackPersistenceSettings { } } -enum CommandPaletteRenameSelectionSettings { - static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus" - static let defaultSelectAllOnFocus = true - private static let flag = UserDefaultsFlag(key: selectAllOnFocusKey, defaultValue: defaultSelectAllOnFocus) - - static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool { - flag.isEnabled(defaults: defaults) - } -} - enum CommandPaletteSwitcherSearchSettings { static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces" static let defaultSearchAllSurfaces = false @@ -222,3 +202,45 @@ enum PreferredEditorSettings { } } + +/// The tabs the settings window is split across. +/// +/// Settings used to be one scroll of nine stacked sections, which meant finding +/// anything required knowing roughly how far down it lived. The grouping here +/// merges sections that answer the same question: Workspace Colors and Sidebar +/// Appearance are both "how it looks", Custom Commands is part of Automation, +/// and Reset belongs with the general app switches it resets. +/// +/// Keyboard Shortcuts stays on its own because it renders one row per +/// `KeyboardShortcutSettings.Action` -- 57 of them -- and would swamp whatever +/// it shared a tab with. +enum SettingsTab: String, CaseIterable, Identifiable { + case general + case appearance + case automation + case phone + case browser + case shortcuts + + var id: String { rawValue } + + var title: String { + switch self { + case .general: String(localized: "settings.tab.general", defaultValue: "General") + case .appearance: String(localized: "settings.tab.appearance", defaultValue: "Appearance") + case .automation: String(localized: "settings.tab.automation", defaultValue: "Automation") + case .phone: String(localized: "settings.tab.phone", defaultValue: "Phone") + case .browser: String(localized: "settings.tab.browser", defaultValue: "Browser") + case .shortcuts: String(localized: "settings.tab.shortcuts", defaultValue: "Shortcuts") + } + } + + /// The tab that owns a deep-link target, so `SettingsNavigationRequest` + /// still lands on the right content now that it is not all one scroll. + static func owning(_ target: SettingsNavigationTarget) -> SettingsTab { + switch target { + case .browser, .browserImport: .browser + case .keyboardShortcuts: .shortcuts + } + } +} diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 154876ed..82a74f62 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -8,7 +8,12 @@ import CoreImage.CIFilterBuiltins struct SettingsView: View { - private let contentTopInset: CGFloat = 8 + // The header overlay now carries a tab strip under the title, so content has + // to start below both rather than just below the title. + private let contentTopInset: CGFloat = 42 + private let headerHeight: CGFloat = 96 + + @State private var selectedTab: SettingsTab = .general private let pickerColumnWidth: CGFloat = 196 private let notificationSoundControlWidth: CGFloat = 280 private let shortcutChordsDocsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs")! @@ -48,17 +53,11 @@ struct SettingsView: View { private var longCommandThresholdSeconds = LongCommandNotificationSettings.defaultThresholdSeconds @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit @AppStorage(ScrollbackPersistenceSettings.persistScrollbackKey) private var sessionPersistScrollback = ScrollbackPersistenceSettings.defaultPersistScrollback - @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue - @AppStorage(LastSurfaceCloseShortcutSettings.key) - private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue - @AppStorage(PaneFirstClickFocusSettings.enabledKey) - private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @@ -92,7 +91,6 @@ struct SettingsView: View { @State private var notificationCustomSoundStatusIsError = false @State private var showNotificationCustomSoundErrorAlert = false @State private var notificationCustomSoundErrorAlertMessage = "" - @State private var workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() @State private var trustedDirectoriesDraft: String = ProgramaDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n") @State private var mobileBridgePairedDevices: [MobileBridgeTrustedDevice] = [] @State private var mobileBridgePairingTicket: String? @@ -122,43 +120,6 @@ struct SettingsView: View { ) } - private var keepWorkspaceOpenOnLastSurfaceShortcut: Bool { - !closeWorkspaceOnLastSurfaceShortcut - } - - private var keepWorkspaceOpenOnLastSurfaceShortcutBinding: Binding { - Binding( - get: { keepWorkspaceOpenOnLastSurfaceShortcut }, - set: { closeWorkspaceOnLastSurfaceShortcut = !$0 } - ) - } - - private var closeWorkspaceOnLastSurfaceShortcutSubtitle: String { - if keepWorkspaceOpenOnLastSurfaceShortcut { - return String( - localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn", - defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut closes only the surface and keeps the workspace open. Use the close-workspace shortcut to close the workspace explicitly." - ) - } - return String( - localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff", - defaultValue: "When the focused surface is the last one in its workspace, the close-surface shortcut also closes the workspace." - ) - } - - private var paneFirstClickFocusSubtitle: String { - if paneFirstClickFocusEnabled { - return String( - localized: "settings.app.paneFirstClickFocus.subtitleOn", - defaultValue: "When Programa is inactive, clicking a pane activates the window and focuses that pane in one click." - ) - } - return String( - localized: "settings.app.paneFirstClickFocus.subtitleOff", - defaultValue: "When Programa is inactive, the first click only activates the window. Click again to focus the pane." - ) - } - private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) } @@ -252,10 +213,6 @@ struct SettingsView: View { } } - private var browserImportSubtitle: String { - InstalledBrowserDetector.summaryText(for: detectedImportBrowsers) - } - private var browserImportHintSettingsNote: String { switch browserImportHintPresentation.settingsStatus { case .visible: @@ -515,15 +472,23 @@ struct SettingsView: View { ZStack(alignment: .top) { ScrollView { VStack(alignment: .leading, spacing: 14) { - appSection - workspaceColorsSection - sidebarAppearanceSection - automationSection - phoneSection - customCommandsSection - browserSection - keyboardShortcutsSection - resetSection + switch selectedTab { + case .general: + appSection + resetSection + case .appearance: + workspaceColorsSection + sidebarAppearanceSection + case .automation: + automationSection + customCommandsSection + case .phone: + phoneSection + case .browser: + browserSection + case .shortcuts: + keyboardShortcutsSection + } } .padding(.horizontal, 20) .padding(.bottom, 20) @@ -579,25 +544,41 @@ struct SettingsView: View { ) .opacity(0.14 + (topBlurOpacity * 0.86)) - HStack { - Text(String(localized: "settings.title", defaultValue: "Settings")) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.primary.opacity(0.92)) - Spacer(minLength: 0) - HStack(spacing: 6) { - SettingsHeaderActionButton( - title: String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json"), - helpText: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), - accessibilityIdentifier: "SettingsFileOpenButton", - action: openProgramaSettingsFileInTextEdit - ) + VStack(spacing: 8) { + HStack { + Text(String(localized: "settings.title", defaultValue: "Settings")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary.opacity(0.92)) + Spacer(minLength: 0) + HStack(spacing: 6) { + SettingsHeaderActionButton( + title: String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json"), + helpText: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), + accessibilityIdentifier: "SettingsFileOpenButton", + action: openProgramaSettingsFileInTextEdit + ) + } + } + .padding(.leading, settingsTitleLeadingInset) + .padding(.trailing, 20) + + // Lives in the header overlay rather than the scroll content so + // it stays put while a tab's rows scroll under the blur. + Picker("", selection: $selectedTab) { + ForEach(SettingsTab.allCases) { tab in + Text(tab.title).tag(tab) + } } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + .padding(.leading, settingsTitleLeadingInset) + .padding(.trailing, 20) + .accessibilityIdentifier("SettingsTabPicker") } - .padding(.leading, settingsTitleLeadingInset) - .padding(.trailing, 20) .padding(.top, 12) } - .frame(height: 62) + .frame(height: headerHeight) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .ignoresSafeArea(.container, edges: .top) .overlay( @@ -616,7 +597,6 @@ struct SettingsView: View { browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist refreshDetectedImportBrowsers() - reloadWorkspaceTabColorSettings() refreshNotificationCustomSoundStatus() Task { await refreshMobileBridgePairedDevices() } } @@ -635,11 +615,11 @@ struct SettingsView: View { .onReceive(BrowserHistoryStore.shared.$entries) { entries in browserHistoryEntryCount = entries.count } - .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in - reloadWorkspaceTabColorSettings() - } .onReceive(NotificationCenter.default.publisher(for: SettingsNavigationRequest.notificationName)) { notification in guard let target = SettingsNavigationRequest.target(from: notification) else { return } + // Select the owning tab first. The anchor only exists while its tab is + // the selected one, so scrolling in the same pass would find nothing. + selectedTab = SettingsTab.owning(target) DispatchQueue.main.async { withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo(target, anchor: .top) @@ -728,31 +708,6 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow( - String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"), - subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle - ) { - Toggle("", isOn: keepWorkspaceOpenOnLastSurfaceShortcutBinding) - .labelsHidden() - .controlSize(.small) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click"), - subtitle: paneFirstClickFocusSubtitle - ) { - Toggle("", isOn: $paneFirstClickFocusEnabled) - .labelsHidden() - .controlSize(.small) - .accessibilityLabel( - String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click") - ) - } - - SettingsCardDivider() - SettingsCardRow( String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), subtitle: String(localized: "settings.app.preferredEditor.subtitle", defaultValue: "Command to open files on Cmd-click. Leave empty for system default.") @@ -795,7 +750,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Desktop Notifications", + String(localized: "settings.notifications.desktop.title", defaultValue: "Desktop Notifications"), subtitle: notificationPermissionSubtitle ) { HStack(spacing: 6) { @@ -882,10 +837,16 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - "Notification Command", - subtitle: "Run a shell command when a notification arrives. $PROGRAMA_NOTIFICATION_TITLE, $PROGRAMA_NOTIFICATION_SUBTITLE, $PROGRAMA_NOTIFICATION_BODY are set." + String(localized: "settings.notifications.command.title", defaultValue: "Notification Command"), + subtitle: String( + localized: "settings.notifications.command.subtitle", + defaultValue: "Run a shell command when a notification arrives. $PROGRAMA_NOTIFICATION_TITLE, $PROGRAMA_NOTIFICATION_SUBTITLE, $PROGRAMA_NOTIFICATION_BODY are set." + ) ) { - TextField("say \"done\"", text: $notificationCustomCommand) + TextField( + String(localized: "settings.notifications.command.placeholder", defaultValue: "say \"done\""), + text: $notificationCustomCommand + ) .textFieldStyle(.roundedBorder) .frame(width: 200) } @@ -942,19 +903,6 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow( - String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), - subtitle: commandPaletteRenameSelectAllOnFocus - ? String(localized: "settings.app.renameSelectsName.subtitleOn", defaultValue: "Command Palette rename starts with all text selected.") - : String(localized: "settings.app.renameSelectsName.subtitleOff", defaultValue: "Command Palette rename keeps the caret at the end.") - ) { - Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus) - .labelsHidden() - .controlSize(.small) - } - - SettingsCardDivider() - SettingsCardRow( String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), subtitle: commandPaletteSearchAllSurfaces @@ -987,136 +935,13 @@ struct SettingsView: View { Text(style.displayName).tag(style.rawValue) } } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.workspaceColors.selectionColor", defaultValue: "Selection Highlight"), - subtitle: String(localized: "settings.workspaceColors.selectionColor.subtitle", defaultValue: "Background color of the selected workspace in the sidebar.") - ) { - HStack(spacing: 8) { - if sidebarSelectionColorHex != nil { - Button(String(localized: "settings.workspaceColors.selectionColor.reset", defaultValue: "Reset")) { - sidebarSelectionColorHex = nil - } - .buttonStyle(.bordered) - .controlSize(.small) - } - - HexColorPicker( - hex: sidebarSelectionColorHex, - fallback: programaAccentColor() - ) { newHex in - sidebarSelectionColorHex = newHex - } - - Text(sidebarSelectionColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.workspaceColors.notificationBadgeColor", defaultValue: "Notification Badge"), - subtitle: String(localized: "settings.workspaceColors.notificationBadgeColor.subtitle", defaultValue: "Color of the unread notification badge on workspace tabs.") - ) { - HStack(spacing: 8) { - if sidebarNotificationBadgeColorHex != nil { - Button(String(localized: "settings.workspaceColors.notificationBadgeColor.reset", defaultValue: "Reset")) { - sidebarNotificationBadgeColorHex = nil - } - .buttonStyle(.bordered) - .controlSize(.small) - } - - HexColorPicker( - hex: sidebarNotificationBadgeColorHex, - fallback: programaAccentColor() - ) { newHex in - sidebarNotificationBadgeColorHex = newHex - } - - Text(sidebarNotificationBadgeColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardNote( - String( - localized: "settings.workspaceColors.dictionaryNote", - defaultValue: "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." - ) - ) - - if workspaceTabPaletteEntries.isEmpty { - SettingsCardNote( - String( - localized: "settings.workspaceColors.emptyPalette", - defaultValue: "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." - ) - ) - } else { - ForEach(Array(workspaceTabPaletteEntries.enumerated()), id: \.element.name) { index, entry in - if index > 0 { - SettingsCardDivider() - } - SettingsCardRow( - entry.name, - subtitle: baseTabColorHex(for: entry.name).map { - String(localized: "settings.workspaceColors.base", defaultValue: "Base: \($0)") - } ?? String( - localized: "settings.workspaceColors.customEntry", - defaultValue: "Named palette entry." - ) - ) { - HStack(spacing: 8) { - HexColorPicker( - hex: entry.hex, - fallback: .blue - ) { newHex in - WorkspaceTabColorSettings.setColor(named: entry.name, hex: newHex) - reloadWorkspaceTabColorSettings() - } - - Text(entry.hex) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - - if baseTabColorHex(for: entry.name) == nil { - Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { - removeWorkspaceColor(named: entry.name) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - } - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), - subtitle: String( - localized: "settings.workspaceColors.resetPalette.subtitleV2", - defaultValue: "Restore the built-in palette and remove extra named colors." - ) - ) { - Button(String(localized: "settings.workspaceColors.resetPalette.button", defaultValue: "Reset")) { - resetWorkspaceTabColors() - } - .buttonStyle(.bordered) - .controlSize(.small) - } + // Selection-highlight and notification-badge hex pickers, the inline + // palette editor and a section-local Reset Palette used to follow. The + // editor duplicated settings.json, which its own note already pointed at + // as the way to manage named colors, and the two hex pickers were the + // same per-pixel tuning as the sidebar tints. "Reset all settings" still + // calls WorkspaceTabColorSettings.reset() and nils both hex keys, so + // anything set while these rows existed is still recoverable. } } @@ -1149,82 +974,13 @@ struct SettingsView: View { String(localized: "settings.sidebarAppearance.showClaudeQuota", defaultValue: "Show Claude Quota") ) } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"), - subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.") - ) { - HStack(spacing: 8) { - HexColorPicker( - hex: sidebarTintHexLight ?? sidebarTintHex, - fallback: .black - ) { newHex in - sidebarTintHexLight = newHex - } - - Text(sidebarTintHexLight ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"), - subtitle: String(localized: "settings.sidebarAppearance.tintColorDark.subtitle", defaultValue: "Sidebar tint color when using dark appearance.") - ) { - HStack(spacing: 8) { - HexColorPicker( - hex: sidebarTintHexDark ?? sidebarTintHex, - fallback: .black - ) { newHex in - sidebarTintHexDark = newHex - } - - Text(sidebarTintHexDark ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"), - subtitle: String(localized: "settings.sidebarAppearance.tintOpacity.subtitle", defaultValue: "How strongly the tint color shows over the sidebar material.") - ) { - HStack(spacing: 8) { - Slider(value: $sidebarTintOpacity, in: 0...1) - .frame(width: 140) - Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 36, alignment: .trailing) - } - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"), - subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.") - ) { - Button(String(localized: "settings.sidebarAppearance.reset.button", defaultValue: "Reset")) { - sidebarTintHexLight = nil - sidebarTintHexDark = nil - sidebarTintHex = SidebarTintDefaults.hex - sidebarTintOpacity = SidebarTintDefaults.opacity - } - .buttonStyle(.bordered) - .controlSize(.small) - } + // Light/dark tint hex, tint opacity and a section-local reset used to + // live here. They were per-pixel tuning of one surface, shipped to + // every user, and the same four keys are already bound by the Debug + // window (DebugWindows.swift) where that kind of tuning belongs. The + // bindings stay on this view because "Reset all settings" below still + // restores them for anyone who set a value while the rows existed. } - } @ViewBuilder @@ -1473,7 +1229,7 @@ struct SettingsView: View { SettingsCardRow( String(localized: "settings.phone.pair.title", defaultValue: "Pair a Device"), - subtitle: String(localized: "settings.phone.pair.subtitle", defaultValue: "Opens a single-use, 5-minute pairing window. Scan the QR code with the Programa iOS app, or enter the payload and token manually.") + subtitle: String(localized: "settings.phone.pair.subtitleV2", defaultValue: "Opens a single-use, 5-minute pairing window. Scan the QR code with the Programa iOS app, or copy the code it shows.") ) { Button(String(localized: "settings.phone.pair.button", defaultValue: "Pair a Device…")) { beginMobileBridgePairing() @@ -1531,34 +1287,6 @@ struct SettingsView: View { } } - DisclosureGroup(String(localized: "settings.phone.pair.manualFallback", defaultValue: "Can't scan? Paste the payload and token manually")) { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.phone.pair.ticketLabel", defaultValue: "Pairing Payload")) - .font(.system(size: 12, weight: .semibold)) - Text(ticket) - .font(.system(size: 11, design: .monospaced)) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - - Text(String(localized: "settings.phone.pair.tokenLabel", defaultValue: "Pairing Token")) - .font(.system(size: 12, weight: .semibold)) - Text(token) - .font(.system(size: 11, design: .monospaced)) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - } - .padding(.top, 6) - } } .padding(.horizontal, 14) .padding(.vertical, 10) @@ -1820,36 +1548,13 @@ struct SettingsView: View { SettingsCardDivider() VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data")) - .font(.system(size: 13, weight: .semibold)) - - VStack(alignment: .leading, spacing: 6) { - Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) - .font(.system(size: 12.5, weight: .semibold)) - - Text(browserImportSubtitle) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) - .font(.system(size: 10.5)) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color(nsColor: .separatorColor).opacity(0.4), lineWidth: 1) - ) - } + // A mock of the blank-tab import hint used to be rendered here, + // reusing the hint's own strings -- including its footnote saying + // "You can always find this in Settings > Browser", shown inside + // Settings. The buttons below do the same job without restating + // the hint. The real card still lives in BrowserToolbarViews. + Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data")) + .font(.system(size: 13, weight: .semibold)) HStack(spacing: 8) { Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { @@ -2006,14 +1711,11 @@ struct SettingsView: View { showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit sessionPersistScrollback = ScrollbackPersistenceSettings.defaultPersistScrollback - commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces ShortcutHintDebugSettings.resetVisibilityDefaults() alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue - closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue - paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue sidebarSelectionColorHex = nil @@ -2033,28 +1735,9 @@ struct SettingsView: View { refreshDetectedImportBrowsers() KeyboardShortcutSettings.resetAll() WorkspaceTabColorSettings.reset() - reloadWorkspaceTabColorSettings() shortcutResetToken = UUID() } - private func baseTabColorHex(for name: String) -> String? { - WorkspaceTabColorSettings.defaultColorHex(named: name) - } - - private func removeWorkspaceColor(named name: String) { - WorkspaceTabColorSettings.removeColor(named: name) - reloadWorkspaceTabColorSettings() - } - - private func resetWorkspaceTabColors() { - WorkspaceTabColorSettings.reset() - reloadWorkspaceTabColorSettings() - } - - private func reloadWorkspaceTabColorSettings() { - workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() - } - private func saveBrowserInsecureHTTPAllowlist() { browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 731da39d..c0731211 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -56,20 +56,6 @@ enum WorkspaceAutoReorderSettings { } } -enum LastSurfaceCloseShortcutSettings { - static let key = "closeWorkspaceOnLastSurfaceShortcut" - // Keep the legacy stored meaning so existing values still map to the same - // behavior. The default is flipped to preserve current Cmd+W behavior. - static let defaultValue = true - - static func closesWorkspace(defaults: UserDefaults = .standard) -> Bool { - if defaults.object(forKey: key) == nil { - return defaultValue - } - return defaults.bool(forKey: key) - } -} - struct SidebarWorkspaceAuxiliaryDetailVisibility: Equatable { let showsMetadata: Bool let showsLog: Bool @@ -2232,8 +2218,7 @@ class TabManager: ObservableObject { } private func shouldCloseWorkspaceOnLastSurfaceShortcut(_ workspace: Workspace, panelId: UUID) -> Bool { - LastSurfaceCloseShortcutSettings.closesWorkspace() && - workspace.panels.count <= 1 && + workspace.panels.count <= 1 && workspace.panels[panelId] != nil } @@ -2267,8 +2252,8 @@ class TabManager: ObservableObject { ) #endif - // The last-surface shortcut preference only affects Cmd+W. The tab close button - // continues to use Workspace's explicit-close path when it closes the last surface. + // Only Cmd+W closes the workspace along with its last surface. The tab close + // button continues to use Workspace's explicit-close path instead. if closesWorkspaceOnLastSurfaceShortcut, let surfaceId = tab.surfaceIdFromPanelId(panelId) { tab.markExplicitClose(surfaceId: surfaceId) diff --git a/Sources/TerminalController+Debug.swift b/Sources/TerminalController+Debug.swift index 4a455b57..ded5dbec 100644 --- a/Sources/TerminalController+Debug.swift +++ b/Sources/TerminalController+Debug.swift @@ -345,33 +345,6 @@ extension TerminalController { return result } - func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult { - if let rawEnabled = params["enabled"] { - guard let enabled = rawEnabled as? Bool else { - return .err( - code: "invalid_params", - message: "enabled must be a bool", - data: ["enabled": rawEnabled] - ) - } - DispatchQueue.main.sync { - UserDefaults.standard.set( - enabled, - forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey - ) - } - } - - var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus - DispatchQueue.main.sync { - enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled() - } - - return .ok([ - "enabled": enabled - ]) - } - func v2DebugBrowserAddressBarFocused(params: [String: Any]) -> V2CallResult { let requestedSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "panel_id") var focusedSurfaceId: UUID? diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 18043b07..f05a918a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1988,8 +1988,6 @@ class TerminalController { return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params)) case "debug.command_palette.rename_input.selection": return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params)) - case "debug.command_palette.rename_input.select_all": - return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params)) case "debug.browser.address_bar_focused": return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params)) case "debug.browser.favicon": diff --git a/Sources/V2CommandCatalog.swift b/Sources/V2CommandCatalog.swift index cc4bc29b..a17ab0e1 100644 --- a/Sources/V2CommandCatalog.swift +++ b/Sources/V2CommandCatalog.swift @@ -228,7 +228,6 @@ enum V2CommandCatalog { "debug.command_palette.rename_input.interact", "debug.command_palette.rename_input.delete_backward", "debug.command_palette.rename_input.selection", - "debug.command_palette.rename_input.select_all", "debug.browser.address_bar_focused", "debug.browser.favicon", "debug.sidebar.visible", diff --git a/docs/testing-layout.md b/docs/testing-layout.md new file mode 100644 index 00000000..9d8265a3 --- /dev/null +++ b/docs/testing-layout.md @@ -0,0 +1,48 @@ +# Where tests live + +Four directories, because there are four harnesses. They are not versions of +each other, and `tests_v2` is not "the second attempt at `tests`". + +| Directory | Language | Needs a running app? | Run by | +|---|---|---|---| +| `tests/` | shell + python | no | `workflow-guard-tests`, CLI steps in `ci.yml` | +| `tests_v2/` | python | **yes** | `socket-integration-tests`, lag/perf jobs | +| `programaTests/` | Swift (XCTest) | no | `unit-tests` (`programa-unit` scheme) | +| `programaUITests/` | Swift (XCUITest) | launches its own | `ui-regressions` | + +## What `_v2` means + +The v2 **socket API** — the JSON-RPC surface in `Sources/V2CommandCatalog.swift`, +whose methods look like `debug.command_palette.visible` or `snapshot.restore`. +Those tests connect to a running Programa over its control socket and drive the +app through that API. The name is about the protocol, not about a previous +generation of tests. There is no `tests_v1`. + +## Which one to write in + +**`tests/`** — the thing under test is a script or a build artifact, and no app +is involved. Shell scripts here guard `scripts/*.sh`, the CI workflows, DMG +creation, and release assets. The three python files here drive the `programa` +CLI binary as a subprocess via `PROGRAMA_CLI_BIN`. + +**`tests_v2/`** — the thing under test is app behaviour you can observe over the +socket. Everything here talks to a live instance, whether through `cmux.py` or +by speaking JSON-RPC directly. The two lag/perf harnesses live here for that +reason, even though they bypass `cmux.py`. + +**`programaTests/`** — pure logic that can be exercised without launching +anything: policy types, decision functions, snapshot encoding, layout maths. +Cheapest and fastest, so prefer it when a behaviour can be reached this way. +When it cannot, add a small seam so it can, rather than reaching for a socket +test. See the test-quality policy in `CLAUDE.md`. + +**`programaUITests/`** — behaviour that only exists through real event +delivery and AppKit: clicks, drags, menus, focus. + +## Running them + +Per `CLAUDE.md`, tests are **not run locally** — they run on CI or the VM. +`xcodebuild -scheme programa-unit` is the one safe local exception because it +launches no app. `tests_v2` in particular will attach to whatever socket it +finds, which is why running it locally risks driving your real Programa +instance rather than a build under test. diff --git a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift b/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift index a48abaed..67503944 100644 --- a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift +++ b/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift @@ -1,9 +1,13 @@ import SwiftUI /// Screen 1: scan or paste the pairing code shown on Programa's Mac -/// Settings ▸ Phone screen, then connect. The legacy separate ticket/token -/// fields stay as a fallback for testers who can't scan or whose combined -/// code paste didn't parse. +/// Settings ▸ Phone screen, then connect. +/// +/// There is deliberately one way in. Separate ticket and token fields used to +/// sit below this as a legacy fallback, with their own Connect button, so the +/// screen offered three entry points and two buttons for a single action. The +/// Mac shows the same combined code it encodes in the QR, and pasting that is +/// the fallback for anyone who cannot scan. struct PairConnectView: View { @Bindable var store: AppStore @@ -36,10 +40,13 @@ struct PairConnectView: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() - Button(String(localized: "pairing.connect.useCodeButton", defaultValue: "Use This Code")) { + Button(String(localized: "pairing.connect.useCodeButton", defaultValue: "Connect")) { applyPairingCodeDraft() } - .disabled(pairingCodeDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled( + pairingCodeDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || store.isConnecting + ) if let pairingCodeError { Text(pairingCodeError) @@ -48,50 +55,6 @@ struct PairConnectView: View { } } - Section( - String( - localized: "pairing.connect.section.advanced", - defaultValue: "Advanced: paste ticket and token separately" - ) - ) { - TextField( - String( - localized: "pairing.connect.ticketField.placeholder", - defaultValue: "Paste the ticket from Programa's pairing screen" - ), - text: $store.pairingTicketDraft, - axis: .vertical - ) - .lineLimit(3 ... 8) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - TextField( - String(localized: "pairing.connect.tokenField.placeholder", defaultValue: "Only needed the first time"), - text: $store.pairingTokenDraft - ) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - Text( - String( - localized: "pairing.connect.tokenField.footnote", - defaultValue: "Once this device is paired it stays trusted — you won't need the token again." - ) - ) - .font(.footnote) - .foregroundStyle(.secondary) - } - - Section { - Button(String(localized: "pairing.connect.connectButton", defaultValue: "Connect")) { - Task { await store.connectManually() } - } - .disabled( - store.pairingTicketDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || store.isConnecting - ) - } - Section(String(localized: "pairing.connect.section.status", defaultValue: "Status")) { LabeledContent(String(localized: "State", defaultValue: "State"), value: store.connectionBanner.label) // The observed network path stays visible even on this diff --git a/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings b/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings index da0f8e32..6a93aa20 100644 --- a/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings +++ b/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings @@ -783,23 +783,6 @@ } } }, - "pairing.connect.connectButton": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connect" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続" - } - } - } - }, "pairing.connect.error.invalidPastedCode": { "extractionState": "manual", "localizations": { @@ -902,23 +885,6 @@ } } }, - "pairing.connect.section.advanced": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Advanced: paste ticket and token separately" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "詳細設定:チケットとトークンを個別に貼り付け" - } - } - } - }, "pairing.connect.section.code": { "extractionState": "manual", "localizations": { @@ -953,23 +919,6 @@ } } }, - "pairing.connect.ticketField.placeholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Paste the ticket from Programa's pairing screen" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Programaのペア設定画面からチケットを貼り付け" - } - } - } - }, "pairing.connect.title": { "extractionState": "manual", "localizations": { @@ -987,53 +936,19 @@ } } }, - "pairing.connect.tokenField.footnote": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Once this device is paired it stays trusted — you won't need the token again." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このデバイスは一度ペア設定されると信頼済みのままになり、次回からトークンは不要です。" - } - } - } - }, - "pairing.connect.tokenField.placeholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Only needed the first time" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "初回のみ必要です" - } - } - } - }, "pairing.connect.useCodeButton": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Use This Code" + "value": "Connect" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このコードを使用" + "value": "接続" } } } diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 7dee2f43..c05571f7 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -1468,69 +1468,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) } - func testCmdWKeepsLastSurfaceWorkspaceOpenWhenKeepWorkspaceOpenPreferenceIsEnabled() throws { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } - - let defaults = UserDefaults.standard - let originalSetting = defaults.object(forKey: appDelegateLastSurfaceCloseShortcutDefaultsKey) - defaults.set(false, forKey: appDelegateLastSurfaceCloseShortcutDefaultsKey) - defer { - if let originalSetting { - defaults.set(originalSetting, forKey: appDelegateLastSurfaceCloseShortcutDefaultsKey) - } else { - defaults.removeObject(forKey: appDelegateLastSurfaceCloseShortcutDefaultsKey) - } - } - - let windowId = appDelegate.createMainWindow() - defer { closeWindow(withId: windowId) } - - guard let targetWindow = window(withId: windowId), - let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let initialPanelId = workspace.focusedPanelId else { - XCTFail("Expected test window, manager, workspace, and focused panel") - return - } - - // This test exercises keep-workspace-open semantics, not close-confirm heuristics. - // Mark the shell idle so Cmd+W routes through the immediate close path deterministically. - workspace.updatePanelShellActivityState(panelId: initialPanelId, state: .promptIdle) - - guard let event = makeKeyDownEvent( - key: "w", - modifiers: [.command], - keyCode: 13, - windowNumber: targetWindow.windowNumber - ) else { - XCTFail("Failed to construct Cmd+W event") - return - } - -#if DEBUG - XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) -#else - XCTFail("debugHandleCustomShortcut is only available in DEBUG") -#endif - - waitUntil(description: "the closed panel to be removed from the workspace") { workspace.panels[initialPanelId] == nil } - - XCTAssertNotNil( - self.window(withId: windowId), - "Cmd+W should keep the window open when the keep-workspace-open preference is enabled" - ) - XCTAssertEqual(manager.tabs.count, 1) - XCTAssertEqual(manager.selectedTabId, workspace.id) - XCTAssertNil(workspace.panels[initialPanelId]) - XCTAssertEqual(workspace.panels.count, 1) - XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId) - } - func testCmdWClosesAuxiliaryWindowInsteadOfMainTerminalPanel() throws { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/programaTests/InactivePaneFirstClickFocusTests.swift b/programaTests/InactivePaneFirstClickFocusTests.swift index db275ae3..4ee1627b 100644 --- a/programaTests/InactivePaneFirstClickFocusTests.swift +++ b/programaTests/InactivePaneFirstClickFocusTests.swift @@ -8,65 +8,59 @@ import WebKit @testable import Programa #endif +/// Clicking into an inactive Programa window activates the window without also +/// focusing the pane under the pointer. That used to be a preference +/// (`paneFirstClickFocus.enabled`); it is now the only behaviour, so these +/// tests pin it rather than exercising both sides of a toggle. +/// +/// The three view classes are covered separately because each one used to +/// override `acceptsFirstMouse` on its own, and a future override on any of +/// them would silently reintroduce click-through. @MainActor final class InactivePaneFirstClickFocusTests: XCTestCase { - private let settingsKey = "paneFirstClickFocus.enabled" - - override func setUp() { - super.setUp() - UserDefaults.standard.removeObject(forKey: settingsKey) - } + /// Set by nothing now. Asserting the views ignore it is what proves the + /// preference is genuinely gone rather than merely hidden from Settings. + private let removedSettingsKey = "paneFirstClickFocus.enabled" override func tearDown() { - UserDefaults.standard.removeObject(forKey: settingsKey) + UserDefaults.standard.removeObject(forKey: removedSettingsKey) super.tearDown() } - func testTerminalViewAcceptsFirstMouseWhenSettingEnabled() { - UserDefaults.standard.set(true, forKey: settingsKey) - - let view = GhosttyNSView(frame: .zero) - - XCTAssertTrue(view.acceptsFirstMouse(for: nil)) - } - - func testTerminalViewRejectsFirstMouseWhenSettingDisabled() { - UserDefaults.standard.set(false, forKey: settingsKey) - + func testTerminalViewRejectsFirstMouse() { let view = GhosttyNSView(frame: .zero) XCTAssertFalse(view.acceptsFirstMouse(for: nil)) } - func testBrowserViewAcceptsFirstMouseWhenSettingEnabled() { - UserDefaults.standard.set(true, forKey: settingsKey) - - let view = ProgramaWebView(frame: .zero, configuration: WKWebViewConfiguration()) - - XCTAssertTrue(view.acceptsFirstMouse(for: nil)) - } - - func testBrowserViewRejectsFirstMouseWhenSettingDisabled() { - UserDefaults.standard.set(false, forKey: settingsKey) - + func testBrowserViewRejectsFirstMouse() { let view = ProgramaWebView(frame: .zero, configuration: WKWebViewConfiguration()) XCTAssertFalse(view.acceptsFirstMouse(for: nil)) } - func testMarkdownPointerObserverAcceptsFirstMouseWhenSettingEnabled() { - UserDefaults.standard.set(true, forKey: settingsKey) - + func testMarkdownPointerObserverRejectsFirstMouse() { let view = MarkdownPanelPointerObserverView(frame: .zero) - XCTAssertTrue(view.acceptsFirstMouse(for: nil)) + XCTAssertFalse(view.acceptsFirstMouse(for: nil)) } - func testMarkdownPointerObserverRejectsFirstMouseWhenSettingDisabled() { - UserDefaults.standard.set(false, forKey: settingsKey) + func testRemovedPreferenceNoLongerEnablesClickThrough() { + // The old key turning click-through back on is the regression this + // guards: leaving a live read behind would make the setting removal + // cosmetic for anyone whose settings.json still carries it. + UserDefaults.standard.set(true, forKey: removedSettingsKey) + + XCTAssertFalse(GhosttyNSView(frame: .zero).acceptsFirstMouse(for: nil)) + XCTAssertFalse( + ProgramaWebView(frame: .zero, configuration: WKWebViewConfiguration()).acceptsFirstMouse(for: nil) + ) + XCTAssertFalse(MarkdownPanelPointerObserverView(frame: .zero).acceptsFirstMouse(for: nil)) + } - let view = MarkdownPanelPointerObserverView(frame: .zero) + func testMarkdownPointerObserverStaysOutOfHitTesting() { + let view = MarkdownPanelPointerObserverView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) - XCTAssertFalse(view.acceptsFirstMouse(for: nil)) + XCTAssertNil(view.hitTest(NSPoint(x: 50, y: 50))) } } diff --git a/programaTests/ShortcutAndCommandPaletteTests.swift b/programaTests/ShortcutAndCommandPaletteTests.swift index 8fa9e8af..a34fe4e9 100644 --- a/programaTests/ShortcutAndCommandPaletteTests.swift +++ b/programaTests/ShortcutAndCommandPaletteTests.swift @@ -680,34 +680,6 @@ final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase { } -final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { - private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)" - - private func makeDefaults() -> UserDefaults { - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - return defaults - } - - func testDefaultsToSelectAllWhenUnset() { - let defaults = makeDefaults() - XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) - } - - func testReturnsFalseWhenStoredFalse() { - let defaults = makeDefaults() - defaults.set(false, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - XCTAssertFalse(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) - } - - func testReturnsTrueWhenStoredTrue() { - let defaults = makeDefaults() - defaults.set(true, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) - } -} - - final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { func testFirstEntryPinsToTopAnchor() { let anchor = ContentView.commandPaletteScrollPositionAnchor( @@ -1027,44 +999,6 @@ final class ShortcutHintHorizontalPlannerTests: XCTestCase { } -final class LastSurfaceCloseShortcutSettingsTests: XCTestCase { - func testDefaultClosesWorkspace() { - let suiteName = "LastSurfaceCloseShortcutSettingsTests.Default.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults)) - } - - func testStoredTrueClosesWorkspace() { - let suiteName = "LastSurfaceCloseShortcutSettingsTests.Enabled.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key) - XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults)) - } - - func testStoredFalseKeepsWorkspaceOpen() { - let suiteName = "LastSurfaceCloseShortcutSettingsTests.Disabled.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(false, forKey: LastSurfaceCloseShortcutSettings.key) - XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults)) - } -} - - final class AppearanceSettingsTests: XCTestCase { func testResolvedModeDefaultsToSystemWhenUnset() { let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" diff --git a/programaTests/TabManagerUnitTests.swift b/programaTests/TabManagerUnitTests.swift index c3909ff5..48677218 100644 --- a/programaTests/TabManagerUnitTests.swift +++ b/programaTests/TabManagerUnitTests.swift @@ -1050,6 +1050,42 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase { XCTAssertTrue(secondWorkspace.panels.isEmpty) } + // Mirrors testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface, with the + // removed preference key written first. Cmd+W closing the workspace along with + // its last surface is no longer configurable, and a stale settings.json entry + // must not quietly bring the old behaviour back. + func testCloseCurrentPanelClosesWorkspaceEvenWhenRemovedKeepOpenKeyIsSet() { + let defaults = UserDefaults.standard + let original = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey) + defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey) + defer { + if let original { + defaults.set(original, forKey: lastSurfaceCloseShortcutDefaultsKey) + } else { + defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey) + } + } + + let manager = TabManager() + let firstWorkspace = manager.tabs[0] + let secondWorkspace = manager.addWorkspace() + manager.selectWorkspace(secondWorkspace) + + guard let secondPanelId = secondWorkspace.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + manager.closeCurrentPanelWithConfirmation() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) + XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) + XCTAssertNil(secondWorkspace.panels[secondPanelId]) + XCTAssertTrue(secondWorkspace.panels.isEmpty) + } + func testCloseCurrentPanelPromptsBeforeClosingPinnedWorkspaceLastSurface() { let manager = TabManager() _ = manager.tabs[0] @@ -1119,39 +1155,6 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase { XCTAssertTrue(pinnedWorkspace.panels.isEmpty) } - func testCloseCurrentPanelKeepsWorkspaceOpenWhenKeepWorkspaceOpenPreferenceIsEnabled() { - let defaults = UserDefaults.standard - let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey) - defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey) - defer { - if let originalSetting { - defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey) - } else { - defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey) - } - } - - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let initialPanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace and focused panel") - return - } - - let initialWorkspaceId = workspace.id - - manager.closeCurrentPanelWithConfirmation() - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.count, 1) - XCTAssertEqual(manager.selectedTabId, initialWorkspaceId) - XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId) - XCTAssertNil(workspace.panels[initialPanelId]) - XCTAssertEqual(workspace.panels.count, 1) - XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId) - } - func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() { let manager = TabManager() let firstWorkspace = manager.tabs[0] @@ -1182,44 +1185,6 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase { XCTAssertTrue(secondWorkspace.panels.isEmpty) } - func testClosePanelButtonStillClosesWorkspaceWhenKeepWorkspaceOpenPreferenceIsEnabled() { - let defaults = UserDefaults.standard - let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey) - defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey) - defer { - if let originalSetting { - defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey) - } else { - defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey) - } - } - - let manager = TabManager() - let firstWorkspace = manager.tabs[0] - let secondWorkspace = manager.addWorkspace() - manager.selectWorkspace(secondWorkspace) - - guard let secondPanelId = secondWorkspace.focusedPanelId else { - XCTFail("Expected focused panel in selected workspace") - return - } - - guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else { - XCTFail("Expected bonsplit surface ID for focused panel") - return - } - - secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId) - XCTAssertFalse(secondWorkspace.closePanel(secondPanelId)) - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) - XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) - XCTAssertNil(secondWorkspace.panels[secondPanelId]) - XCTAssertTrue(secondWorkspace.panels.isEmpty) - } - func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() { let manager = TabManager() guard let workspace = manager.selectedWorkspace, diff --git a/tests_v2/cmux.py b/tests_v2/cmux.py index 6a411617..4edc1862 100755 --- a/tests_v2/cmux.py +++ b/tests_v2/cmux.py @@ -951,14 +951,6 @@ def command_palette_results(self, window_id: str, limit: int = 20) -> dict: ) or {} return dict(res) - def command_palette_rename_select_all(self) -> bool: - res = self._call("debug.command_palette.rename_input.select_all") or {} - return bool(res.get("enabled")) - - def set_command_palette_rename_select_all(self, enabled: bool) -> bool: - res = self._call("debug.command_palette.rename_input.select_all", {"enabled": bool(enabled)}) or {} - return bool(res.get("enabled")) - def is_terminal_focused(self, panel: Union[str, int]) -> bool: sid = self._resolve_surface_id(panel) res = self._call("debug.terminal.is_focused", {"surface_id": sid}) or {} diff --git a/tests_v2/test_command_palette_backspace_go_back.py b/tests_v2/test_command_palette_backspace_go_back.py index 3504c9fe..e48390dd 100644 --- a/tests_v2/test_command_palette_backspace_go_back.py +++ b/tests_v2/test_command_palette_backspace_go_back.py @@ -77,10 +77,9 @@ def main(): time.sleep(0.2) window_id = client.current_window() - original_select_all = client.command_palette_rename_select_all() - try: - client.set_command_palette_rename_select_all(True) + # Rename always opens with the existing name selected now, so this + # no longer has to pin a setting to get a deterministic starting state. _open_rename_input(client, window_id) _wait_until( @@ -138,11 +137,6 @@ def main(): raise cmuxError("palette closed unexpectedly instead of navigating back to command list") finally: - try: - client.set_command_palette_rename_select_all(original_select_all) - except Exception: - pass - if _palette_visible(client, window_id): client._call("debug.command_palette.toggle", {"window_id": window_id}) _wait_until( diff --git a/tests_v2/test_command_palette_rename_select_all.py b/tests_v2/test_command_palette_rename_select_all.py index 63e9c07d..252c990a 100644 --- a/tests_v2/test_command_palette_rename_select_all.py +++ b/tests_v2/test_command_palette_rename_select_all.py @@ -3,8 +3,12 @@ Regression test: command-palette rename input keeps select-all on interaction. Coverage: -- With select-all setting enabled, rename input selects all existing text - immediately and stays selected after interaction. +- Rename input selects all existing text immediately on open, and the + selection survives interaction with the field. + +Rename selecting the existing name used to be a setting. It is now the only +behaviour, so this exercises it directly instead of toggling +`debug.command_palette.rename_input.select_all`, which no longer exists. """ import os @@ -37,26 +41,7 @@ def _rename_input_selection(client, window_id): return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} -def _rename_select_all_setting(client): - payload = client._call("debug.command_palette.rename_input.select_all", {}) or {} - return bool(payload.get("enabled")) - - -def _set_rename_select_all_setting(client, enabled): - payload = client._call( - "debug.command_palette.rename_input.select_all", - {"enabled": bool(enabled)}, - ) or {} - return bool(payload.get("enabled")) - - -def _wait_for_rename_selection( - client, - window_id, - expect_select_all, - message, - timeout_s=0.6, -): +def _wait_for_select_all(client, window_id, message, timeout_s=0.6): def _matches(): selection = _rename_input_selection(client, window_id) if not selection.get("focused"): @@ -64,40 +49,49 @@ def _matches(): text_length = int(selection.get("text_length") or 0) selection_location = int(selection.get("selection_location") or 0) selection_length = int(selection.get("selection_length") or 0) - if expect_select_all: - return text_length > 0 and selection_location == 0 and selection_length == text_length - return selection_location == text_length and selection_length == 0 + return text_length > 0 and selection_location == 0 and selection_length == text_length _wait_until(_matches, timeout_s=timeout_s, message=message) -def _exercise_rename_selection_setting( - client, - window_id, - expect_select_all, - cycles, - label, -): +def _open_rename_tab_input(client, window_id): + client.activate_app() + client.focus_window(window_id) + time.sleep(0.1) + + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close before setup", + ) + + client.open_command_palette_rename_tab_input(window_id=window_id) + _wait_until( + lambda: _palette_visible(client, window_id), + message="command palette failed to open rename-tab input", + ) + + +def _exercise_rename_selection(client, window_id, cycles): for cycle in range(cycles): _open_rename_tab_input(client, window_id) - _wait_for_rename_selection( + _wait_for_select_all( client, window_id, - expect_select_all=expect_select_all, timeout_s=0.4, message=( - f"{label}: rename input not ready with expected selection " - f"on open (cycle {cycle + 1}/{cycles})" + "rename input not ready with the name selected on open " + f"(cycle {cycle + 1}/{cycles})" ), ) client._call("debug.command_palette.rename_input.interact", {"window_id": window_id}) - _wait_for_rename_selection( + _wait_for_select_all( client, window_id, - expect_select_all=expect_select_all, timeout_s=0.6, message=( - f"{label}: rename input selection changed after interaction " + "rename input selection changed after interaction " f"(cycle {cycle + 1}/{cycles})" ), ) @@ -106,36 +100,15 @@ def _exercise_rename_selection_setting( client._call("debug.command_palette.toggle", {"window_id": window_id}) _wait_until( lambda: not _palette_visible(client, window_id), - message=f"{label}: command palette failed to close (cycle {cycle + 1}/{cycles})", + message=f"command palette failed to close (cycle {cycle + 1}/{cycles})", ) -def _open_rename_tab_input(client, window_id): - client.activate_app() - client.focus_window(window_id) - time.sleep(0.1) - - if _palette_visible(client, window_id): - client._call("debug.command_palette.toggle", {"window_id": window_id}) - _wait_until( - lambda: not _palette_visible(client, window_id), - message="command palette failed to close before setup", - ) - - client.open_command_palette_rename_tab_input(window_id=window_id) - _wait_until( - lambda: _palette_visible(client, window_id), - message="command palette failed to open rename-tab input", - ) - - def main(): with cmux(SOCKET_PATH) as client: client.activate_app() time.sleep(0.2) - original_select_all = _rename_select_all_setting(client) - workspace_id = client.new_workspace() client.select_workspace(workspace_id) client.rename_workspace("SeedName", workspace_id) @@ -143,33 +116,8 @@ def main(): window_id = client.current_window() try: - stress_cycles = 8 - - # ON: immediate select-all and interaction-preserved select-all. - _set_rename_select_all_setting(client, True) - _exercise_rename_selection_setting( - client, - window_id, - expect_select_all=True, - cycles=stress_cycles, - label="select-all enabled", - ) - - # OFF: immediate caret-at-end and interaction-preserved caret-at-end. - _set_rename_select_all_setting(client, False) - _exercise_rename_selection_setting( - client, - window_id, - expect_select_all=False, - cycles=stress_cycles, - label="select-all disabled", - ) - + _exercise_rename_selection(client, window_id, cycles=8) finally: - try: - _set_rename_select_all_setting(client, original_select_all) - except Exception: - pass if _palette_visible(client, window_id): client._call("debug.command_palette.toggle", {"window_id": window_id}) _wait_until( @@ -177,7 +125,7 @@ def main(): message="command palette failed to close during cleanup", ) - print("PASS: command-palette rename input obeys select-all setting (on/off)") + print("PASS: command-palette rename input selects the existing name and keeps it") return 0 diff --git a/tests/test_text_input_refresh_cost.py b/tests_v2/test_text_input_refresh_cost.py similarity index 96% rename from tests/test_text_input_refresh_cost.py rename to tests_v2/test_text_input_refresh_cost.py index 84403017..9bd26d14 100644 --- a/tests/test_text_input_refresh_cost.py +++ b/tests_v2/test_text_input_refresh_cost.py @@ -3,7 +3,7 @@ Measuring instrument (not a regression gate): in-process cost of the text-input refresh call on the keyDown path. -tests/test_workspace_churn_up_arrow_lag.py measures a full socket RPC round trip +tests_v2/test_workspace_churn_up_arrow_lag.py measures a full socket RPC round trip (IPC + JSON + DispatchQueue.main.sync + AppKit dispatch + the work under test). That transport floor sits in the low milliseconds, which is too coarse to resolve changes to a single call inside keyDown (e.g. swapping forceRefresh() for a @@ -41,7 +41,7 @@ import time from typing import Optional -# Mirrors tests/test_workspace_churn_up_arrow_lag.py: speak the v2 JSON-RPC +# Mirrors tests_v2/test_workspace_churn_up_arrow_lag.py: speak the v2 JSON-RPC # protocol directly for the tight simulate-keystroke loop, and reuse # tests_v2/cmux.py only for its error type. _TESTS_V2_DIR = os.path.normpath( @@ -65,7 +65,7 @@ class RawSocketClient: """Minimal v2 JSON-RPC client for the simulate-keystroke loop. Copied in shape from RawSocketClient in - tests/test_workspace_churn_up_arrow_lag.py: skips the full tests_v2/cmux.py + tests_v2/test_workspace_churn_up_arrow_lag.py: skips the full tests_v2/cmux.py client's id-resolution helpers so per-call overhead stays minimal. Not shared with that file directly since it is out of scope for this change. """ @@ -142,7 +142,7 @@ def call(self, method: str, params: Optional[dict] = None, timeout_s: float = 2. def resolve_target_socket() -> str: - # Same refusal as tests/test_workspace_churn_up_arrow_lag.py::resolve_target_socket: + # Same refusal as tests_v2/test_workspace_churn_up_arrow_lag.py::resolve_target_socket: # never target the main/untagged socket from an automated harness. socket_path = os.environ.get("PROGRAMA_SOCKET_PATH") if not socket_path: diff --git a/tests/test_workspace_churn_up_arrow_lag.py b/tests_v2/test_workspace_churn_up_arrow_lag.py similarity index 100% rename from tests/test_workspace_churn_up_arrow_lag.py rename to tests_v2/test_workspace_churn_up_arrow_lag.py