"""
Main Window for SSH Pilot
Primary UI with connection list, tabs, and terminal management
"""

from __future__ import annotations

from .api.connection_identity import connection_id_for
import copy
import os
import logging
import shlex
import shutil  # noqa: F401  patched as sshpilot.window.shutil by tests
import sys
import traceback
from datetime import datetime
from typing import TYPE_CHECKING, Optional, Dict, Any, List

if TYPE_CHECKING:
    from .api.models.connections import ConnectionSummary
    from .command_blocks import CommandBlocksPanel, CommandBlockStore
    from .terminal import TerminalWidget


import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
try:
    gi.require_version('Vte', '3.91')
    _HAS_VTE = True
except Exception:
    _HAS_VTE = False

gi.require_version('PangoFT2', '1.0')
from gi.repository import Gtk, Adw, Gio, GLib, Gdk
import threading

# Feature detection for libadwaita versions across distros
HAS_NAV_SPLIT = hasattr(Adw, 'NavigationSplitView')
HAS_OVERLAY_SPLIT = hasattr(Adw, 'OverlaySplitView')
HAS_TIMED_ANIMATION = hasattr(Adw, 'TimedAnimation')

from gettext import gettext as _

from .connection_model import Connection, ConnectionState
from .gtk.connection_store import ConnectionPresentationStore
from .gtk.daemon_connection_services import DaemonConnectionServices
from .gtk.connection_runtime_status import ConnectionRuntimeStatusStore
from .config import Config
from .key_manager import KeyManager
from sshpilot.api.models.keys import KeyStoreScope
from sshpilot.api.models.daemon import OperationMode, SetOperationModeRequest
from .update_checker import check_for_updates_async
from .connection_display import (
    get_connection_alias,
    get_connection_host,
    format_connection_host_display,
)
from .connection_sort import (
    CONNECTION_SORT_PRESETS,
    DEFAULT_CONNECTION_SORT,
    apply_connection_sort as apply_sort_to_manager,
)
# Port forwarding UI is now integrated into connection_dialog.py
# ConnectionDialog is imported lazily at its use site (show_connection_dialog) so
# the connection-dialog module stays off the startup import path.
from .file_manager_integration import should_hide_file_manager_options
# SshCopyIdRunner/SshCopyIdWindow and ScpWindowController are imported lazily
# (see the sshcopyid_runner / scp_controller properties and their use sites) so
# the sshcopyid_window and scp_window modules stay off the startup import path.
from .groups import GroupManager
from .session_manager import SessionManager
from .sidebar import (
    GroupRow,
    ConnectionRow,
    build_sidebar,
    install_sidebar_css,
    reset_connection_list_drag_session,
)

from .welcome_page import WelcomePage
from .actions import WindowActions, register_window_actions
from .window_broadcast import WindowBroadcastMixin
from .window_session import WindowSessionMixin
from .window_help import WindowHelpMixin
from .window_file_manager import WindowFileManagerMixin
from .window_tabs import WindowTabsMixin
from .window_fullscreen import WindowFullscreenController
from .shortcut_utils import TOGGLE_FULLSCREEN_ACTION
from .window_dialogs import (
    WindowConfigDialogsMixin,
    resolve_app_modal_parent,  # noqa: F401  re-exported for tests / other modules
    present_for_modal_dialog,
    show_ssh_password_dialog,
)
from . import shutdown
from .search_utils import connection_matches
from .shortcut_utils import (
    DOUBLE_SHIFT_SHORTCUT,
    DoubleShiftDetector,
    get_primary_modifier_label,
)
from .platform_utils import (
    get_default_terminal_command,
    get_user_preferred_terminal,
    find_any_terminal,
    open_system_terminal,
)
from .plugins.api import Capability as PluginCapability
from .api.capabilities import Capability as ApiCapability
from .plugins.registry import capabilities_for
logger = logging.getLogger(__name__)


def _describe_daemon_start_failure(reason: str) -> str:
    """Human-readable sentence for a ``DaemonStartupFailure`` reason string.

    Falls back to a generic sentence for reasons this map does not know
    about (including plain exception type names) instead of leaking the
    internal enum value into user-facing text.
    """
    from .daemon.launcher import DaemonStartupFailure

    messages = {
        DaemonStartupFailure.PROCESS_EXITED.value: _(
            "The background service exited unexpectedly during startup."
        ),
        DaemonStartupFailure.STARTUP_TIMEOUT.value: _(
            "The background service did not become ready in time."
        ),
        DaemonStartupFailure.HANDSHAKE_FAILED.value: _(
            "The background service did not respond correctly during startup."
        ),
        DaemonStartupFailure.INCOMPATIBLE_PROTOCOL.value: _(
            "A running background service uses an incompatible protocol version."
        ),
        DaemonStartupFailure.API_VERSION_MISMATCH.value: _(
            "A background service from a different app version is running and "
            "could not be replaced."
        ),
        DaemonStartupFailure.MISSING_CAPABILITY.value: _(
            "The running background service does not support required features."
        ),
        DaemonStartupFailure.UNSAFE_SOCKET.value: _(
            "The background service socket location is not safe to use."
        ),
        DaemonStartupFailure.INTERNAL_ERROR.value: _(
            "The background service could not be started."
        ),
    }
    return messages.get(reason, _("The background service could not be started."))


_tips_banner_css_installed = False


def _ensure_tips_banner_css() -> None:
    """Install the accent (Adwaita blue) styling for the terminal tips banner.

    The banner is a plain Gtk.Box (``.tips-banner``) inside a Gtk.Revealer, so we
    paint the box and recolor its inline buttons. @accent_bg_color /
    @accent_fg_color follow the user's accent + light/dark theme. Installed once
    per display, mirroring split_view's CSS helper.
    """
    global _tips_banner_css_installed
    if _tips_banner_css_installed:
        return
    display = Gdk.Display.get_default()
    if display is None:
        return
    provider = Gtk.CssProvider()
    provider.load_from_data(b"""
.tips-banner {
    background-color: @accent_bg_color;
    background-image: none;
    color: @accent_fg_color;
    padding: 6px 12px;
}
""")
    Gtk.StyleContext.add_provider_for_display(
        display,
        provider,
        Gtk.STYLE_PROVIDER_PRIORITY_USER,
    )
    _tips_banner_css_installed = True


def maybe_set_native_controls(header_bar: Gtk.HeaderBar, value: bool = False) -> None:
    """
    Safely set native controls on header bar, with fallback for older GTK versions.
    Only affects macOS and requires GTK 4.18+. GTK will handle title buttons by default.
    """
    # Only exists in GTK 4.18+
    is_418_plus = (
        Gtk.get_major_version() > 4 or
        (Gtk.get_major_version() == 4 and Gtk.get_minor_version() >= 18)
    )

    if sys.platform == "darwin" and is_418_plus:
        try:
            header_bar.set_use_native_controls(value)
        except AttributeError:
            pass  # extra safety in case of odd bindings
_get_connection_host = get_connection_host
_get_connection_alias = get_connection_alias
_format_connection_host_display = format_connection_host_display

# Width of the minimal (icon-only) sidebar strip.
_MINIMAL_STRIP_WIDTH = 64


def _effective_max_sidebar_width(saved_value, default: int = 400) -> int:
    """Resolve the startup max sidebar width from a saved setting value.

    Returns the saved width when it is a valid integer, otherwise ``default``.
    Kept as a module-level pure function so the parsing/fallback logic is unit
    testable without building the GTK window.
    """
    if saved_value is None:
        return default
    try:
        return int(saved_value)
    except (TypeError, ValueError):
        logger.warning("Invalid ui.max-sidebar-width %r; using default %d", saved_value, default)
        return default


def _accelerator_label(accel: str) -> str:
    """Human-readable form of a GTK accelerator ("F11", "⌃⌘F"), for tooltips."""
    try:
        ok, keyval, mods = Gtk.accelerator_parse(accel)
        if ok and keyval:
            return Gtk.accelerator_get_label(keyval, mods)
    except Exception:
        pass
    return accel


@Gtk.Template(resource_path="/io/github/mfat/sshpilot/ui/window.ui")
class MainWindow(Adw.ApplicationWindow, WindowBroadcastMixin, WindowSessionMixin, WindowHelpMixin, WindowFileManagerMixin, WindowTabsMixin, WindowConfigDialogsMixin, WindowActions):
    """Main application window"""

    __gtype_name__ = "SshPilotMainWindow"

    toast_overlay = Gtk.Template.Child()
    main_box = Gtk.Template.Child()

    def __init__(self, *args, isolated: bool = False, **kwargs):
        super().__init__(*args, **kwargs)

        # Icon theme path is already registered in main.py load_resources()
        # No need to register again here

        self.active_terminals = {}
        self.connections = []
        self._is_quitting = False  # Flag to prevent multiple quit attempts
        self._is_controlled_reconnect = False  # Flag to track controlled reconnection
        self._internal_file_manager_windows: List[Any] = []

        # Command Blocks panel (right-side sidebar)
        self.command_block_store: Optional[CommandBlockStore] = None
        self.command_blocks_panel: Optional[CommandBlocksPanel] = None
        self.cmd_split_view = None
        self._command_sidebar_visible: bool = False
        self._cmd_blocks_toggle_btn = None
        self._headerbar_theme_menu_button = None

        # Update notification
        self.update_banner = None
        self._latest_version = None

        # Initialize managers
        app = self.get_application()
        app_config = getattr(app, 'config', None) if app else None
        if app_config is not None:
            self.config = app_config
        else:
            self.config = Config()
            if app is not None:
                setattr(app, 'config', self.config)
        self._config_changed_handler = None
        self._startup_tasks_scheduled = False
        self._startup_complete = False
        self._initial_connection_list_focus_done = False
        self._start_tab_focus_idle_id = None
        self._pending_focus_operations = []
        if hasattr(self.config, 'connect'):
            try:
                self._config_changed_handler = self.config.connect('setting-changed', self._on_config_setting_changed)
            except Exception:
                self._config_changed_handler = None
        # Start with a conservative semantic scope only until the daemon
        # confirms its current operation mode.  GTK never reads config.json to
        # select an SSH root; ``_refresh_operation_mode_scope`` replaces this
        # projection after the typed client is attached.
        self._requested_operation_mode = (
            OperationMode.ISOLATED if isolated else None
        )
        self._confirmed_operation_mode = None
        self._daemon_client_generation = 0
        self._key_scope = KeyStoreScope.DEFAULT
        self.key_manager = None
        # Compatibility attribute while call sites migrate terminology. This is
        # a DTO projection, never a persistence-capable backend manager.
        self.connection_manager = ConnectionPresentationStore(
            dispatch=lambda callback: GLib.idle_add(
                lambda: (callback(), GLib.SOURCE_REMOVE)[1]
            )
        )
        self.plugin_connection_services = DaemonConnectionServices(
            self.connection_manager
        )
        self.connection_runtime_status = ConnectionRuntimeStatusStore(
            dispatch=lambda callback: GLib.idle_add(
                lambda: (callback(), GLib.SOURCE_REMOVE)[1]
            ),
            on_changed=self._on_runtime_connection_status_changed,
        )

        # Lazy, cached, off-main-thread check of each connection against its
        # effective SSH config (shows a warning icon on mismatched rows).
        from .effective_config_check import EffectiveConfigChecker
        self.effective_config_checker = EffectiveConfigChecker(
            self.connection_manager,
            on_result=self._on_effective_config_result,
            client_provider=lambda: getattr(self, "client", None),
        )

        # Menu section that plugin pages append to (built before create_menu
        # runs during setup_ui; the host mutates it on bind).
        self._plugins_menu_section = Gio.Menu()

        # Load plugins once per process (cached on the application object so a
        # second window doesn't re-activate them) before any terminal can spawn.
        # The PluginHost (event bus + UI host) is likewise process-wide; it is
        # bound to the first window's live UI at the end of setup_ui.
        try:
            from .plugins.loader import load_plugins
            from .plugins.host import PluginHost
            host = getattr(app, 'plugin_host', None) if app else None
            if host is None:
                host = PluginHost(
                    connection_manager=self.plugin_connection_services
                )
                if app is not None:
                    setattr(app, 'plugin_host', host)
            self.plugin_host = host
            if app is not None:
                setattr(app, 'plugin_host', host)
            loaded_plugins = getattr(app, 'loaded_plugins', None) if app else None
            if loaded_plugins is None:
                loaded_plugins = load_plugins(
                    app_config=self.config,
                    connection_manager=self.plugin_connection_services,
                    plugin_host=host,
                )
                if app is not None:
                    setattr(app, 'loaded_plugins', loaded_plugins)
            self.loaded_plugins = loaded_plugins
        except Exception:
            logger.exception("Plugin loading failed")
            self.loaded_plugins = []
            self.plugin_host = None

        self.group_manager = GroupManager(
            self.config,
            connection_manager=self.connection_manager,
        )
        self.session_manager = SessionManager(
            self.config,
            connection_manager=self.connection_manager,
        )
        self.client_bridge = None
        self._api_client_selection_pending = False
        self._api_client_selection_request = None
        self._client_mode_warning = None
        self._compose_api_client(app)
        if self.client is not None:
            self._attach_client_backed_services()

        # UI state
        self.active_terminals: Dict[Connection, TerminalWidget] = {}  # most recent terminal per connection
        self.connection_to_terminals: Dict[Connection, List[TerminalWidget]] = {}
        self.terminal_to_connection: Dict[TerminalWidget, Connection] = {}
        self.connection_rows = {}   # connection -> [row_widget, ...] (a connection may appear in several groups)
        self._sidebar_minimal = False   # icon-only strip state
        self._sidebar_overlay = False   # overlay (covers content) vs side-by-side
        self._sidebar_width_animation = None
        self._context_menu_row = None
        self._context_menu_popover = None
        # Hide hosts toggle state
        try:
            self._hide_hosts = bool(self.config.get_setting('ui.hide_hosts', False))
        except Exception:
            self._hide_hosts = False

        # Active tag filter (casefolded tag key), or None for all connections
        self._tag_filter = None

        # Remember last chosen sort preset
        try:
            stored_sort = str(self.config.get_setting('ui.connection_sort_last', DEFAULT_CONNECTION_SORT))
        except Exception:
            stored_sort = DEFAULT_CONNECTION_SORT
        if stored_sort not in CONNECTION_SORT_PRESETS:
            stored_sort = DEFAULT_CONNECTION_SORT
        self._connection_sort_last = stored_sort
        self.sort_button = None

        # Set up window
        self.setup_window()
        self.setup_ui()
        # Fullscreen is window state: exactly one controller per window owns the
        # saved chrome, the F11/Escape controllers and the top-edge exit
        # controls, so a closing terminal can never strand the window
        # fullscreen (issue #1102).
        self.fullscreen_controller = WindowFullscreenController(self)
        self.fullscreen_controller.install()
        self._setup_omnisearch_shortcut()
        self.setup_connections()
        self.setup_signals()
        # Authoritative SSH files are monitored by the daemon; GTK refreshes
        # from connection events and never installs a filesystem watcher.

        # Apply the persisted sidebar mode (full / minimal icon strip).
        try:
            if str(self.config.get_setting('ui.sidebar_mode', 'full')).lower() == 'minimal':
                self.set_sidebar_minimal(True, animate=False)
        except Exception:
            logger.debug("apply startup sidebar mode failed", exc_info=True)

        # Terminal manager handles terminal-related operations (import deferred so
        # terminal.py stays off the window module import path until __init__).
        from .terminal_manager import TerminalManager
        self.terminal_manager = TerminalManager(self)
        self._sshcopyid_runner = None  # built lazily via the sshcopyid_runner property
        self._scp_controller = None  # built lazily via the scp_controller property

        # Add action for activating connections
        self.activate_action = Gio.SimpleAction.new('activate-connection', None)
        self.activate_action.connect('activate', self.on_activate_connection)
        self.add_action(self.activate_action)

        # Register remaining window actions
        register_window_actions(self)
        # (Toasts disabled) Remove any toast-related actions if previously defined
        try:
            if hasattr(self, '_toast_reconnect_action'):
                self.remove_action('toast-reconnect')
        except Exception:
            pass

        # Connect to close request signal
        self.connect('close-request', self.on_close_request)

        # Start with welcome view (tab view setup already shows welcome initially)

        # Schedule deferred startup behaviors
        self._schedule_startup_tasks()
        if self._api_client_selection_pending:
            self._begin_daemon_client_selection()
        elif self._client_mode_warning:
            GLib.idle_add(self._show_client_mode_warning)

        logger.info("Main window initialized")

    def _compose_api_client(self, app) -> None:
        """Select and store the client; never attach client-backed services.

        Attaching happens exactly once per selection: ``__init__`` does it for
        an already-selected client and ``_apply_client_selection`` does it for a
        newly completed asynchronous selection. Keeping attachment out of this
        method prevents the duplicate attach path (services were attached here
        *and* again in ``__init__``).
        """
        existing = getattr(app, '_api_client_selection', None) if app else None
        if existing is not None:
            self.client = existing.client
            self.client_bridge = getattr(app, '_api_client_bridge', None)
            return
        from .gtk_client_bridge import GtkClientBridge
        self.client = None
        self.client_bridge = GtkClientBridge()
        if app is not None:
            app._api_client_bridge = self.client_bridge
        self._api_client_selection_pending = True

    def _attach_client_backed_services(self) -> None:
        """Attach every client-backed frontend service for the active daemon client.

        Called for both an already-selected client (``_compose_api_client``) and
        a newly completed asynchronous selection (``_apply_client_selection``).
        The key manager is only ever built over the daemon client; when no
        client is available it stays ``None`` and there is no local fallback.
        """
        if self.client is None:
            self.key_manager = None
            return
        self.connection_manager.attach_client(self.client)
        self.group_manager.attach_client(self.client)
        from .gtk.group_store import GroupMutationController
        if getattr(self, "_group_mutation_controller", None) is None:
            self._group_mutation_controller = GroupMutationController(
                self.client,
                bridge=self.client_bridge,
                refresh=self.connection_manager.refresh,
                dispatch=lambda callback: GLib.idle_add(
                    lambda: (callback(), GLib.SOURCE_REMOVE)[1]
                ),
            )
            self.group_manager.set_mutation_controller(self._group_mutation_controller)
        self.connection_runtime_status.attach_client(self.client)
        self.plugin_connection_services.attach_client(self.client)
        # Key operations stay unavailable until the daemon confirms its
        # semantic mode; never construct a DEFAULT manager during a delayed
        # isolated-mode status response.
        self.key_manager = (
            KeyManager(self.client, self._key_scope)
            if self._confirmed_operation_mode is not None
            else None
        )
        checker = getattr(self, "effective_config_checker", None)
        if checker is not None:
            checker.invalidate()
        self._refresh_operation_mode_scope()
        self.secrets_controller = self._build_secrets_controller()
        self._attach_secrets_interaction_presenter()

    def _replace_daemon_client(self, client) -> None:
        """Atomically rebind every long-lived frontend service to *client*.

        Reconnect used to update the projection and runtime status objects in
        isolation.  Controllers kept by Preferences, plugins, groups, keys,
        and secret interaction routing could therefore continue using the
        closed transport.  This is the one replacement lifecycle used by the
        application after a transport replacement; initial attachment still
        uses :meth:`_attach_client_backed_services` directly.
        """
        if client is None:
            raise ValueError("a daemon client is required")
        if client is getattr(self, "client", None):
            return

        old_client = getattr(self, "client", None)
        old_generation = getattr(self, "_daemon_client_generation", 0)
        controller = getattr(self, "_group_mutation_controller", None)
        if controller is not None:
            controller.close()
            self._group_mutation_controller = None
        presenter = getattr(self, "_secrets_interaction_presenter", None)
        if presenter is not None:
            presenter.close()
            self._secrets_interaction_presenter = None

        # Do not expose DEFAULT-scope key operations while the new daemon's
        # mode is still unconfirmed. The async status request below recreates
        # the manager only after the daemon confirms its semantic scope.
        self.client = client
        self._daemon_client_generation = old_generation + 1
        self._confirmed_operation_mode = None
        self.key_manager = None
        try:
            self._attach_client_backed_services()
            terminal_manager = getattr(self, "terminal_manager", None)
            if terminal_manager is not None:
                terminal_manager.rebind_client(client)
        except Exception:
            # Rebind is a prepare/commit operation.  If a dependent service
            # rejects the candidate, restore a complete old-client graph and
            # leave publication to the application layer untouched.
            logger.warning("Daemon client rebind failed; restoring old client", exc_info=True)
            failed_controller = getattr(self, "_group_mutation_controller", None)
            if failed_controller is not None:
                failed_controller.close()
            failed_presenter = getattr(self, "_secrets_interaction_presenter", None)
            if failed_presenter is not None:
                failed_presenter.close()
            self.client = old_client
            self._daemon_client_generation = old_generation
            self._confirmed_operation_mode = None
            self.key_manager = None
            if old_client is not None:
                self._attach_client_backed_services()
            raise

        preferences = getattr(self, "_preferences_window", None)
        if preferences is not None:
            preferences.set_ssh_overrides_controller(
                self._build_ssh_overrides_controller()
            )
            reset = getattr(preferences, "reset_operation_mode_confirmation", None)
            if callable(reset):
                reset()

    def _refresh_operation_mode_scope(self) -> None:
        """Project the daemon-confirmed mode into client-backed UI services."""
        client = self.client
        bridge = getattr(self, "client_bridge", None)
        if client is None or bridge is None:
            return
        try:
            capabilities = client.get_capabilities()
            if not capabilities.supports(ApiCapability.OPERATION_MODE):
                return
        except Exception:
            logger.debug("Operation-mode capability is not available", exc_info=True)
            return

        def _apply(result) -> None:
            if (
                self.client is not client
                or getattr(self, "_daemon_client_generation", 0) != generation
            ):
                logger.debug("Ignoring stale operation-mode result")
                return
            if not getattr(result, "accepted", True):
                message = getattr(
                    result,
                    "message",
                    "The daemon could not confirm operation mode",
                )
                if getattr(result, "recovery_required", False):
                    self._show_operation_mode_recovery(message)
                else:
                    logger.warning("Daemon rejected startup operation mode: %s", message)
                return
            self._apply_confirmed_operation_mode(result.active_mode)
            requested = getattr(self, "_requested_operation_mode", None)
            if requested is not None and result.active_mode is not requested:
                bridge.submit(
                    lambda: client.set_operation_mode(
                        SetOperationModeRequest(mode=requested)
                    ),
                    on_success=self._on_startup_operation_mode_result,
                    on_error=self._on_startup_operation_mode_error,
                )

        generation = getattr(self, "_daemon_client_generation", 0)
        try:
            bridge.submit(client.get_operation_mode, on_success=_apply,
                          on_error=lambda _error: None)
        except Exception:
            logger.debug("Could not request daemon operation mode", exc_info=True)

    def _on_startup_operation_mode_result(self, result) -> None:
        if not result.accepted:
            logger.warning("Daemon rejected startup operation mode: %s", result.message)
            if getattr(result, "recovery_required", False):
                self._show_operation_mode_recovery(result.message)
            return
        self._requested_operation_mode = None
        self._apply_confirmed_operation_mode(result.active_mode)
        # This path also fires after a mid-session daemon restart (e.g. to
        # apply an operation-mode change that live sessions were blocking),
        # so an already-open Preferences window can be showing the stale
        # pre-restart radio state. Resync it from the daemon-confirmed mode.
        preferences = getattr(self, "_preferences_window", None)
        if preferences is not None:
            refresh = getattr(preferences, "_request_confirmed_operation_mode", None)
            if callable(refresh):
                refresh()

    def _on_startup_operation_mode_error(self, error) -> None:
        """Report a failed startup mode request without losing its detail."""
        self._show_daemon_unavailable_dialog(error)

    def _apply_confirmed_operation_mode(self, mode: OperationMode) -> None:
        """Project one daemon-confirmed mode into all GTK-owned state."""
        if not isinstance(mode, OperationMode):
            raise TypeError("mode must be an OperationMode")
        self._confirmed_operation_mode = mode
        self._key_scope = (
            KeyStoreScope.ISOLATED
            if mode is OperationMode.ISOLATED
            else KeyStoreScope.DEFAULT
        )
        config_data = getattr(getattr(self, "config", None), "config_data", None)
        if isinstance(config_data, dict):
            ssh = config_data.setdefault("ssh", {})
            if isinstance(ssh, dict):
                ssh["use_isolated_config"] = mode is OperationMode.ISOLATED
        if self.client is not None:
            self.key_manager = KeyManager(self.client, self._key_scope)

    def _build_secrets_controller(self):
        """Build the daemon-backed secrets controller for the frontend.

        Returns ``None`` when the daemon does not advertise the secrets
        capability, so backend management stays disabled instead of falling
        back to GTK-owned secret code.
        """
        client = getattr(self, "client", None)
        if client is None:
            return None
        try:
            capabilities = client.get_capabilities()
        except Exception:
            return None
        if not capabilities.supports(ApiCapability.SECRETS_READ):
            return None
        from .gtk.secret_backends_controller import SecretBackendsController

        return SecretBackendsController(client)

    def _attach_secrets_interaction_presenter(self) -> None:
        """Present daemon-owned secret-backend interactions app-wide.

        The presenter subscribes to the daemon event stream and claims
        interactions in the reserved ``secret-session`` namespace (unlock,
        Bitwarden login/2FA, backup passphrases). It is not session-scoped.
        """
        if self.client is None or self.client_bridge is None:
            return
        from .gtk.secrets_interaction_presenter import SecretsInteractionPresenter

        presenter = getattr(self, "_secrets_interaction_presenter", None)
        if presenter is not None:
            presenter.close()
        try:
            self._secrets_interaction_presenter = SecretsInteractionPresenter(
                self.client,
                self.client_bridge,
                self,
            )
        except Exception:
            logger.debug("Secret interaction presenter unavailable", exc_info=True)

    def _begin_daemon_client_selection(self) -> None:
        """Locate/start and validate the sole backend away from the GTK thread."""
        from .api.client_factory import select_client
        app = self.get_application()
        launcher = getattr(app, '_api_daemon_launcher', None) if app else None
        self._api_client_selection_request = self.client_bridge.submit(
            lambda: select_client(launcher=launcher),
            on_success=self._apply_client_selection,
            on_error=self._handle_client_selection_error,
            on_discard=lambda selection: selection.client.close(),
        )

    def _apply_client_selection(self, selection) -> None:
        if self._is_quitting:
            selection.client.close()
            return
        self.client = selection.client
        self._attach_client_backed_services()
        preferences = getattr(self, '_preferences_window', None)
        if preferences is not None:
            try:
                preferences.set_ssh_overrides_controller(
                    self._build_ssh_overrides_controller()
                )
            except Exception:
                logger.debug(
                    "Failed to attach SSH overrides controller to Preferences",
                    exc_info=True,
                )
        self._api_client_selection_pending = False
        self._api_client_selection_request = None
        app = self.get_application()
        if app is not None:
            app._api_client_selection = selection
            if hasattr(app, 'install_api_event_subscription'):
                app.install_api_event_subscription(self.client)
        if hasattr(self, 'welcome_view') and self.welcome_view is not None:
            self.welcome_view.set_client(self.client, bridge=self.client_bridge)
        self._maybe_restore_daemon_sessions()

    def _handle_client_selection_error(self, error) -> None:
        """Keep the backend unavailable and expose retry/diagnostic recovery."""
        self._api_client_selection_pending = False
        self._api_client_selection_request = None
        self.client = None
        self.key_manager = None
        self._mark_daemon_unavailable()
        presenter = getattr(self, "_secrets_interaction_presenter", None)
        if presenter is not None:
            presenter.close()
            self._secrets_interaction_presenter = None
        self.secrets_controller = None
        controller = getattr(self, "_group_mutation_controller", None)
        if controller is not None:
            controller.close()
            self._group_mutation_controller = None
        self.plugin_connection_services.detach_client()
        self.connection_runtime_status.close()
        reason = getattr(getattr(error, 'reason', None), 'value', type(error).__name__)
        logger.error(
            "Daemon client selection failed reason=%s type=%s",
            reason,
            type(error).__name__,
        )
        frames = traceback.extract_tb(error.__traceback__)
        if frames:
            logger.debug(
                "Daemon client selection traceback (exception text redacted): %s",
                " -> ".join(
                    f"{frame.filename}:{frame.lineno} in {frame.name}"
                    for frame in frames
                ),
            )
        cause = error.__cause__ or error.__context__
        if cause is not None:
            logger.debug(
                "Daemon client selection cause type=%s code=%s",
                type(cause).__name__,
                getattr(getattr(cause, 'code', None), 'value', None),
            )
        self._client_mode_warning = _(
            "SSH Pilot requires its background service. %s "
            "Retry, restart the service, inspect diagnostics, or exit."
        ) % _describe_daemon_start_failure(reason)
        if hasattr(self, 'welcome_view') and self.welcome_view is not None:
            self.welcome_view.set_client(None)
        self._show_client_mode_warning()

    def _mark_daemon_unavailable(self) -> None:
        """Invalidate daemon-dependent UI state without selecting a fallback."""
        self._confirmed_operation_mode = None
        self.key_manager = None
        preferences = getattr(self, "_preferences_window", None)
        mark_unavailable = getattr(preferences, "mark_daemon_unavailable", None)
        if callable(mark_unavailable):
            mark_unavailable()

    def retry_daemon_connection(self) -> None:
        """Explicit recovery action; never changes backend ownership."""
        if self._api_client_selection_pending:
            return
        self._api_client_selection_pending = True
        self._begin_daemon_client_selection()

    def _show_client_mode_warning(self) -> bool:
        message = self._client_mode_warning
        self._client_mode_warning = None
        if not message or self._is_quitting:
            return False
        try:
            toast = Adw.Toast.new(message)
            toast.set_timeout(0)
            # Retry is the right action for every reason, including a stale
            # peer: each attempt runs the launcher's full recovery, which
            # stops or kills a daemon that cannot serve this build before
            # starting a current one.
            toast.set_button_label(_("Retry"))
            toast.connect("button-clicked", lambda *_args: self.retry_daemon_connection())
            self.toast_overlay.add_toast(toast)
        except Exception:
            logger.warning("Unable to display daemon recovery diagnostics")
        return False

    def _maybe_restore_daemon_sessions(self) -> None:
        """Offer or auto-reattach retained daemon sessions after GTK restart.

        Session listing runs on the client bridge worker so a slow or blocked
        control RPC cannot stall the GTK main loop (and cannot serialize behind
        an in-flight welcome ``connections.list`` on the single request gate).
        """

        try:
            if not self.config.get_setting("terminal.daemon_restore_sessions", True):
                return
            if self.client_bridge is None or self.client is None:
                return
            try:
                if not self.client.get_capabilities().supports(ApiCapability.SESSIONS_READ):
                    return
            except Exception:
                return
            from .daemon_session_restore import DaemonSessionRestoreManager

            restore = DaemonSessionRestoreManager(self.config)
            client = self.client
            bridge = self.client_bridge
            expected_instance = getattr(client, "server_instance_id", "") or ""

            def _list_sessions():
                return list(client.list_sessions() or [])

            def _on_listed(sessions):
                if self._is_quitting or self.client is not client:
                    return
                if (
                    expected_instance
                    and getattr(client, "server_instance_id", "") != expected_instance
                ):
                    return
                restorable = restore.get_restorable_sessions(
                    client,
                    sessions=sessions,
                )
                if not restorable:
                    return
                if self.config.get_setting("terminal.daemon_auto_attach", False):
                    for metadata in restorable:
                        restore.restore_session(
                            self, metadata, self.client, self.client_bridge
                        )
                    return
                dialog = Adw.AlertDialog.new(
                    _("Restore Daemon Sessions?"),
                    _(
                        "One or more SSH sessions are still running in the local "
                        "service. Restore them into tabs now?"
                    ),
                )
                dialog.add_response("skip", _("Not Now"))
                dialog.add_response("restore", _("Restore"))
                dialog.set_response_appearance(
                    "restore", Adw.ResponseAppearance.SUGGESTED
                )
                dialog.set_default_response("restore")
                dialog.set_close_response("skip")

                def _respond(_dialog, response):
                    if response != "restore":
                        return
                    for metadata in restorable:
                        restore.restore_session(
                            self, metadata, self.client, self.client_bridge
                        )

                dialog.connect("response", _respond)
                dialog.present(self)

            def _on_error(error):
                logger.warning(
                    "Failed to list daemon sessions for restore code=%s",
                    getattr(getattr(error, "code", None), "value", type(error).__name__),
                )

            bridge.submit(
                _list_sessions,
                on_success=_on_listed,
                on_error=_on_error,
            )
        except Exception:
            logger.error("Daemon session restore failed", exc_info=True)

    @property
    def sshcopyid_runner(self):
        """Lazily build the ssh-copy-id runner on first use, keeping the
        sshcopyid_window module off the startup import path."""
        if self._sshcopyid_runner is None:
            from .sshcopyid_window import SshCopyIdRunner
            self._sshcopyid_runner = SshCopyIdRunner(self)
        return self._sshcopyid_runner

    @sshcopyid_runner.setter
    def sshcopyid_runner(self, value):
        # Preserve the previously-assignable public attribute (e.g. tests inject a runner).
        self._sshcopyid_runner = value

    @property
    def scp_controller(self):
        """Lazily build the SCP controller on first use, keeping the scp_window
        module off the startup import path."""
        if self._scp_controller is None:
            from .scp_window import ScpWindowController
            self._scp_controller = ScpWindowController(self)
        return self._scp_controller

    @scp_controller.setter
    def scp_controller(self, value):
        self._scp_controller = value

    def _on_config_setting_changed(self, _config, key, value):
        """Synchronize runtime state when configuration values change."""
        if key == 'command_blocks.always_show_sidebar':
            if bool(value):
                self._ensure_command_blocks_panel()
                self._toggle_command_blocks_panel(True)
            return

        if key == 'terminal.pass_through_mode':
            try:
                self._update_sidebar_accelerators()
            except Exception:
                pass
            return

        if key == 'ui.max-sidebar-width':
            try:
                max_width = int(value)
                self.update_sidebar_max_width(max_width)
            except (ValueError, TypeError) as e:
                logger.error(f"Invalid max-sidebar-width value: {e}")
            return

        if key == 'app-theme':
            try:
                self._sync_theme_menu_button()
            except Exception:
                logger.debug("Failed to sync theme toggle button", exc_info=True)
            return

    def _schedule_startup_tasks(self):
        """Schedule one-time startup behaviors such as focus and welcome state."""
        if getattr(self, '_startup_tasks_scheduled', False):
            return

        self._startup_tasks_scheduled = True

        try:
            install_sidebar_css()
        except Exception as e:
            logger.error(f"Failed to install sidebar CSS: {e}")

        # Apply header-bar button visibility preferences now that the buttons
        # exist (split view, commands, local terminal).
        try:
            self.update_headerbar_buttons()
        except Exception as e:
            logger.error(f"Failed to apply header-bar button visibility: {e}")

        # Check startup behavior setting and show appropriate view
        try:
            startup_behavior = self.config.get_setting('app-startup-behavior', 'welcome')
        except Exception as e:
            logger.error(f"Error handling startup behavior: {e}")
            startup_behavior = 'welcome'

        # On startup, focus the appropriate widget based on preference
        if startup_behavior in ('previous-session', 'saved-session'):
            try:
                GLib.idle_add(self._restore_startup_session, startup_behavior)
            except Exception as e:
                logger.error(f"Failed to restore session on startup: {e}")
            # Late focus pass once tabs (if any) have been created
            try:
                GLib.timeout_add(700, self._focus_connection_list_first_row)
            except Exception:
                pass
        elif startup_behavior == 'terminal':
            # Show terminal when explicitly requested. Create it SYNCHRONOUSLY
            # here (this runs during __init__, before the window is presented) so
            # the tab view is shown from the first frame — deferring it via
            # idle_add briefly flashed the welcome/start page first.
            try:
                self.terminal_manager.show_local_terminal()
            except Exception as e:
                logger.error(f"Failed to show local terminal on startup: {e}")

            def _focus_terminal_when_ready():
                try:
                    terminal_widget = self._get_active_terminal_widget()
                    if terminal_widget is None:
                        return
                    terminal_widget.grab_terminal_focus()
                except Exception as focus_error:
                    logger.debug(f"Failed to focus startup terminal: {focus_error}")

            # Queue terminal focus operation to avoid race conditions
            self._queue_focus_operation(_focus_terminal_when_ready)
        else:
            # Two calls: early (100 ms) for immediate visual feedback, and late
            # (700 ms, after _on_startup_complete at 500 ms) to win back focus
            # if anything else grabbed it during startup.
            try:
                GLib.timeout_add(100, self._focus_connection_list_first_row)
                GLib.timeout_add(700, self._focus_connection_list_first_row)
            except Exception:
                pass

        # Mark startup as complete after a short delay to allow all initialization to finish
        GLib.timeout_add(500, self._on_startup_complete)
        # Note: "hide sidebar on startup" is applied at split-view creation
        # (before the window is presented) so it never flashes visible.

        # If the previous run crashed, surface it once the UI has settled.
        GLib.timeout_add(1200, self._check_previous_crash)

    def _build_ssh_overrides_controller(self):
        """Build the daemon-backed SSH overrides controller for Preferences.

        Returns ``None`` when the daemon does not advertise the SSH overrides
        capability, so the SSH override controls are disabled instead of
        failing at save time.
        """
        client = getattr(self, 'client', None)
        if client is None:
            return None
        try:
            capabilities = client.get_capabilities()
        except Exception:
            return None
        if not capabilities.supports(ApiCapability.SSH_OVERRIDES_READ):
            return None
        from .gtk.ssh_overrides_controller import SshOverridesController
        return SshOverridesController(client)

    def _preload_preferences_window(self) -> bool:
        """Pre-instantiate PreferencesWindow on low-priority idle so opening Settings is instant."""
        if getattr(self, '_preferences_window', None) is not None or getattr(self, '_is_quitting', False):
            return GLib.SOURCE_REMOVE
        try:
            from .preferences import PreferencesWindow
            if getattr(self, '_preferences_window', None) is None:
                controller = self._build_ssh_overrides_controller()
                self._preferences_window = PreferencesWindow(
                    self,
                    self.config,
                    ssh_overrides_controller=controller,
                )
        except Exception as exc:
            logger.debug("Preloading preferences page skipped/failed: %s", exc)
        return GLib.SOURCE_REMOVE

    def _check_previous_crash(self):
        """If the previous run left a crash report, offer to view/report it."""
        try:
            app = self.get_application()
            crash_path = getattr(app, '_previous_crash_report', None) if app else None
            if not crash_path:
                return False
            # Consume so the prompt only appears once per crash.
            app._previous_crash_report = None
            self._pending_crash_report = crash_path

            heading = _("SSH Pilot closed unexpectedly")
            body = _(
                "An error report from your previous session was saved. "
                "Sending it helps us find and fix the problem."
            )
            if hasattr(Adw, 'AlertDialog'):
                dialog = Adw.AlertDialog(heading=heading, body=body)
                present = lambda d: d.present(self)
            else:
                dialog = Adw.MessageDialog(
                    transient_for=self, modal=True, heading=heading, body=body)
                present = lambda d: d.present()
            dialog.add_response('dismiss', _("Not Now"))
            dialog.add_response('logs', _("View Logs…"))
            dialog.add_response('report', _("Report a Problem…"))
            dialog.set_default_response('report')
            dialog.set_close_response('dismiss')
            try:
                dialog.set_response_appearance(
                    'report', Adw.ResponseAppearance.SUGGESTED)
            except Exception:
                pass
            dialog.connect('response', self._on_previous_crash_response)
            present(dialog)
        except Exception as exc:
            logger.debug("Previous-crash check failed: %s", exc, exc_info=True)
        return False  # one-shot

    def _on_previous_crash_response(self, dialog, response):
        # NB: self.activate_action is shadowed by a SimpleAction attribute
        # (window.py: self.activate_action = Gio.SimpleAction.new(...)), so the
        # GtkWidget.activate_action() method is not callable here. Invoke the
        # handler / action object directly instead.
        try:
            if response == 'report':
                self.on_report_problem_action()
            elif response == 'logs':
                action = getattr(self, 'view_logs_action', None)
                if action is not None:
                    action.activate(None)
        except Exception as exc:
            logger.debug("Crash dialog response failed: %s", exc, exc_info=True)

    def on_report_problem_action(self, action=None, param=None):
        """Copy a diagnostic bundle (incl. crash report), then confirm before
        opening the new-issue page so the user can paste it."""
        try:
            from .log_viewer import build_report_bundle
            crash_path = getattr(self, '_pending_crash_report', None)
            bundle = build_report_bundle(crash_path)
        except Exception as exc:
            logger.error("Could not build problem report: %s", exc, exc_info=True)
            bundle = ''
        copied = False
        try:
            display = self.get_display() or Gdk.Display.get_default()
            if display is not None and bundle:
                display.get_clipboard().set(bundle)
                copied = True
        except Exception as exc:
            logger.error("Could not copy report to clipboard: %s", exc)

        heading = _("Report a Problem")
        if copied:
            body = _(
                "Diagnostic information has been copied to your clipboard.\n\n"
                "Press OK to open the issue page, then paste (Ctrl+V) it into "
                "the report."
            )
        else:
            body = _(
                "Press OK to open the issue page. You can attach your logs from "
                "Help ▸ View Logs."
            )

        if hasattr(Adw, 'AlertDialog'):
            dialog = Adw.AlertDialog(heading=heading, body=body)
            present = lambda d: d.present(self)
        else:
            dialog = Adw.MessageDialog(
                transient_for=self, modal=True, heading=heading, body=body)
            present = lambda d: d.present()
        dialog.add_response('cancel', _("Cancel"))
        dialog.add_response('open', _("OK"))
        dialog.set_default_response('open')
        dialog.set_close_response('cancel')
        try:
            dialog.set_response_appearance('open', Adw.ResponseAppearance.SUGGESTED)
        except Exception:
            pass
        dialog.connect('response', self._on_report_problem_response)
        present(dialog)

    def _on_report_problem_response(self, dialog, response):
        if response != 'open':
            return
        try:
            Gtk.show_uri(self, 'https://github.com/mfat/sshpilot/issues/new',
                         Gdk.CURRENT_TIME)
        except Exception as exc:
            logger.error("Could not open issue tracker: %s", exc)

    def on_export_diagnostics_action(self, action=None, param=None):
        """Save a ZIP of logs + system info + redacted config for bug reports."""
        file_dialog = Gtk.FileDialog()
        file_dialog.set_title(_("Export Diagnostics"))
        file_dialog.set_initial_name(
            "sshpilot-diagnostics-%s.zip" % datetime.now().strftime('%Y%m%d-%H%M%S'))
        try:
            docs = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DOCUMENTS)
            if docs:
                file_dialog.set_initial_folder(Gio.File.new_for_path(docs))
        except Exception:
            pass

        def _on_save(dialog, result):
            try:
                gfile = dialog.save_finish(result)
            except GLib.Error as exc:
                if getattr(exc, 'code', None) != 2:  # 2 = dismissed
                    logger.error("Export diagnostics dialog failed: %s", exc)
                return
            if gfile is None:
                return
            path = gfile.get_path()

            def _work():
                try:
                    from .log_viewer import build_diagnostics_zip
                    build_diagnostics_zip(path)
                    GLib.idle_add(self._on_export_diagnostics_done, True, None, path)
                except Exception as exc:
                    logger.error("Export diagnostics failed: %s", exc, exc_info=True)
                    GLib.idle_add(self._on_export_diagnostics_done, False, str(exc), path)

            threading.Thread(target=_work, daemon=True).start()

        file_dialog.save(self, None, _on_save)

    def on_daemon_sessions_action(self, action=None, param=None):
        """Show live daemon sessions management dialog."""
        del action, param
        try:
            try:
                supports_sessions = self.client is not None and self.client.get_capabilities().supports(
                    ApiCapability.SESSIONS_READ
                )
            except Exception:
                supports_sessions = False
            if not supports_sessions:
                dialog = Adw.AlertDialog.new(
                    _("Daemon Sessions Unavailable"),
                    _(
                        "Daemon sessions are not available. Enable daemon-backed "
                        "SSH terminals and ensure the local service is running."
                    ),
                )
                dialog.add_response("ok", _("OK"))
                dialog.set_default_response("ok")
                dialog.set_close_response("ok")
                dialog.present(self)
                return

            bridge = getattr(self, "client_bridge", None)
            if bridge is None:
                dialog = Adw.AlertDialog.new(
                    _("Daemon Sessions Unavailable"),
                    _("The local SSH Pilot service bridge is unavailable."),
                )
                dialog.add_response("ok", _("OK"))
                dialog.set_default_response("ok")
                dialog.set_close_response("ok")
                dialog.present(self)
                return

            from .daemon_sessions_dialog import DaemonSessionsDialog

            DaemonSessionsDialog(self, self.client, bridge).show()
        except Exception:
            logger.error("Failed to show daemon sessions", exc_info=True)
            dialog = Adw.AlertDialog.new(
                _("Error"),
                _("Failed to open the daemon sessions dialog."),
            )
            dialog.add_response("ok", _("OK"))
            dialog.set_default_response("ok")
            dialog.set_close_response("ok")
            dialog.present(self)

    def _on_export_diagnostics_done(self, ok, error, path):
        if ok:
            dialog = Adw.MessageDialog(
                transient_for=self, modal=True,
                heading=_("Diagnostics Exported"),
                body=_("Saved to:\n{path}\n\nSecrets are redacted. Your saved connections "
                       "and SSH config are not included; a maintainer can request them "
                       "separately if needed.").format(path=path),
            )
            dialog.add_response('open', _("Open Folder"))
            dialog.add_response('ok', _("OK"))
            dialog.set_default_response('ok')
            dialog.connect(
                'response',
                lambda d, r: self._open_containing_folder(path) if r == 'open' else None)
        else:
            dialog = Adw.MessageDialog(
                transient_for=self, modal=True,
                heading=_("Export Failed"),
                body=_("Could not export diagnostics:\n{error}").format(error=error or _("Unknown error")),
            )
            dialog.add_response('ok', _("OK"))
        dialog.present()
        return False

    def _open_containing_folder(self, path):
        try:
            folder = os.path.dirname(path) or '.'
            uri = Gio.File.new_for_path(folder).get_uri()
            Gio.AppInfo.launch_default_for_uri(uri, None)
        except Exception as exc:
            logger.debug("Could not open containing folder: %s", exc)

    def _restore_startup_session(self, startup_behavior):
        """Restore a session on startup based on the configured behavior."""
        try:
            session_manager = getattr(self, 'session_manager', None)
            if session_manager is None:
                return False

            data = None
            if startup_behavior == 'previous-session':
                data = session_manager.get_previous()
            elif startup_behavior == 'saved-session':
                name = self.config.get_setting('app-startup-session-name', '')
                if name:
                    data = session_manager.get_session(name)
                if data is None:
                    logger.info(f"Startup session '{name}' not found; showing start page")

            if data:
                self.restore_session(data, replace=True)
        except Exception as e:
            logger.error(f"Failed to restore startup session: {e}")
        return False  # Don't repeat

    def _on_startup_complete(self):
        """Called when startup is complete - process any pending focus operations"""
        self._startup_complete = True

        # Process any pending focus operations
        for focus_op in self._pending_focus_operations:
            try:
                focus_op()
            except Exception as e:
                logger.debug(f"Failed to execute pending focus operation: {e}")

        self._pending_focus_operations.clear()

        # Pre-instantiate PreferencesWindow on low-priority idle so opening Settings is instant
        try:
            GLib.idle_add(self._preload_preferences_window, priority=GLib.PRIORITY_LOW)
        except Exception as e:
            logger.debug(f"Failed to schedule preferences preloading: {e}")

        # Check for updates if enabled in preferences
        try:
            check_on_startup = self.config.get_setting('updates.check_on_startup', True)
            if check_on_startup:
                logger.debug("Checking for updates on startup")

                def on_update_check_complete(latest_version):
                    """Callback when startup update check completes.

                    Always route through the result handler (even when there is
                    no update) so the tips banner is surfaced once the update
                    banner's area is known to be free. ``from_startup=True``
                    keeps the silent check from raising a toast.
                    """
                    GLib.idle_add(
                        self._handle_update_check_result, latest_version, True
                    )

                check_for_updates_async(on_update_check_complete)
        except Exception as e:
            logger.debug(f"Failed to check for updates on startup: {e}")

        return False  # Don't repeat

    def _queue_focus_operation(self, focus_func):
        """Queue a focus operation to be executed after startup is complete"""
        if self._startup_complete:
            # Startup is complete, execute immediately
            try:
                focus_func()
            except Exception as e:
                logger.debug(f"Failed to execute focus operation: {e}")
        else:
            # Queue for later execution
            self._pending_focus_operations.append(focus_func)

    def _toggle_class(self, widget, name, on):
        """Helper to toggle CSS class on a widget"""
        if on:
            widget.add_css_class(name)
        else:
            widget.remove_css_class(name)


    def _first_connection_row(self) -> Optional[Gtk.ListBoxRow]:
        """Return the first row in the connection list that is a connection.

        Skips group header rows so search results that start with a matching
        group still resolve to the first matching host."""
        if not getattr(self, 'connection_list', None):
            return None
        index = 0
        while True:
            row = self.connection_list.get_row_at_index(index)
            if row is None:
                return None
            if hasattr(row, 'connection'):
                return row
            index += 1

    def _on_connection_list_nav_key(self, controller, keyval, keycode, state):
        """Keyboard handling while the connection list has focus.

        - Arrow Up from the first row returns focus to the search entry (when
          the search bar is open) so the user can keep editing the query.
        - Space selects the focused row, Ctrl/⌘+Space toggles it in the
          multi-selection (the rows are activatable, so without this the
          row's default Space binding would activate it, i.e. connect).
        - Typing a printable character starts a search (type-ahead): the search
          bar opens, takes focus, and receives the character.
        """
        if keyval in (Gdk.KEY_space, Gdk.KEY_KP_Space):
            row = self.connection_list.get_focus_child()
            if row is None:
                return False
            toggle_mask = (
                Gdk.ModifierType.CONTROL_MASK
                | getattr(Gdk.ModifierType, 'META_MASK', 0)
            )
            try:
                if state & toggle_mask:
                    # Toggle the focused row in the multi-selection.
                    if row in self.connection_list.get_selected_rows():
                        self.connection_list.unselect_row(row)
                    else:
                        self.connection_list.select_row(row)
                else:
                    self._select_only_row(row)
            except Exception:
                logger.debug("Space selection handling failed", exc_info=True)
            return True
        if keyval in (Gdk.KEY_Up, Gdk.KEY_KP_Up):
            if (
                getattr(self, 'search_container', None)
                and self.search_container.get_visible()
                and getattr(self, 'search_entry', None)
                and getattr(self, 'connection_list', None)
            ):
                first_row = self.connection_list.get_row_at_index(0)
                focused = self.connection_list.get_focus_child()
                if first_row is not None and focused is first_row:
                    self.search_entry.grab_focus()
                    text = self.search_entry.get_text()
                    if text:
                        self.search_entry.select_region(0, len(text))
                    return True
            return False

        # Type-ahead: a printable key starts/continues a search.
        if not getattr(self, 'search_entry', None):
            return False
        shortcut_modifiers = (
            Gdk.ModifierType.CONTROL_MASK
            | Gdk.ModifierType.ALT_MASK
            | getattr(Gdk.ModifierType, 'META_MASK', 0)
            | getattr(Gdk.ModifierType, 'SUPER_MASK', 0)
        )
        if state & shortcut_modifiers:
            return False
        unicode_point = Gdk.keyval_to_unicode(keyval)
        if not unicode_point:
            return False
        char = chr(unicode_point)
        # Skip control chars and Space (Space toggles row selection in the list).
        if not char.isprintable() or char == ' ':
            return False
        self.activate_search_entry()
        self.search_entry.set_text(self.search_entry.get_text() + char)
        self.search_entry.set_position(-1)
        return True

    def _focus_is_in_connection_list(self) -> bool:
        """Return True if keyboard focus is currently on/within the connection list."""
        if not getattr(self, 'connection_list', None):
            return False
        try:
            widget = self.get_focus()
        except Exception:
            return False
        while widget is not None:
            if widget is self.connection_list:
                return True
            widget = widget.get_parent()
        return False

    def _on_focus_visible_changed(self, *args) -> None:
        """Keep the keyboard focus ring visible while navigating the connection list.

        GTK hides the window's focus-visible state after a few seconds of
        keyboard inactivity. While the user is navigating the connection list
        that reads as the selection being lost, so re-assert it as long as
        focus remains inside the list."""
        try:
            if self.get_focus_visible():
                return
            if self._focus_is_in_connection_list():
                self.set_focus_visible(True)
        except Exception:
            pass

    def _select_only_row(self, row: Optional[Gtk.ListBoxRow]) -> None:
        """Select only the provided row, clearing any other selections."""
        if not row or not getattr(self, 'connection_list', None):
            return

        try:
            if hasattr(self.connection_list, 'unselect_all'):
                self.connection_list.unselect_all()
        except Exception:
            pass

        try:
            self.connection_list.select_row(row)
        except Exception:
            pass

    def _get_selected_connection_rows(self) -> List[Gtk.ListBoxRow]:
        """Return all selected rows that represent connections."""
        if not getattr(self, 'connection_list', None):
            return []

        try:
            selected_rows = list(self.connection_list.get_selected_rows())
        except Exception:
            selected_row = self.connection_list.get_selected_row()
            selected_rows = [selected_row] if selected_row else []

        return [row for row in selected_rows if hasattr(row, 'connection')]

    def _get_selected_group_rows(self) -> List[Gtk.ListBoxRow]:
        """Return all selected rows that represent groups."""
        if not getattr(self, 'connection_list', None):
            return []

        try:
            selected_rows = list(self.connection_list.get_selected_rows())
        except Exception:
            selected_row = self.connection_list.get_selected_row()
            selected_rows = [selected_row] if selected_row else []

        return [row for row in selected_rows if hasattr(row, 'group_id')]

    def _get_target_connection_rows(self, prefer_context: bool = False) -> List[Gtk.ListBoxRow]:
        """Return rows targeted by the current action, respecting context menus."""
        rows = self._get_selected_connection_rows()
        context_row = getattr(self, '_context_menu_row', None)

        if context_row and hasattr(context_row, 'connection'):
            if rows and context_row in rows:
                return rows
            if prefer_context or not rows:
                return [context_row]

        return rows

    def _connections_from_rows(self, rows: List[Gtk.ListBoxRow]) -> List[Connection]:
        """Return unique connections represented by the provided rows."""
        connections: List[Connection] = []
        seen_ids = set()
        for row in rows:
            connection = getattr(row, 'connection', None)
            if connection and id(connection) not in seen_ids:
                seen_ids.add(id(connection))
                connections.append(connection)
        return connections

    def _get_target_connections(self, prefer_context: bool = False) -> List[Connection]:
        """Return connection objects targeted by the current action."""
        rows = self._get_target_connection_rows(prefer_context=prefer_context)
        return self._connections_from_rows(rows)

    def _on_effective_config_result(self, nickname: str, differs: bool):
        """Background checker result (main thread): update matching rows' icons."""
        try:
            for conn, rows in list(self.connection_rows.items()):
                if getattr(conn, 'nickname', None) != nickname:
                    continue
                for row in (rows if isinstance(rows, list) else [rows]):
                    if hasattr(row, 'set_effective_warning'):
                        row.set_effective_warning(differs)
        except Exception:
            logger.debug("Failed to apply effective-config result", exc_info=True)
        return False

    def _invalidate_effective_check(self, nickname, connection=None):
        """Drop a cached effective-config result; optionally recompute now.

        Called when a connection's own block changes. Pass ``connection`` to
        re-check it immediately (rows updated via the result callback).
        """
        checker = getattr(self, 'effective_config_checker', None)
        if checker is None or not nickname:
            return
        try:
            checker.invalidate(nickname)
            if connection is not None:
                checker.schedule(connection)
        except Exception:
            logger.debug("effective-config invalidate failed", exc_info=True)

    def _request_effective_warning(self, row, connection):
        """Resolve a hovered row from cache or enqueue its background check.

        Called only from the row's hover handler, never while building the list.
        """
        checker = getattr(self, 'effective_config_checker', None)
        if checker is None:
            return
        try:
            status = checker.status(getattr(connection, 'nickname', '') or '')
            if status is not None and hasattr(row, 'set_effective_warning'):
                row.set_effective_warning(status)
            checker.schedule(connection)
        except Exception:
            logger.debug("Failed to prime effective-config warning", exc_info=True)

    def _reload_ssh_config(self, create_missing=True):
        """Compatibility callback: refresh only from the attached daemon."""
        try:
            self.connection_manager.refresh()
            self.group_manager.bind_connections(self.connection_manager.get_connections())
            checker = getattr(self, 'effective_config_checker', None)
            if checker is not None:
                checker.invalidate()
            self.rebuild_connection_list()
        except Exception as error:
            logger.error("Failed to refresh daemon connection state: %s", error)

    def _observe_and_reload_ssh_config(self):
        """Compatibility callback for dialogs; no local file is observed."""
        self._reload_ssh_config()

    def _teardown_ssh_config_monitor(self):
        """Compatibility no-op: GTK never creates configuration monitors."""

    def _rows_for_connection(self, connection) -> List[Gtk.ListBoxRow]:
        """Return every visible row representing ``connection``.

        A connection can be shown in more than one group, so several rows may
        map to the same connection object.
        """
        rows = self.connection_rows.get(connection)
        if not rows:
            return []
        if isinstance(rows, list):
            return list(rows)
        return [rows]

    def _primary_row_for_connection(self, connection) -> Optional[Gtk.ListBoxRow]:
        """Return the first row representing ``connection`` (or ``None``)."""
        rows = self._rows_for_connection(connection)
        return rows[0] if rows else None

    def _determine_neighbor_connection_row(
        self, target_rows: List[Gtk.ListBoxRow]
    ) -> Optional[Gtk.ListBoxRow]:
        """Find the closest remaining connection row after deleting target_rows."""
        if not target_rows or not getattr(self, 'connection_list', None):
            return None

        try:
            all_rows = list(self.connection_list)
        except Exception:
            # If iteration fails, fall back to default behavior.
            return None

        if not all_rows:
            return None

        index_map = {row: idx for idx, row in enumerate(all_rows)}
        target_indexes = sorted(
            index_map[row]
            for row in target_rows
            if row in index_map
        )

        if not target_indexes:
            return None

        max_index = target_indexes[-1]
        min_index = target_indexes[0]

        # Try to find the next connection row after the targeted range.
        for idx in range(max_index + 1, len(all_rows)):
            row = all_rows[idx]
            if hasattr(row, 'connection') and row not in target_rows:
                return row

        # Fall back to previous connection rows before the targeted range.
        for idx in range(min_index - 1, -1, -1):
            row = all_rows[idx]
            if hasattr(row, 'connection') and row not in target_rows:
                return row

        return None

    def _disconnect_connection_terminals(self, connection: Connection) -> None:
        """Disconnect all tracked terminals for a connection."""
        try:
            for term in list(self.connection_to_terminals.get(connection, [])):
                try:
                    if hasattr(term, 'disconnect'):
                        term.disconnect()
                except Exception:
                    pass

            term = self.active_terminals.get(connection)
            if term and hasattr(term, 'disconnect'):
                try:
                    term.disconnect()
                except Exception:
                    pass
        except Exception:
            pass

    def _prompt_delete_connections(
        self,
        connections: List[Connection],
        neighbor_row: Optional[Gtk.ListBoxRow] = None,
    ) -> None:
        """Show a confirmation dialog for deleting one or more connections."""
        unique_connections: List[Connection] = []
        seen_ids = set()
        for connection in connections:
            if connection and id(connection) not in seen_ids:
                seen_ids.add(id(connection))
                unique_connections.append(connection)

        if not unique_connections:
            return

        active_connections = [
            connection
            for connection in unique_connections
            if getattr(connection, 'is_connected', False)
            or bool(self.connection_to_terminals.get(connection, []))
        ]

        if active_connections:
            heading = _('Remove host?') if len(unique_connections) == 1 else _('Remove connections?')
            body = _('Close connections and remove host?') if len(unique_connections) == 1 else _(
                'Close connections and remove the selected hosts?'
            )
            dialog = Adw.MessageDialog(
                transient_for=self,
                modal=True,
                heading=heading,
                body=body,
            )
            dialog.add_response('cancel', _('Cancel'))
            dialog.add_response('close_remove', _('Close and Remove'))
            dialog.set_response_appearance('close_remove', Adw.ResponseAppearance.DESTRUCTIVE)
            dialog.set_default_response('close_remove')
            dialog.set_close_response('cancel')
        else:
            heading = _('Delete Connection?') if len(unique_connections) == 1 else _('Delete Connections?')
            if len(unique_connections) == 1:
                nickname = unique_connections[0].nickname if hasattr(unique_connections[0], 'nickname') else ''
                body = _('Are you sure you want to delete "{}"?').format(nickname)
            else:
                body = _('Are you sure you want to delete the selected connections?')

            dialog = Adw.MessageDialog.new(self, heading, body)
            dialog.add_response('cancel', _('Cancel'))
            dialog.add_response('delete', _('Delete'))
            dialog.set_response_appearance('delete', Adw.ResponseAppearance.DESTRUCTIVE)
            dialog.set_default_response('cancel')
            dialog.set_close_response('cancel')

        payload = {
            'connections': unique_connections,
            'neighbor_row': neighbor_row,
        }
        dialog.connect('response', self.on_delete_connection_response, payload)
        dialog.present()


    def _on_connection_list_key_pressed(self, controller, keyval, keycode, state):
        """Handle key presses in the connection list"""

        # Handle Enter key specifically
        if keyval == Gdk.KEY_Return or keyval == Gdk.KEY_KP_Enter:
            selected_row = self.connection_list.get_selected_row()
            if selected_row and hasattr(selected_row, 'connection'):
                self._return_to_tab_view_if_welcome()
                connection = selected_row.connection
                self._focus_most_recent_tab_or_open_new(connection)
                return True  # Consume the event to prevent row-activated
            return False  # Allow group rows to be handled by row-activated

        # Handle deletion keys to remove selected connections
        if keyval in (
            Gdk.KEY_Delete,
            Gdk.KEY_KP_Delete,
            Gdk.KEY_BackSpace,
        ):
            target_rows = self._get_target_connection_rows()
            connections = self._connections_from_rows(target_rows)

            if connections:
                neighbor_row = self._determine_neighbor_connection_row(target_rows)
                self._prompt_delete_connections(connections, neighbor_row)
                return True

            return False
        return False



        # Sidebar toggle action registered via register_window_actions

    def setup_window(self):
        """Configure main window properties"""
        self.set_title('SSH Pilot')
        self.set_icon_name('io.github.mfat.sshpilot')

        # Load window geometry
        geometry = self.config.get_window_geometry()
        self.set_default_size(geometry['width'], geometry['height'])
        self.set_resizable(True)

        # Connect window state signals
        self.connect('notify::default-width', self.on_window_size_changed)
        self.connect('notify::default-height', self.on_window_size_changed)
        # Ensure initial focus after the window is mapped
        try:
            self.connect('map', lambda *a: GLib.timeout_add(200, self._focus_connection_list_first_row))
        except Exception:
            pass

        # Tab navigation shortcuts are handled by application actions (see sshpilot/main.py)

    def setup_ui(self):
        """Set up the user interface"""
        # Outer skeleton (ToastOverlay -> main_box) is in the template; the split
        # view, header bar, banners, sidebar, and tabs are appended below.
        main_box = self.main_box

        # Create update notification banner (hidden by default)
        # Use overlay to position dismiss button on top of banner
        banner_overlay = Gtk.Overlay()
        banner_overlay.set_visible(False)  # Hidden by default

        self.update_banner = Adw.Banner()
        self.update_banner.set_revealed(False)
        banner_overlay.set_child(self.update_banner)

        # Create dismiss button with text, positioned at the left
        dismiss_button = Gtk.Button()
        dismiss_button.set_label(_('Dismiss'))
        dismiss_button.set_halign(Gtk.Align.START)
        dismiss_button.set_valign(Gtk.Align.CENTER)
        dismiss_button.set_margin_start(12)
        dismiss_button.connect('clicked', self._on_update_banner_dismiss)
        banner_overlay.add_overlay(dismiss_button)

        self.update_banner_container = banner_overlay
        self.update_banner_dismiss_button = dismiss_button
        # Note: Update banner will be added to content area in setup_content_area()
        # to ensure it appears below the header bar

        # Create terminal tips banner (hidden by default). Shown in the same
        # area as the update banner — below the header bar, above the content —
        # instead of floating over the terminal where it would mask output.
        # The update banner takes priority over tips (see show_terminal_tip and
        # _show_update_banner).
        # A Gtk.Revealer holding a single inline row: the extra buttons are real
        # children of the banner body, so no Gtk.Overlay hack is needed to show
        # them (Adw.Banner only exposes one action button). The revealer gives us
        # the same slide-in/out animation Adw.Banner.set_revealed() provided.
        _ensure_tips_banner_css()
        self.tips_revealer = Gtk.Revealer()
        # SLIDE_DOWN slides the banner in from the top edge and collapses it back
        # up on dismiss (matching Adw.Banner's feel).
        self.tips_revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
        self.tips_revealer.set_transition_duration(250)
        self.tips_revealer.set_reveal_child(False)

        # No outer margins: the accent background must span the full width like
        # the previous Adw.Banner. Horizontal padding comes from the .tips-banner
        # CSS instead, so there are no gaps on the sides.
        tips_body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        tips_body.add_css_class('tips-banner')

        # Leading-edge button cluster: "Next tip" (cycles) + "Dismiss".
        self.tips_next_button = Gtk.Button(label=_('Next tip'))
        self.tips_next_button.set_valign(Gtk.Align.CENTER)
        self.tips_next_button.connect('clicked', self._on_tips_banner_next)
        tips_dismiss_button = Gtk.Button(label=_('Dismiss'))
        tips_dismiss_button.set_valign(Gtk.Align.CENTER)
        tips_dismiss_button.connect('clicked', self._on_tips_banner_dismiss)
        tips_body.append(self.tips_next_button)
        tips_body.append(tips_dismiss_button)

        # Tip text fills the middle.
        self.tips_label = Gtk.Label()
        self.tips_label.set_hexpand(True)
        self.tips_label.set_halign(Gtk.Align.CENTER)
        self.tips_label.set_wrap(True)
        self.tips_label.set_justify(Gtk.Justification.CENTER)
        tips_body.append(self.tips_label)

        # Trailing button: "Don't show again".
        tips_dont_show_button = Gtk.Button(label=_('Don\'t show again'))
        tips_dont_show_button.set_valign(Gtk.Align.CENTER)
        tips_dont_show_button.connect('clicked', self._on_tips_banner_dont_show_again)
        tips_body.append(tips_dont_show_button)

        self.tips_revealer.set_child(tips_body)
        self.tips_banner_container = self.tips_revealer

        # Create header bar (content pane — window controls on the right with split views)
        self.header_bar = Adw.HeaderBar()
        self.header_bar.add_css_class('flat')
        self.header_bar.set_show_start_title_buttons(True)
        self.header_bar.set_show_end_title_buttons(True)
        # Empty title so Adw doesn't repeat the window title beside tab actions.
        # In minimal-strip mode the "SSH Pilot" title moves here (the sidebar
        # header is too narrow) — see _apply_sidebar_minimal_chrome.
        self._content_empty_title = Gtk.Box()
        self.header_bar.set_title_widget(self._content_empty_title)

        # Safely configure native window controls (macOS only, GTK 4.18+)
        maybe_set_native_controls(self.header_bar, False)


        # Add sidebar toggle button to the left side of header bar
        self.sidebar_toggle_button = Gtk.ToggleButton()
        self.sidebar_toggle_button.set_can_focus(False)  # Remove focus from sidebar toggle

        # Sidebar always starts visible
        sidebar_visible = True

        from sshpilot import icon_utils
        icon_utils.set_button_icon(self.sidebar_toggle_button, 'sidebar-show-symbolic')
        # Show the effective (possibly user-customized) shortcut in the tooltip.
        sidebar_accels = self._get_safe_current_shortcuts().get('toggle_sidebar') or ['F9']

        accel_labels = ', '.join(_accelerator_label(a) for a in sidebar_accels)
        self.sidebar_toggle_button.set_tooltip_text(
            _('Hide Sidebar ({accels})').format(accels=accel_labels))
        # Button should not appear pressed when sidebar is visible
        self.sidebar_toggle_button.set_active(False)
        self.sidebar_toggle_button.connect('toggled', self.on_sidebar_toggle)
        self.header_bar.pack_start(self.sidebar_toggle_button)

        # Add local terminal button right after the sidebar toggle
        self.local_terminal_button = Gtk.Button()
        icon_utils.set_button_icon(self.local_terminal_button, 'utilities-terminal-symbolic')
        self.local_terminal_button.set_tooltip_text(
            _('Open Local Terminal ({modifier}+Shift+T)').format(
                modifier=get_primary_modifier_label())
        )
        self.local_terminal_button.add_css_class('flat')
        self.local_terminal_button.set_action_name('app.local-terminal')
        self.header_bar.pack_start(self.local_terminal_button)
        # Toggled by Preferences ▸ Interface ▸ Header Bar.
        self._headerbar_local_terminal_button = self.local_terminal_button

        # Add tab button to header bar (will be created later in setup_content_area)
        # This will be added after the tab view is created

        # Add header bar to main container only when using traditional split views
        if not (HAS_NAV_SPLIT or HAS_OVERLAY_SPLIT):
            main_box.append(self.header_bar)

        # Honor the saved max-sidebar-width on startup (previously it was read but
        # ignored here, so the saved width only took effect after being changed
        # mid-session); fall back to 400 when unset/invalid.
        saved_max_width = self.config.get_setting('ui.max-sidebar-width', None)
        effective_max_width = _effective_max_sidebar_width(saved_max_width)

        # Try OverlaySplitView first as it's more reliable
        if HAS_OVERLAY_SPLIT:
            self.split_view = Adw.OverlaySplitView()
            try:
                self.split_view.set_sidebar_width_fraction(0.25)
                self.split_view.set_min_sidebar_width(180)
                self.split_view.set_max_sidebar_width(effective_max_width)
            except Exception:
                pass
            self.split_view.set_vexpand(True)
            self._split_variant = 'overlay'
            logger.debug("Using OverlaySplitView")
        elif HAS_NAV_SPLIT:
            self.split_view = Adw.NavigationSplitView()
            try:
                self.split_view.set_sidebar_width_fraction(0.25)
                self.split_view.set_min_sidebar_width(200)
                self.split_view.set_max_sidebar_width(effective_max_width)
            except Exception:
                pass
            self.split_view.set_vexpand(True)
            self._split_variant = 'navigation'
            logger.debug("Using NavigationSplitView")
        else:
            self.split_view = Gtk.Paned.new(Gtk.Orientation.HORIZONTAL)
            self.split_view.set_wide_handle(True)
            self.split_view.set_vexpand(True)
            self._split_variant = 'paned'
            logger.debug("Using Gtk.Paned fallback")

        # Initial sidebar visibility. Apply "hide on startup" HERE — before the
        # window is presented — so it never flashes visible then collapses.
        try:
            start_hidden = bool(self.config.get_setting('ui.sidebar_hide_on_startup', False))
        except Exception:
            start_hidden = False
        sidebar_visible = not start_hidden
        # Track sidebar visibility state for NavigationSplitView (which doesn't have get_show_sidebar)
        self._sidebar_visible = sidebar_visible
        # Keep the header toggle button in sync (active == hidden).
        if hasattr(self, 'sidebar_toggle_button'):
            try:
                self.sidebar_toggle_button.set_active(start_hidden)
            except Exception:
                pass

        # For OverlaySplitView, we need to explicitly set the sidebar state
        if HAS_OVERLAY_SPLIT:
            try:
                self.split_view.set_show_sidebar(sidebar_visible)
                logger.debug(f"Set OverlaySplitView sidebar visible={sidebar_visible}")
            except Exception as e:
                logger.error(f"Failed to set OverlaySplitView sidebar: {e}")
        elif HAS_NAV_SPLIT and start_hidden:
            try:
                self._toggle_sidebar_visibility(False)
            except Exception:
                pass

        # Create sidebar
        self.setup_sidebar()

        # Create main content area
        self.setup_content_area()

        # Wrap the work UI in an overlay so the detachable sidebar popup can
        # float over the content without affecting layout (see search_popup.py).
        self._content_overlay = Gtk.Overlay()
        self._content_overlay.set_hexpand(True)
        self._content_overlay.set_vexpand(True)
        self._content_overlay.set_child(self.split_view)
        from .search_popup import SearchPopup
        self._search_popup = SearchPopup(
            self._content_overlay,
            self._sidebar_toolbar_view,
            self._sidebar_box,
            self._popup_target_width,
            on_shown=self._on_search_popup_shown,
            on_hidden=self._on_search_popup_hidden,
            on_dismiss=self._dismiss_search_popup,
            focus_func=lambda: getattr(self, 'search_entry', None),
        )

        # Outer NavigationView: work UI is the root page; Settings is pushed on
        # top (Telegram-style mode). Back / Esc pops Settings and restores work.
        self.nav_view = None
        self._work_page = None
        if hasattr(Adw, 'NavigationView'):
            self.nav_view = Adw.NavigationView()
            self.nav_view.set_hexpand(True)
            self.nav_view.set_vexpand(True)
            # Work UI <-> Settings is a mode switch, not a deeper page: swap
            # instantly instead of the forward slide push() would animate.
            try:
                self.nav_view.set_animate_transitions(False)
            except Exception:
                logger.debug('NavigationView animate-transitions unavailable', exc_info=True)
            self._work_page = Adw.NavigationPage.new(self._content_overlay, _('SSH Pilot'))
            try:
                self._work_page.set_tag('work')
            except Exception:
                pass
            try:
                self._work_page.set_can_pop(False)
            except Exception:
                pass
            self.nav_view.add(self._work_page)
            root_widget = self.nav_view
        else:
            root_widget = self._content_overlay

        # The omni-search must float above both the work page and pages pushed
        # onto NavigationView (Preferences, editors, plugin pages).
        self._global_overlay = Gtk.Overlay()
        self._global_overlay.set_hexpand(True)
        self._global_overlay.set_vexpand(True)
        self._global_overlay.set_child(root_widget)
        main_box.append(self._global_overlay)

        from .omni_search import OmniSearchController
        self._omni_search = OmniSearchController(
            self,
            self._global_overlay,
            self.welcome_view.omni_home,
        )
        # Initial Start presentation at first paint: the Start tab is created
        # and selected before this controller exists (see ``_add_start_tab``),
        # so its selection notification cannot reach the controller here. Ask
        # for the Omnisearch attention tracer directly; the controller defers
        # it until the window is actually mapped.
        omni = getattr(self, '_omni_search', None)
        if omni is not None and hasattr(omni, 'request_attention'):
            omni.request_attention()

        # Sidebar is always visible on startup
        # (toast_overlay + main_box come from the template)

        # The window UI now exists (tab_view, toast_overlay, the plugins menu
        # section). Bind the plugin host so plugin pages, toasts, events, and
        # terminal control go live. Idempotent: only the first window binds.
        try:
            if getattr(self, 'plugin_host', None) is not None:
                self.plugin_host.bind_window(self)
        except Exception:
            logger.exception("Plugin host bind_window failed")

    def is_preferences_visible(self) -> bool:
        """True when Settings mode is the visible NavigationView page."""
        nav = getattr(self, 'nav_view', None)
        prefs = getattr(self, '_preferences_window', None)
        if nav is None or prefs is None:
            return False
        try:
            return nav.get_visible_page() is prefs
        except Exception:
            return False

    def leave_preferences(self) -> bool:
        """Pop Settings mode if it is visible. Returns True if it was showing."""
        nav = getattr(self, 'nav_view', None)
        prefs = getattr(self, '_preferences_window', None)
        if nav is None or prefs is None:
            return False
        try:
            if nav.get_visible_page() is prefs:
                nav.pop()
                return True
        except Exception:
            logger.debug('Failed to leave preferences mode', exc_info=True)
        return False

    def _set_sidebar_widget(self, widget: Gtk.Widget) -> None:
        if HAS_OVERLAY_SPLIT:
            try:
                self.split_view.set_sidebar(widget)
                return
            except Exception:
                pass
        elif HAS_NAV_SPLIT:
            try:
                # NavigationSplitView requires the sidebar to be a NavigationPage
                # According to docs: https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.2/class.NavigationSplitView.html
                sidebar_page = Adw.NavigationPage.new(widget, _("Connections"))
                self.split_view.set_sidebar(sidebar_page)
                return
            except Exception:
                pass
        # Fallback for Gtk.Paned
        try:
            self.split_view.set_start_child(widget)
        except Exception:
            pass

    def _set_content_widget(self, widget: Gtk.Widget) -> None:
        if HAS_OVERLAY_SPLIT:
            try:
                self.split_view.set_content(widget)
                return
            except Exception:
                pass
        elif HAS_NAV_SPLIT:
            try:
                # NavigationSplitView content should be a NavigationPage directly
                # According to docs: https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.2/class.NavigationSplitView.html
                # Both sidebar and content must be AdwNavigationPage objects
                content_page = Adw.NavigationPage.new(widget, _("Terminal"))
                self.split_view.set_content(content_page)
                return
            except Exception:
                pass
        # Fallback for Gtk.Paned
        try:
            self.split_view.set_end_child(widget)
        except Exception:
            pass

    def _get_sidebar_width(self) -> int:
        try:
            if (HAS_NAV_SPLIT or HAS_OVERLAY_SPLIT) and hasattr(self.split_view, 'get_max_sidebar_width'):
                return int(self.split_view.get_max_sidebar_width())
        except Exception:
            pass
        # Fallback: attempt to read allocation of the first child when using Paned
        try:
            sidebar = self.split_view.get_start_child()
            if sidebar is not None:
                alloc = sidebar.get_allocation()
                return int(alloc.width)
        except Exception:
            pass
        return 0

    def update_headerbar_buttons(self):
        """Show/hide the toggleable header-bar buttons per preferences
        (Settings ▸ Interface ▸ Header Bar)."""
        mapping = (
            ('sidebar_toggle_button', 'ui.headerbar_show_sidebar_toggle', False),
            ('split_view_button', 'ui.headerbar_show_split_view', False),
            ('_cmd_blocks_toggle_btn', 'ui.headerbar_show_commands', True),
            ('_headerbar_theme_menu_button', 'ui.headerbar_show_theme_toggle', True),
            ('_headerbar_local_terminal_button', 'ui.headerbar_show_local_terminal', True),
        )
        for attr, key, default in mapping:
            btn = getattr(self, attr, None)
            if btn is None:
                continue
            try:
                btn.set_visible(bool(self.config.get_setting(key, default)))
            except Exception:
                logger.debug("Failed to apply header-bar button visibility for %s", attr, exc_info=True)

    def update_sidebar_display(self):
        """Update sidebar display based on current preferences."""
        if not hasattr(self, 'connection_list'):
            return

        try:
            connections = self.connection_manager.get_connections()
        except Exception:
            connections = []
        self._attach_sidebar_forwarding_rules(connections)
        self._refresh_sidebar_forwarding_rules(connections)

        show_user_hostname = self.config.get_setting('ui.sidebar_show_user_hostname', True)
        show_group_count = self.config.get_setting('ui.sidebar_show_group_count', True)
        show_status = self.config.get_setting('ui.sidebar_show_connection_status', True)
        show_connection_icon = self.config.get_setting('ui.sidebar_show_connection_icon', True)
        show_group_icon = self.config.get_setting('ui.sidebar_show_group_icon', True)
        flat_rows = self.config.get_setting('ui.sidebar_flat_rows', False)

        # Update all rows in the connection list
        row = self.connection_list.get_first_child()
        while row:
            if hasattr(row, 'apply_row_style'):
                row.apply_row_style(flat_rows)
            # Update ConnectionRow elements
            if hasattr(row, 'connection_icon'):
                row.connection_icon.set_visible(show_connection_icon)
            if hasattr(row, 'host_label'):
                row.host_label.set_visible(show_user_hostname)
            if hasattr(row, 'status_icon'):
                # update_status() applies both the icon and visibility, honoring
                # the show_status pref AND keeping idle (UNKNOWN) rows iconless.
                if hasattr(row, 'update_status'):
                    row.update_status()
                else:
                    row.status_icon.set_visible(show_status)
            if hasattr(row, '_update_forwarding_indicators'):
                row._update_forwarding_indicators()

            # Update GroupRow elements
            if hasattr(row, 'count_label'):
                row.count_label.set_visible(show_group_count)
            if hasattr(row, 'group_id') and hasattr(row, 'icon'):
                row.icon.set_visible(show_group_icon)

            row = row.get_next_sibling()

        # These per-preference updates re-show widgets that minimized mode hides;
        # re-collapse so the icon strip isn't broken by a preference change (but
        # not while detached into the popup, which shows full rows).
        if getattr(self, '_sidebar_minimal', False) and not (getattr(self, "_search_popup", None) and self._search_popup.visible):
            self._apply_sidebar_minimal_rows(True)

    def _sidebar_forwarding_rules_enabled(self) -> bool:
        try:
            return bool(self.config.get_setting('ui.sidebar_show_port_forwarding', True))
        except Exception:
            return True

    def _sidebar_store_generation(self) -> int:
        try:
            return int(getattr(self.connection_manager, 'generation', 0) or 0)
        except Exception:
            return 0

    @staticmethod
    def _sidebar_connection_key(connection) -> Optional[str]:
        value = getattr(connection, 'id', None) or getattr(connection, 'nickname', None)
        return str(value or '').strip() or None

    @staticmethod
    def _sidebar_connection_uses_ssh(connection) -> bool:
        return str(getattr(connection, 'protocol', 'ssh') or 'ssh').lower() == 'ssh'

    def _attach_sidebar_forwarding_rules(self, connections) -> None:
        cache = getattr(self, '_forwarding_rules_cache', None)
        if not cache:
            return
        generation = self._sidebar_store_generation()
        for connection in connections:
            if not self._sidebar_connection_uses_ssh(connection):
                continue
            key = self._sidebar_connection_key(connection)
            if key is None:
                continue
            entry = cache.get(key)
            if entry is None or entry[0] != generation:
                continue
            try:
                object.__setattr__(connection, 'forwarding_rules', entry[1])
            except Exception:
                pass

    def _refresh_sidebar_forwarding_rules(self, connections) -> None:
        if not self._sidebar_forwarding_rules_enabled():
            return
        if getattr(self, '_is_quitting', False):
            return
        bridge = getattr(self, 'client_bridge', None)
        client = getattr(self, 'client', None)
        if bridge is None or client is None:
            return
        cache = getattr(self, '_forwarding_rules_cache', None)
        if cache is None:
            cache = {}
            self._forwarding_rules_cache = cache
        pending = getattr(self, '_forwarding_rules_pending', None)
        if pending is None:
            pending = set()
            self._forwarding_rules_pending = pending
        generation = self._sidebar_store_generation()
        for connection in connections:
            if not self._sidebar_connection_uses_ssh(connection):
                continue
            key = self._sidebar_connection_key(connection)
            if key is None:
                continue
            entry = cache.get(key)
            if entry is not None and entry[0] == generation:
                continue
            if key in pending:
                continue
            pending.add(key)
            self._submit_sidebar_forwarding_fetch(
                client, bridge, connection, key, generation, cache, pending
            )

    def _submit_sidebar_forwarding_fetch(
        self, client, bridge, connection, key, generation, cache, pending
    ) -> None:
        def on_success(details):
            pending.discard(key)
            current_generation = self._sidebar_store_generation()
            if current_generation != generation:
                logger.debug(
                    "Discarding stale forwarding rules for %s (generation %s -> %s)",
                    key,
                    generation,
                    current_generation,
                )
                self._refresh_sidebar_forwarding_rules([connection])
                return
            try:
                from .api.models.connections import (
                    ForwardingRule,
                    forwarding_rule_to_dict,
                )
                raw = getattr(details, 'forwarding_rules', None) or ()
                rules = tuple(
                    forwarding_rule_to_dict(rule)
                    for rule in raw
                    if isinstance(rule, (ForwardingRule, dict))
                )
                cache[key] = (generation, rules)
                self._apply_sidebar_forwarding_rules(connection, rules)
            except Exception:
                logger.debug(
                    "Failed to cache forwarding rules for %s", key, exc_info=True
                )

        def on_error(error):
            pending.discard(key)
            if self._sidebar_store_generation() != generation:
                self._refresh_sidebar_forwarding_rules([connection])

        try:
            bridge.submit(
                lambda: client.get_connection_editor(connection_id_for(connection)),
                on_success=on_success,
                on_error=on_error,
            )
        except Exception:
            pending.discard(key)
            logger.debug(
                "Failed to submit forwarding-rule fetch for %s", key, exc_info=True
            )

    def _apply_sidebar_forwarding_rules(self, connection, rules) -> None:
        try:
            object.__setattr__(connection, 'forwarding_rules', rules)
        except Exception:
            pass
        try:
            target = self._sidebar_connection_key(connection)
            for conn, rows in list(getattr(self, 'connection_rows', {}).items()):
                if self._sidebar_connection_key(conn) != target:
                    continue
                try:
                    object.__setattr__(conn, 'forwarding_rules', rules)
                except Exception:
                    pass
                for row in (rows if isinstance(rows, list) else [rows]):
                    if hasattr(row, '_update_forwarding_indicators'):
                        row._update_forwarding_indicators()
        except Exception:
            logger.debug("Failed to refresh sidebar forwarding rows", exc_info=True)

    def update_sidebar_max_width(self, max_width: int):
        """Update the maximum sidebar width for both NavigationSplitView and OverlaySplitView."""
        try:
            if HAS_NAV_SPLIT and hasattr(self.split_view, 'set_max_sidebar_width'):
                self.split_view.set_max_sidebar_width(max_width)
                logger.debug(f"Updated NavigationSplitView max-sidebar-width to {max_width} sp")
            elif HAS_OVERLAY_SPLIT and hasattr(self.split_view, 'set_max_sidebar_width'):
                self.split_view.set_max_sidebar_width(max_width)
                logger.debug(f"Updated OverlaySplitView max-sidebar-width to {max_width} sp")
        except Exception as e:
            logger.error(f"Failed to update max-sidebar-width: {e}")

    # --- Minimal (icon-only) sidebar strip -----------------------------------
    def _apply_sidebar_width(self, width: int) -> None:
        """Pin the split view sidebar to exactly ``width`` px (one animation tick).

        Both min and max are driven to ``width``. They must be set in the order
        that never leaves the pair transiently ``min > max`` — OverlaySplitView
        mishandles that and the sidebar fails to follow (the width jump). The
        safe order depends on the *current* constraints, not the logical
        animation direction: if the target is at/above the current max, raise the
        max ceiling first; otherwise lower the min floor first. (Deriving it from
        direction breaks the collapse "lock" step in narrow windows, where the
        current allocation can already be below the resting min.)
        """
        sv = getattr(self, 'split_view', None)
        if sv is None or not hasattr(sv, 'set_max_sidebar_width'):
            return
        try:
            current_max = int(sv.get_max_sidebar_width())
        except Exception:
            current_max = width
        try:
            if width >= current_max:
                sv.set_max_sidebar_width(width)
                sv.set_min_sidebar_width(width)
            else:
                sv.set_min_sidebar_width(width)
                sv.set_max_sidebar_width(width)
        except Exception:
            pass

    def _set_sidebar_clipping(self, enabled: bool) -> None:
        """Flip the sidebar's scrollers between clip (EXTERNAL) and fit (NEVER).

        Clipping is only wanted *during* the expand animation, so full-width
        rows/chrome can be revealed by the widening instead of forcing the
        sidebar to their minimum width. At rest the fit behaviour must return so
        rows ellipsize to the sidebar width and toolbar buttons spread.
        """
        hpol = Gtk.PolicyType.EXTERNAL if enabled else Gtk.PolicyType.NEVER
        # In the minimal strip the vertical scrollbar is hidden the documented
        # way — EXTERNAL keeps the list scrollable (wheel/touch) without drawing
        # a scrollbar over the icons; full mode shows it on demand (AUTOMATIC).
        # _set_sidebar_clipping(False) is the resting call after every
        # transition, and _sidebar_minimal is already updated by then.
        conn_vpol = (Gtk.PolicyType.EXTERNAL
                     if getattr(self, '_sidebar_minimal', False)
                     else Gtk.PolicyType.AUTOMATIC)
        targets = (
            ('connection_scrolled', conn_vpol),
            ('_sidebar_header_clip', Gtk.PolicyType.NEVER),
            ('_sidebar_toolbar_clip', Gtk.PolicyType.NEVER),
        )
        for attr, vpol in targets:
            sw = getattr(self, attr, None)
            if sw is not None:
                try:
                    sw.set_policy(hpol, vpol)
                except Exception:
                    pass

    def _apply_sidebar_minimal_chrome(self, minimal: bool) -> None:
        """Hide the header/search/toolbar chrome that can't fit the strip."""
        show = not minimal
        # The "SSH Pilot" title label has a natural min width that floors how
        # narrow the sidebar can get; hide it so the strip can shrink fully, and
        # move the title to the content header bar instead.
        title = getattr(self, '_sidebar_title_label', None)
        if title is not None:
            try:
                title.set_visible(show)
            except Exception:
                pass
        self._move_title_to_content_header(minimal)
        for attr in ('_sidebar_header_handle', 'search_container', '_sidebar_toolbar_box'):
            widget = getattr(self, attr, None)
            if widget is None:
                continue
            # The search container manages its own visibility (search mode); only
            # force it hidden in minimal, never force it visible on restore.
            if attr == 'search_container' and show:
                continue
            try:
                widget.set_visible(show)
            except Exception:
                pass
        # The expand button takes the (hidden) toolbar's slot in minimal mode.
        btn = getattr(self, '_sidebar_expand_button', None)
        if btn is not None:
            try:
                btn.set_visible(minimal)
            except Exception:
                pass
        box = getattr(self, '_sidebar_box', None)
        if box is not None:
            try:
                (box.add_css_class if minimal else box.remove_css_class)('sidebar-minimal')
            except Exception:
                pass

    def _move_title_to_content_header(self, minimal: bool) -> None:
        """Put the "SSH Pilot" title in the content header while the sidebar is a
        strip (its own header is too narrow), and clear it again when full."""
        hb = getattr(self, 'header_bar', None)
        if hb is None:
            return
        if minimal and not hasattr(self, '_content_title_label'):
            self._content_title_label = Gtk.Label(label='SSH Pilot')
            self._content_title_label.add_css_class('title')
        title = getattr(self, '_content_title_label', None)
        empty = getattr(self, '_content_empty_title', None)
        try:
            hb.set_title_widget(title if minimal else empty)
        except Exception:
            pass

    def _apply_sidebar_minimal_rows(self, minimal: bool) -> None:
        """Toggle compact rendering on every connection/group row."""
        lb = getattr(self, 'connection_list', None)
        if lb is None:
            return
        row = lb.get_first_child()
        while row is not None:
            if hasattr(row, 'set_compact'):
                try:
                    row.set_compact(minimal)
                except Exception:
                    logger.debug("row set_compact failed", exc_info=True)
            row = row.get_next_sibling()

    def _measure_sidebar_content_min(self) -> int:
        """Largest minimum width among the sidebar's full-content strips.

        The header/toolbar button rows and the connection rows each request a
        minimum width; the widest is the floor the sidebar rests at once fit
        (unclipped), which can exceed the user's max-width setting.
        """
        widest = 0
        for attr in ('_sidebar_header_handle', '_sidebar_toolbar_box', 'connection_list'):
            widget = getattr(self, attr, None)
            if widget is None:
                continue
            try:
                minimum, _nat, _a, _b = widget.measure(Gtk.Orientation.HORIZONTAL, -1)
                widest = max(widest, int(minimum))
            except Exception:
                pass
        return widest

    def set_sidebar_minimal(self, minimal: bool, animate: bool = True) -> None:
        """Collapse the sidebar to an icon-only strip, or restore its full width.

        The split view's min/max sidebar width is the single width lever; the
        transition is animated with ``Adw.TimedAnimation`` when available.
        """
        minimal = bool(minimal)
        if minimal == getattr(self, '_sidebar_minimal', False):
            return
        self._sidebar_minimal = minimal

        if minimal:
            # A strip implies the sidebar is on screen.
            try:
                self._toggle_sidebar_visibility(True)
                if hasattr(self, 'sidebar_toggle_button'):
                    self.sidebar_toggle_button.set_active(False)
            except Exception:
                logger.debug("show-for-minimal failed", exc_info=True)

        saved_max = _effective_max_sidebar_width(
            self.config.get_setting('ui.max-sidebar-width', None))
        # A user-chosen max width can be below the nominal minimum; the resting
        # min must never exceed the max (OverlaySplitView breaks on min > max,
        # bringing the jump back at small max widths).
        base_min = min(180 if HAS_OVERLAY_SPLIT else 200, saved_max)

        anim = getattr(self, '_sidebar_width_animation', None)
        if anim is not None:
            try:
                anim.pause()
            except Exception:
                pass
            self._sidebar_width_animation = None

        sv = getattr(self, 'split_view', None)
        if sv is None or not hasattr(sv, 'set_max_sidebar_width'):
            # No width lever — just swap the content to the target state.
            self._apply_sidebar_minimal_chrome(minimal)
            self._apply_sidebar_minimal_rows(minimal)
            self._set_sidebar_clipping(False)  # apply the minimal-aware vpolicy
            return

        def _fraction_width():
            try:
                frac = sv.get_sidebar_width_fraction()
                win_w = sv.get_width() or 0
                if win_w > 0:
                    return max(base_min, min(saved_max, int(frac * win_w)))
            except Exception:
                pass
            return saved_max

        # Decide the full (expanded) width and prepare the content. The chrome's
        # min width can push the resting width *above* saved_max, so the full
        # endpoint must reflect that or the end of the animation snaps to it.
        if minimal:
            # Collapsing: lock the current (full) width before compacting so
            # releasing the chrome's min width doesn't drop the sidebar first.
            full_width = 0
            box = getattr(self, '_sidebar_box', None)
            if box is not None:
                try:
                    full_width = int(box.get_width())
                except Exception:
                    full_width = 0
            if full_width <= _MINIMAL_STRIP_WIDTH:
                full_width = max(_fraction_width(), self._measure_sidebar_content_min())
            self._apply_sidebar_width(full_width)
            self._apply_sidebar_minimal_chrome(True)
            self._apply_sidebar_minimal_rows(True)
        else:
            # Expanding: clip first so restoring full content can't force the
            # width, then restore and measure the width it will rest at.
            self._set_sidebar_clipping(True)
            self._apply_sidebar_minimal_chrome(False)
            self._apply_sidebar_minimal_rows(False)
            full_width = max(_fraction_width(), self._measure_sidebar_content_min())

        full_width = max(int(full_width), _MINIMAL_STRIP_WIDTH)

        def _pin_endpoints():
            try:
                if minimal:
                    sv.set_min_sidebar_width(_MINIMAL_STRIP_WIDTH)
                    sv.set_max_sidebar_width(_MINIMAL_STRIP_WIDTH)
                else:
                    sv.set_min_sidebar_width(base_min)
                    sv.set_max_sidebar_width(saved_max)
            except Exception:
                pass

        if not animate or not HAS_TIMED_ANIMATION:
            self._set_sidebar_clipping(False)
            _pin_endpoints()
            return

        if minimal:
            start, target = float(full_width), float(_MINIMAL_STRIP_WIDTH)
        else:
            start, target = float(_MINIMAL_STRIP_WIDTH), float(full_width)

        def _tick(value, *_):
            self._apply_sidebar_width(int(value))

        target_cb = Adw.CallbackAnimationTarget.new(_tick)
        animation = Adw.TimedAnimation.new(sv, start, float(target), 200, target_cb)
        try:
            animation.set_easing(Adw.Easing.EASE_OUT_CUBIC)
        except Exception:
            pass

        def _on_done(*_a):
            self._set_sidebar_clipping(False)
            _pin_endpoints()
            self._sidebar_width_animation = None

        animation.connect('done', _on_done)
        self._sidebar_width_animation = animation
        animation.play()

    #: Delay before collapsing the drag-expanded strip, so the drop settles first.
    _DRAG_COLLAPSE_DELAY_MS = 900

    def begin_sidebar_drag_expand(self) -> None:
        """Temporarily expand a minimal strip while a drag is in progress so
        drop targets (groups, ungrouped area) are visible and reachable. Paired
        with :meth:`end_sidebar_drag_expand` on drag-end."""
        self._cancel_pending_drag_collapse()
        if getattr(self, '_sidebar_minimal', False):
            self._sidebar_expanded_for_drag = True
            self.set_sidebar_minimal(False)

    def end_sidebar_drag_expand(self) -> None:
        """Collapse the strip back if it was auto-expanded for a drag, after a
        short delay so the drop lands before the sidebar snaps closed."""
        if getattr(self, '_sidebar_expanded_for_drag', False):
            self._sidebar_expanded_for_drag = False
            self._cancel_pending_drag_collapse()
            self._drag_collapse_timeout_id = GLib.timeout_add(
                self._DRAG_COLLAPSE_DELAY_MS, self._collapse_after_drag)

    def _collapse_after_drag(self) -> bool:
        self._drag_collapse_timeout_id = 0
        self.set_sidebar_minimal(True)
        return False  # one-shot

    def _cancel_pending_drag_collapse(self) -> None:
        tid = getattr(self, '_drag_collapse_timeout_id', 0)
        if tid:
            try:
                GLib.source_remove(tid)
            except Exception:
                pass
            self._drag_collapse_timeout_id = 0

    def _show_duplicate_connection_error(self, connection: Optional[Connection], error: Exception) -> None:
        """Display an error dialog when duplication fails."""
        try:
            nickname = (getattr(connection, 'nickname', '') or _('Connection')).strip()
            heading = _('Duplicate Failed')
            body = _('Failed to duplicate connection "{name}".\n\n{details}').format(
                name=nickname,
                details=str(error) or _('An unknown error occurred.')
            )
            dialog = Adw.MessageDialog(
                transient_for=self,
                modal=True,
                heading=heading,
                body=body,
            )
            dialog.add_response('close', _('Close'))
            dialog.set_close_response('close')
            dialog.present()
        except Exception:
            pass

    def duplicate_connection(self, connection: Optional[Connection]) -> None:
        """Duplicate through the daemon and select its refreshed projection."""
        if connection is None or self.client_bridge is None or self.client is None:
            return

        def _select_duplicate(result):
            duplicated = self.connection_manager.get_connection_by_id(
                result.connection_id
            )
            self.rebuild_connection_list()
            row = self._primary_row_for_connection(duplicated)
            if row is None:
                return
            self._select_only_row(row)
            try:
                self.connection_list.scroll_to_row(row)
                self.connection_list.grab_focus()
            except Exception:
                pass

        def _refresh_after_duplicate(result):
            try:
                self.client_bridge.submit(
                    self.connection_manager.refresh,
                    on_success=lambda _snapshot: _select_duplicate(result),
                    on_error=lambda error: self._show_duplicate_connection_error(
                        connection, error
                    ),
                )
            except RuntimeError as error:
                self._show_duplicate_connection_error(connection, error)

        def _failed(error):
            self._show_duplicate_connection_error(connection, error)
            logger.error(
                "Failed to duplicate connection type=%s",
                type(error).__name__,
            )

        try:
            self.client_bridge.submit(
                lambda: self.client.duplicate_connection(
                    connection_id_for(connection)
                ),
                on_success=_refresh_after_duplicate,
                on_error=_failed,
            )
        except RuntimeError as error:
            _failed(error)

    def setup_sidebar(self):
        """Build the sidebar UI (implementation in sidebar.build_sidebar)."""
        build_sidebar(self)

    def _copy_connection_address(self):
        conn = getattr(self, '_context_menu_connection', None)
        if conn:
            host = getattr(conn, 'hostname', '') or getattr(conn, 'host', '')
            if host:
                self.get_clipboard().set(host)

    def _toggle_pin_connection(self, conn):
        """Pin or unpin a connection from the start page."""
        if conn is None:
            return
        self._toggle_pin_connections([conn])

    def _toggle_pin_connections(self, conns):
        """Pin or unpin a batch of connections from the start page.

        If every connection is already pinned, all are unpinned; otherwise
        all are pinned (the same aggregate rule the context menu label uses).
        """
        conns = [c for c in (conns or []) if c is not None]
        if not conns:
            return
        try:
            all_pinned = all(
                bool(self.connection_manager.get_metadata(c.nickname).get("pinned"))
                for c in conns
            )
            for c in conns:
                self.client.update_connection_metadata(
                    c.nickname, {"pinned": not all_pinned}
                )
            if len(conns) == 1:
                msg = _("Unpinned from start page") if all_pinned else _("Pinned to start page")
            elif all_pinned:
                msg = _("Unpinned {n} connections from start page").format(n=len(conns))
            else:
                msg = _("Pinned {n} connections to start page").format(n=len(conns))
            if hasattr(self, 'toast_overlay') and self.toast_overlay:
                previous = getattr(self, '_pin_status_toast', None)
                if previous is not None:
                    previous.dismiss()

                toast = Adw.Toast.new(msg)
                toast.set_timeout(3)
                self._pin_status_toast = toast

                def _clear_pin_status_toast(dismissed_toast):
                    if getattr(self, '_pin_status_toast', None) is dismissed_toast:
                        self._pin_status_toast = None

                toast.connect('dismissed', _clear_pin_status_toast)
                self.toast_overlay.add_toast(toast)
            if hasattr(self, 'welcome_view') and self.welcome_view:
                self.welcome_view.refresh_pinned()
        except Exception as e:
            logger.error(f"Failed to toggle pin for {len(conns)} connection(s): {e}")

    def _pick_connection_list_row(
        self, x: float, y: float
    ) -> Optional[Gtk.ListBoxRow]:
        """Return the ListBoxRow under a pointer event on the connection list.

        The coordinates come from a gesture attached to ``connection_list``
        itself, so they are already in the ListBox's content space. ``pick()``
        resolves the row directly with no scroll adjustment, which is why both
        the right-click and middle-click handlers must share this path: any
        manual vadjustment math would double-count the scroll offset and select
        a row further down the list (see issue #1013).
        """
        try:
            widget = self.connection_list.pick(x, y, Gtk.PickFlags.DEFAULT)
        except Exception as e:
            logger.debug(f"Failed to pick connection list row: {e}")
            return None

        while widget is not None:
            if isinstance(widget, Gtk.ListBoxRow):
                return widget
            if widget == self.connection_list:
                break
            widget = widget.get_parent()

        return None

    # ------------------------------------------------------------------
    # Connection sorting helpers
    # ------------------------------------------------------------------

    def _apply_app_theme(self, theme: str) -> None:
        """Apply application light/dark/system theme and persist app-theme."""
        theme_key = str(theme).lower()
        if theme_key not in {'default', 'light', 'dark'}:
            theme_key = 'default'

        style_manager = Adw.StyleManager.get_default()
        if theme_key == 'light':
            style_manager.set_color_scheme(Adw.ColorScheme.FORCE_LIGHT)
        elif theme_key == 'dark':
            style_manager.set_color_scheme(Adw.ColorScheme.FORCE_DARK)
        else:
            style_manager.set_color_scheme(Adw.ColorScheme.DEFAULT)

        self.config.set_setting('app-theme', theme_key)
        self._sync_theme_menu_button()

    def _create_theme_menu(self) -> Gio.Menu:
        menu = Gio.Menu()
        menu.append(_('Follow System'), 'win.set-app-theme::default')
        menu.append(_('Light'), 'win.set-app-theme::light')
        menu.append(_('Dark'), 'win.set-app-theme::dark')
        return menu

    def _sync_theme_menu_button(self) -> None:
        btn = getattr(self, '_headerbar_theme_menu_button', None)
        if btn is None:
            return

        labels = {
            'default': _('Follow System'),
            'light': _('Light'),
            'dark': _('Dark'),
        }
        saved = str(self.config.get_setting('app-theme', 'default'))
        current = labels.get(saved, labels['default'])
        btn.set_tooltip_text(_('Application theme: {theme}').format(theme=current))

    def _build_sort_button(self):
        from sshpilot import icon_utils
        button = icon_utils.new_button_from_icon_name("view-sort-ascending-symbolic")
        button.add_css_class('flat')
        button.set_can_focus(False)
        button.connect("clicked", self._on_sort_button_clicked)
        self.sort_button = button
        self._update_sort_button()
        return button

    def _build_preferences_button(self):
        from sshpilot import icon_utils
        button = icon_utils.new_button_from_icon_name("org.gnome.Settings-system-symbolic")
        button.add_css_class('flat')
        button.set_can_focus(False)
        button.set_tooltip_text(_("Settings"))
        button.connect("clicked", lambda *_: self.show_preferences())
        return button

    def _next_sort_preset_id(self, current_id: str) -> str:
        if current_id == "name-desc":
            return "name-asc"
        return "name-desc"

    def _update_sort_button(self):
        if not self.sort_button:
            return

        from sshpilot import icon_utils
        preset_id = self._connection_sort_last or DEFAULT_CONNECTION_SORT
        preset = CONNECTION_SORT_PRESETS.get(preset_id, CONNECTION_SORT_PRESETS[DEFAULT_CONNECTION_SORT])
        icon_utils.set_button_icon(self.sort_button, preset.icon_name)

        next_preset_id = self._next_sort_preset_id(preset_id)
        next_preset = CONNECTION_SORT_PRESETS.get(next_preset_id)
        if next_preset:
            tooltip = _("Sort {current} — click for {next}").format(
                current=preset.title, next=next_preset.title
            )
        else:
            tooltip = _("Sort {title}").format(title=preset.title)

        try:
            self.sort_button.set_tooltip_text(tooltip)
        except Exception:
            pass

    def _on_sort_button_clicked(self, *_args):
        current = self._connection_sort_last or DEFAULT_CONNECTION_SORT
        next_preset = self._next_sort_preset_id(current)
        self.apply_connection_sort_preset(next_preset)

    def apply_connection_sort_preset(self, preset_id: str):
        preset = CONNECTION_SORT_PRESETS.get(preset_id)
        if not preset:
            preset_id = DEFAULT_CONNECTION_SORT
            preset = CONNECTION_SORT_PRESETS[preset_id]

        changed = apply_sort_to_manager(
            self.group_manager,
            self.connection_manager.get_connections(),
            preset_id,
        )

        self._connection_sort_last = preset_id
        try:
            self.config.set_setting('ui.connection_sort_last', preset_id)
        except Exception:
            pass

        self._update_sort_button()
        if changed:
            self.rebuild_connection_list()

        self._notify_sort_result(preset, changed)

    def _notify_sort_result(self, preset, changed: bool):
        toast_overlay = getattr(self, "toast_overlay", None)
        if not toast_overlay:
            return

        if changed:
            message = _("Connections sorted — {title}").format(title=preset.title)
        else:
            message = _("Already sorted as {title}").format(title=preset.title)

        toast = Adw.Toast.new(message)
        toast.set_timeout(3)
        toast_overlay.add_toast(toast)

    def setup_content_area(self):
        """Set up the main content area with tab overview and pinned Start tab."""
        # Create tab view
        self.tab_view = Adw.TabView()
        self.tab_view.set_hexpand(True)
        self.tab_view.set_vexpand(True)

        # SSH Pilot owns *all* keyboard-driven tab navigation through its own
        # configurable Gio.SimpleAction layer (tab-next, tab-prev,
        # tab-move-left, tab-move-right, tab-overview — see main.py
        # create_action calls and shortcut_editor.py). Adw.TabView's built-in
        # accelerators (Ctrl+PageUp/Down, Ctrl+Shift+PageUp/Down, Ctrl+Tab,
        # Ctrl+Shift+Tab, Ctrl+Home/End, Alt+digits, ...) are a second,
        # independent handler for the same keys that GTK dispatches *before*
        # our app-level accelerators are even considered, and they are not
        # wired to the shortcut editor at all. That meant disabling a tab
        # shortcut in preferences only removed our accelerator while
        # Adw.TabView kept eating the key, so it never reached the focused
        # VTE terminal (e.g. Ctrl+PageUp/Down in vim/nano) — see issue #1170.
        # Disable every Adw.TabView built-in so there is exactly one
        # authoritative owner of tab-navigation keys; anything not claimed by
        # an SSH Pilot accelerator falls straight through to the terminal.
        try:
            self.tab_view.set_shortcuts(Adw.TabViewShortcuts.NONE)
        except Exception:
            logger.debug("Could not adjust Adw.TabView shortcuts", exc_info=True)

        # Provide widget-scoped Alt+Arrow navigation helpers for tab-specific focus
        try:
            tab_nav = Gtk.ShortcutController()
            tab_nav.set_scope(Gtk.ShortcutScope.LOCAL)

            tab_nav.add_shortcut(Gtk.Shortcut.new(
                Gtk.ShortcutTrigger.parse_string('<Alt>Right'),
                Gtk.CallbackAction.new(lambda widget, *a: self._on_tab_nav_shortcut(1))
            ))
            tab_nav.add_shortcut(Gtk.Shortcut.new(
                Gtk.ShortcutTrigger.parse_string('<Alt>Left'),
                Gtk.CallbackAction.new(lambda widget, *a: self._on_tab_nav_shortcut(-1))
            ))

            self.tab_view.add_controller(tab_nav)
        except Exception:
            pass

        # Connect tab signals
        self.tab_view.connect('close-page', self.on_tab_close)
        self.tab_view.connect('page-attached', self.on_tab_attached)
        self.tab_view.connect('page-detached', self.on_tab_detached)
        # Track selected tab to keep row selection in sync
        self.tab_view.connect('notify::selected-page', self.on_tab_selected)

        # Context-aware right-click tab menu (official AdwTabView mechanism:
        # set_menu_model + setup-menu signal). A single menu model holds every
        # item; the setup-menu handler enables only the actions relevant to the
        # right-clicked tab type, and each item is hidden when its action is
        # disabled (hidden-when="action-disabled"). The model/popover is built
        # once and cached by AdwTabBox, so per-tab differences must come from
        # action state, not from swapping the model.
        self._tab_menu_page = None
        self._tab_menu_xy = None
        self._build_tab_context_menus()
        self.tab_view.set_menu_model(self._tab_menu_model)
        self.tab_view.connect('setup-menu', self._on_tab_setup_menu)

        # Whenever the window layout changes, propagate toolbar height to
        # any TerminalWidget so the reconnect banner exactly matches.
        try:
            # Capture the toolbar variable from this scope for measurement
            local_toolbar = locals().get('toolbar', None)
            def _sync_banner_heights(*args):
                try:
                    # Re-measure toolbar height in case style/theme changed
                    try:
                        if local_toolbar is not None:
                            min_h, nat_h, min_baseline, nat_baseline = local_toolbar.measure(Gtk.Orientation.VERTICAL, -1)
                            self._toolbar_row_height = max(min_h, nat_h)
                    except Exception:
                        pass
                    # Push exact allocated height to all terminal widgets (+5px)
                    for terms in self.connection_to_terminals.values():
                        for term in terms:
                            if hasattr(term, 'set_banner_height'):
                                term.set_banner_height(getattr(self, '_toolbar_row_height', 37) + 55)
                except Exception:
                    pass
            # Call once after UI is built and again after a short delay
            def _push_now():
                try:
                    height = getattr(self, '_toolbar_row_height', 36)
                    for terms in self.connection_to_terminals.values():
                        for term in terms:
                            if hasattr(term, 'set_banner_height'):
                                term.set_banner_height(height + 55)
                except Exception:
                    pass
                return False
            GLib.idle_add(_sync_banner_heights)
            GLib.timeout_add(200, _sync_banner_heights)
            GLib.idle_add(_push_now)
        except Exception:
            pass

        # Create tab overview
        self.tab_overview = Adw.TabOverview()
        self.tab_overview.set_view(self.tab_view)
        self.tab_overview.set_enable_new_tab(False)
        self.tab_overview.set_enable_search(True)
        # Hide window buttons in tab overview
        self.tab_overview.set_show_start_title_buttons(False)
        self.tab_overview.set_show_end_title_buttons(False)

        # Create tab bar
        self.tab_bar = Adw.TabBar()
        self.tab_bar.set_view(self.tab_view)
        self.tab_bar.set_autohide(False)
        self.tab_bar.set_expand_tabs(False)
        # Blend with the flat header bar above it (no headerbar fill / seam).
        self.tab_bar.add_css_class('inline')

        # H/V layout toggles after the last tab (tab bar end action)
        from .split_view import create_layout_toggle_buttons

        self._layout_h_btn, self._layout_v_btn, self._layout_toggle_updating = (
            create_layout_toggle_buttons(
                lambda: self._apply_tab_layout_mode('horizontal'),
                lambda: self._apply_tab_layout_mode('vertical'),
            )
        )
        self._layout_h_btn.set_visible(False)
        self._layout_v_btn.set_visible(False)

        self.tab_button = Adw.TabButton()
        self.tab_button.set_view(self.tab_view)
        self.tab_button.connect('clicked', self.on_tab_button_clicked)
        self.tab_button.set_visible(False)  # Hidden by default, shown when tabs exist

        layout_end_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
        layout_end_box.append(self._layout_h_btn)
        layout_end_box.append(self._layout_v_btn)
        layout_end_box.append(self.tab_button)
        self.tab_bar.set_end_action_widget(layout_end_box)

        # Double-click on a tab to rename it inline
        rename_gesture = Gtk.GestureClick()
        rename_gesture.set_button(1)
        rename_gesture.connect('pressed', self._on_tab_bar_pressed)
        self.tab_bar.add_controller(rename_gesture)

        # Suppress the context menu on the pinned Start tab. This capture-phase
        # gesture runs before AdwTabBox's own (bubble-phase) right-click handler,
        # so we can clear the view's menu model just-in-time when the click lands
        # on the Start tab — AdwTabBox.do_popup early-returns on a NULL model.
        menu_guard = Gtk.GestureClick()
        menu_guard.set_button(Gdk.BUTTON_SECONDARY)
        menu_guard.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
        menu_guard.connect('pressed', self._on_tab_bar_secondary_press)
        self.tab_bar.add_controller(menu_guard)

        # Create tab content box
        self.tab_content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.tab_content_box.append(self.tab_bar)
        self.tab_content_box.append(self.tab_view)
        if hasattr(self.tab_view, 'add_css_class'):
            self.tab_view.add_css_class('terminal-bg')

        # Set the tab content box as the child of the tab overview
        self.tab_overview.set_child(self.tab_content_box)

        self._create_start_tab()
        self._update_tab_button_visibility()

        # Split-view button — opens a new split-view tab
        from sshpilot import icon_utils as _iu
        self.split_view_button = Gtk.Button()
        _iu.set_button_icon(self.split_view_button, 'view-grid-symbolic')
        self.split_view_button.set_tooltip_text(_('New Split View'))
        self.split_view_button.add_css_class('flat')
        self.split_view_button.connect('clicked', self.on_open_split_view_clicked)
        self.header_bar.pack_start(self.split_view_button)

        # Command blocks toggle button (right sidebar)
        from sshpilot import icon_utils as _cmd_icon_utils

        self._headerbar_theme_menu_button = Gtk.MenuButton()
        _cmd_icon_utils.set_button_icon(self._headerbar_theme_menu_button, 'dark-mode-symbolic')
        self._headerbar_theme_menu_button.add_css_class('flat')
        self._headerbar_theme_menu_button.set_tooltip_text(_('Application theme'))
        self._headerbar_theme_menu_button.set_menu_model(self._create_theme_menu())

        self._cmd_blocks_toggle_btn = Gtk.ToggleButton()
        _cmd_icon_utils.set_button_icon(self._cmd_blocks_toggle_btn, 'system-run-symbolic')
        self._cmd_blocks_toggle_btn.add_css_class('flat')
        self._cmd_blocks_toggle_btn.set_tooltip_text(_('Commands'))
        self._updating_cmd_toggle = False

        def _on_cmd_toggle_btn_toggled(btn):
            if self._updating_cmd_toggle:
                return
            self._toggle_command_blocks_panel(btn.get_active())

        self._cmd_blocks_toggle_btn.connect('toggled', _on_cmd_toggle_btn_toggled)

        # Fullscreen toggle. Hidden outside fullscreen so normal chrome is
        # unchanged; shown while fullscreen so the revealed header bar carries
        # its own way out (no bespoke overlay button is needed).
        self.fullscreen_button = Gtk.Button()
        _cmd_icon_utils.set_button_icon(self.fullscreen_button, 'view-restore-symbolic')
        self.fullscreen_button.add_css_class('flat')
        self.fullscreen_button.set_visible(False)
        # Same window action every fullscreen accelerator resolves to, so the
        # button and the shortcut can never drift apart.
        self.fullscreen_button.set_action_name(f'win.{TOGGLE_FULLSCREEN_ACTION}')
        self._update_fullscreen_button_tooltip()
        self.header_bar.pack_end(self.fullscreen_button)

        self.header_bar.pack_end(self._cmd_blocks_toggle_btn)
        self.header_bar.pack_end(self._headerbar_theme_menu_button)
        self.header_bar.pack_end(self.menu_button)
        self._sync_theme_menu_button()

        # Create broadcast command banner (custom banner-like widget)
        self.broadcast_banner = Gtk.Revealer()
        self.broadcast_banner.set_visible(False)
        self.broadcast_banner.set_reveal_child(False)
        self.broadcast_banner.set_transition_type(Gtk.RevealerTransitionType.NONE)
        self.broadcast_hide_timeout_id: Optional[int] = None
        self.broadcast_entry_dirty = False
        self._suppress_broadcast_entry_changed = False

        # Create banner content box
        banner_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        banner_box.add_css_class('banner')
        banner_box.set_can_focus(True)
        banner_box.set_focusable(True)

        # Create banner header with title and send button
        banner_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        banner_header.set_margin_start(12)
        banner_header.set_margin_end(12)
        banner_header.set_margin_top(8)
        banner_header.set_margin_bottom(4)

        # Banner title
        banner_title = Gtk.Label(label=_("Broadcast Command"))
        banner_title.set_xalign(0)
        banner_title.add_css_class('title-4')
        banner_header.append(banner_title)

        # Send button
        self.broadcast_send_button = Gtk.Button()
        self.broadcast_send_button.set_label(_("Send"))
        self.broadcast_send_button.add_css_class('suggested-action')
        self.broadcast_send_button.connect('clicked', self.on_broadcast_send_clicked)
        banner_header.append(self.broadcast_send_button)

        banner_box.append(banner_header)

        # Create banner content with entry and cancel button
        banner_content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        banner_content.set_margin_start(12)
        banner_content.set_margin_end(12)
        banner_content.set_margin_bottom(8)

        # Create command entry
        self.broadcast_entry = Gtk.Entry()
        self.broadcast_entry.set_placeholder_text(_("e.g., ls -la"))
        self.broadcast_entry.set_hexpand(True)
        self.broadcast_entry.connect('activate', self.on_broadcast_entry_activate)
        self.broadcast_entry.connect('changed', self.on_broadcast_entry_changed)

        # Add ESC key handling to the entry
        entry_controller = Gtk.EventControllerKey()
        entry_controller.connect('key-pressed', self.on_broadcast_entry_key_pressed)
        self.broadcast_entry.add_controller(entry_controller)

        focus_controller = Gtk.EventControllerFocus()
        focus_controller.connect('enter', self.on_broadcast_entry_focus_enter)
        focus_controller.connect('leave', self.on_broadcast_entry_focus_leave)
        self.broadcast_entry.add_controller(focus_controller)

        banner_content.append(self.broadcast_entry)

        # Create cancel button
        self.broadcast_cancel_button = Gtk.Button()
        self.broadcast_cancel_button.set_label(_("Cancel"))
        self.broadcast_cancel_button.connect('clicked', self.on_broadcast_cancel_clicked)
        banner_content.append(self.broadcast_cancel_button)

        banner_box.append(banner_content)

        # Set the banner box as the revealer's child
        self.broadcast_banner.set_child(banner_box)

        # Add global ESC key handling to the entire banner
        banner_controller = Gtk.EventControllerKey()
        banner_controller.connect('key-pressed', self.on_broadcast_banner_key_pressed)
        banner_box.add_controller(banner_controller)

        # Only the Adw split variants build a ToolbarView; the legacy fallback
        # below has none, and fullscreen degrades to plain hiding there.
        self._content_toolbar_view = None

        if HAS_OVERLAY_SPLIT:
            content_box = Adw.ToolbarView()
            # Kept on the window: terminal fullscreen folds the tab bar into
            # this view's top-bar group and drives its reveal/extend properties
            # to overlay the chrome (see window_fullscreen.py).
            self._content_toolbar_view = content_box
            content_box.add_top_bar(self.header_bar)
            # Create content wrapper with banner below header bar
            content_wrapper = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
            content_wrapper.append(self.update_banner_container)
            content_wrapper.append(self.tips_banner_container)
            content_wrapper.append(self.broadcast_banner)
            content_wrapper.append(self.tab_overview)
            # Wrap only the content area (below the header bar) so the command
            # blocks sidebar opens inside the terminal pane, not across the full window.
            self._wrap_content_with_command_panel(content_wrapper, set_as_window_content=False)
            content_box.set_content(
                self.cmd_split_view if self.cmd_split_view is not None else content_wrapper
            )
            main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
            main_box.append(content_box)
            self._set_content_widget(main_box)
            logger.debug("Set content widget for OverlaySplitView")
        elif HAS_NAV_SPLIT:
            content_box = Adw.ToolbarView()
            self._content_toolbar_view = content_box
            content_box.add_top_bar(self.header_bar)
            # Create content wrapper with banner below header bar
            content_wrapper = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
            content_wrapper.append(self.update_banner_container)
            content_wrapper.append(self.tips_banner_container)
            content_wrapper.append(self.broadcast_banner)
            content_wrapper.append(self.tab_overview)
            # Same: scope the sidebar to the content pane only.
            self._wrap_content_with_command_panel(content_wrapper, set_as_window_content=False)
            content_box.set_content(
                self.cmd_split_view if self.cmd_split_view is not None else content_wrapper
            )
            main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
            main_box.append(content_box)
            self._set_content_widget(main_box)
            logger.debug("Set content widget for NavigationSplitView")
        else:
            # For non-split views, create a vertical box to contain banners and content
            main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
            main_box.append(self.update_banner_container)
            main_box.append(self.tips_banner_container)
            main_box.append(self.broadcast_banner)
            main_box.append(self.tab_overview)
            self._set_content_widget(main_box)
            logger.debug("Set content widget for other split view types")

    def _ensure_command_block_store(self) -> Optional[CommandBlockStore]:
        """Create the command-block store on first use."""
        if self.command_block_store is not None:
            return self.command_block_store
        app = self.get_application()
        config = getattr(app, 'config', None) if app else None
        if config is None:
            config = getattr(self, 'config', None)
        if config is None:
            return None
        from .command_blocks import CommandBlockStore
        self.command_block_store = CommandBlockStore(config)
        return self.command_block_store

    def _ensure_command_blocks_panel(self) -> Optional[CommandBlocksPanel]:
        """Build the command blocks sidebar on first open (or when always shown)."""
        if self.command_blocks_panel is not None:
            return self.command_blocks_panel
        if self.cmd_split_view is None:
            return None
        store = self._ensure_command_block_store()
        if store is None:
            return None
        from .command_blocks import CommandBlocksPanel
        self.command_blocks_panel = CommandBlocksPanel(self, store)
        self.cmd_split_view.set_sidebar(self.command_blocks_panel)
        logger.debug("Command blocks panel created")
        return self.command_blocks_panel

    def _wrap_content_with_command_panel(self, content_widget: Gtk.Widget, *, set_as_window_content: bool = True) -> None:
        """Build a right-side OverlaySplitView for the command blocks panel around content_widget.

        When set_as_window_content is True (default) the resulting split view is
        installed as the window content via _set_content_widget.  Pass False when
        the caller wants to place the split view itself (e.g. as the content of an
        inner ToolbarView so the sidebar sits next to the terminal only).
        """
        if not HAS_OVERLAY_SPLIT:
            if set_as_window_content:
                self._set_content_widget(content_widget)
            logger.warning("Command blocks panel requires Adw.OverlaySplitView; panel disabled")
            return
        try:
            app = self.get_application()
            config = getattr(app, 'config', None) if app else None
            if config is None:
                config = getattr(self, 'config', None)
            self.cmd_split_view = Adw.OverlaySplitView()
            self.cmd_split_view.set_sidebar_position(Gtk.PackType.END)
            _always_show = bool(config.get_setting('command_blocks.always_show_sidebar', False)) if config else False
            self.cmd_split_view.set_show_sidebar(_always_show)
            self._command_sidebar_visible = _always_show
            if _always_show and getattr(self, '_cmd_blocks_toggle_btn', None) is not None:
                self._updating_cmd_toggle = True
                self._cmd_blocks_toggle_btn.set_active(True)
                self._updating_cmd_toggle = False
            try:
                self.cmd_split_view.set_min_sidebar_width(240)
                self.cmd_split_view.set_max_sidebar_width(400)
                self.cmd_split_view.set_sidebar_width_fraction(0.28)
            except Exception:
                pass
            self.cmd_split_view.set_hexpand(True)
            self.cmd_split_view.set_vexpand(True)

            if _always_show:
                self._ensure_command_blocks_panel()

            self.cmd_split_view.set_content(content_widget)
            if set_as_window_content:
                self._set_content_widget(self.cmd_split_view)
            logger.debug("Command blocks split view created")
        except Exception as exc:
            logger.error("Failed to create command blocks panel: %s", exc)
            if set_as_window_content:
                self._set_content_widget(content_widget)

    def _toggle_command_blocks_panel(self, visible: bool | None = None) -> None:
        """Show or hide the command blocks right sidebar."""
        if self.cmd_split_view is None:
            return
        try:
            if visible is None:
                visible = not self.cmd_split_view.get_show_sidebar()
            if visible:
                self._ensure_command_blocks_panel()
            self.cmd_split_view.set_show_sidebar(visible)
            self._command_sidebar_visible = visible
            if self._cmd_blocks_toggle_btn is not None:
                self._updating_cmd_toggle = True
                self._cmd_blocks_toggle_btn.set_active(visible)
                self._updating_cmd_toggle = False
            if visible and self.command_blocks_panel is not None:
                GLib.idle_add(self.command_blocks_panel.focus_search)
            elif not visible:
                terminal = self._get_active_terminal_widget()
                if terminal is not None:
                    self._focus_terminal_widget(terminal)
        except Exception as exc:
            logger.debug("_toggle_command_blocks_panel: %s", exc)

    def create_menu(self):
        """Create application menu"""
        menu = Gio.Menu()

        new_section = Gio.Menu()
        new_section.append(_('New Connection'), 'app.new-connection')
        new_section.append(_('Create Group'), 'win.create-group')
        new_section.append(_('Local Terminal'), 'app.local-terminal')
        menu.append_section(None, new_section)

        server_section = Gio.Menu()
        server_section.append(_('Copy Key to Server'), 'app.new-key')
        server_section.append(_('Broadcast Command'), 'app.broadcast-command')
        if not should_hide_file_manager_options():
            server_section.append(_('Manage Files'), 'win.open-file-manager')
        menu.append_section(None, server_section)

        ssh_section = Gio.Menu()
        ssh_section.append(_('SSH Config Editor'), 'app.edit-ssh-config')
        ssh_section.append(_('Known Hosts Editor'), 'win.edit-known-hosts')
        ssh_section.append(_('Manage Local authorized_keys…'), 'win.manage-local-authorized-keys')
        menu.append_section(None, ssh_section)

        submenu_section = Gio.Menu()

        sessions_menu = Gio.Menu()
        sessions_menu.append(_('Save Session…'), 'win.save-session')
        sessions_menu.append(_('Open Session…'), 'win.open-session')
        sessions_menu.append(_('Manage Sessions…'), 'win.manage-sessions')
        submenu_section.append_submenu(_('Sessions'), sessions_menu)

        import_export_menu = Gio.Menu()
        import_export_menu.append(_('Export Configuration'), 'win.export-config')
        import_export_menu.append(_('Import Configuration'), 'win.import-config')
        submenu_section.append_submenu(_('Import/Export'), import_export_menu)

        # Plugin-contributed pages live in the Tools submenu. The section
        # object is shared/mutable, so items the plugin host appends after
        # this menu is built still appear.
        plugins_section = getattr(self, '_plugins_menu_section', None)
        if plugins_section is not None:
            tools_menu = Gio.Menu()
            tools_menu.append_section(None, plugins_section)
            submenu_section.append_submenu(_('Tools'), tools_menu)

        menu.append_section(None, submenu_section)

        settings_section = Gio.Menu()
        settings_section.append(_('Settings'), 'app.preferences')
        menu.append_section(None, settings_section)

        help_section = Gio.Menu()
        # Help submenu with platform-aware keyboard shortcuts overlay
        help_menu = Gio.Menu()
        help_menu.append(_('Keyboard Shortcuts'), 'app.shortcuts')
        help_menu.append(_('Documentation'), 'app.help')
        help_menu.append(_('Check for Updates'), 'win.check-for-updates')
        help_menu.append(_('View Logs…'), 'win.view-logs')
        help_menu.append(_('Report a Problem…'), 'win.report-problem')
        help_menu.append(_('Export Diagnostics…'), 'win.export-diagnostics')
        help_section.append_submenu(_('Help'), help_menu)
        help_section.append(_('About'), 'app.about')
        menu.append_section(None, help_section)

        quit_section = Gio.Menu()
        quit_section.append(_('Quit'), 'app.quit')
        menu.append_section(None, quit_section)

        return menu

    def setup_connections(self):
        """Load and display existing connections with grouping"""
        self.rebuild_connection_list()

        # Select first connection if available
        connections = self.connection_manager.get_connections()
        if connections:
            first_row = self.connection_list.get_row_at_index(0)
            if first_row:
                self._select_only_row(first_row)
                # Defer focus to the list to ensure keyboard navigation works immediately
                GLib.idle_add(self._focus_connection_list_first_row)

    def _finish_rebuild(
        self,
        scroll_position,
        selected_connection_rows=(),
    ) -> None:
        """Common tail for every rebuild_connection_list() exit path.

        Freshly-built rows always start expanded; re-collapse them when the
        sidebar is the icon strip (the filtered search/tag paths return early and
        would otherwise show full rows inside the strip), then restore scroll.
        While the sidebar is detached into the popup it shows full rows, so skip
        the re-collapse then.
        """
        self._ungrouped_area_row = None
        # Command/settings results in the popup are parked until the welcome-page
        # omnisearch lands — re-enable by calling self._append_command_matches().
        if getattr(self, '_sidebar_minimal', False) and not (getattr(self, "_search_popup", None) and self._search_popup.visible):
            self._apply_sidebar_minimal_rows(True)
        for connection_uuid, group_id in selected_connection_rows:
            connection = self.connection_manager.get_connection_by_uuid(
                connection_uuid
            )
            for row in self.connection_rows.get(connection, ()):
                if getattr(row, '_group_id', None) == group_id:
                    self.connection_list.select_row(row)
                    break
        if scroll_position is not None and hasattr(self, 'connection_scrolled') and self.connection_scrolled:
            vadj = self.connection_scrolled.get_vadjustment()
            if vadj:
                GLib.idle_add(lambda: vadj.set_value(scroll_position))

    def _append_command_matches(self) -> None:
        """When the search popup is open with a query, append in-app commands
        (settings/utilities) below the connection results — see
        command_palette.py."""
        popup = getattr(self, '_search_popup', None)
        if popup is None or not popup.visible:
            return
        entry = getattr(self, 'search_entry', None)
        query = entry.get_text().strip() if entry else ''
        if not query:
            return
        from .command_palette import append_command_rows
        append_command_rows(self, query)

    def rebuild_connection_list(self):
        """Rebuild the connection list with groups"""
        reset_connection_list_drag_session(self)

        # Save current scroll position
        scroll_position = None
        if hasattr(self, 'connection_scrolled') and self.connection_scrolled:
            vadj = self.connection_scrolled.get_vadjustment()
            if vadj:
                scroll_position = vadj.get_value()
        selected_connection_rows = []
        get_selected_rows = getattr(
            self.connection_list,
            'get_selected_rows',
            None,
        )
        get_selected_row = getattr(
            self.connection_list,
            'get_selected_row',
            None,
        )
        if callable(get_selected_rows):
            selected_rows = list(get_selected_rows())
        elif callable(get_selected_row):
            selected = get_selected_row()
            selected_rows = [selected] if selected is not None else []
        else:
            selected_rows = []
        for row in selected_rows:
            connection = getattr(row, 'connection', None)
            connection_id = getattr(connection, 'id', None) or getattr(connection, 'nickname', None)
            if connection_id:
                selected_connection_rows.append(
                    (connection_id, getattr(row, '_group_id', None))
                )

        # Clear existing rows
        child = self.connection_list.get_first_child()
        while child:
            next_child = child.get_next_sibling()
            self.connection_list.remove(child)
            child = next_child
        self.connection_rows.clear()

        # Get all connections
        connections = self.connection_manager.get_connections()
        # Attach tags so the search filter and freshly built rows see them.
        for conn in connections:
            try:
                object.__setattr__(
                    conn,
                    "tags",
                    list(self.connection_manager.get_metadata(conn.nickname).get("tags", [])),
                )
            except Exception:
                object.__setattr__(conn, 'tags', [])
        self._attach_sidebar_forwarding_rules(connections)
        self._refresh_sidebar_forwarding_rules(connections)
        connections_dict = {conn.nickname: conn for conn in connections}
        connections_dict.update(
            {
                conn.id: conn
                for conn in connections
                if getattr(conn, 'id', None)
            }
        )
        search_text = ''
        if hasattr(self, 'search_entry') and self.search_entry:
            search_text = self.search_entry.get_text().strip().lower()

        tag_filter = getattr(self, '_tag_filter', None)

        # When the search popup asks for a flat list (no group headers), show a
        # plain connection list honouring the active search/tag filters.
        popup = getattr(self, '_search_popup', None)
        if popup is not None and popup.visible and not popup.show_groups:
            matches = [
                c for c in connections
                if (not tag_filter or tag_filter in {str(t).casefold()
                    for t in (getattr(c, 'tags', None) or [])})
                and (not search_text or connection_matches(c, search_text))
            ]
            for conn in sorted(matches, key=lambda c: c.nickname.lower()):
                self.add_connection_row(conn)
            self._finish_rebuild(scroll_position, selected_connection_rows)
            return

        if tag_filter:
            matches = [
                c for c in connections
                if tag_filter in {str(t).casefold() for t in (getattr(c, 'tags', None) or [])}
                and (not search_text or connection_matches(c, search_text))
            ]
            for conn in sorted(matches, key=lambda c: c.nickname.lower()):
                self.add_connection_row(conn)
            self._finish_rebuild(scroll_position, selected_connection_rows)
            return

        if search_text:
            displayed_connections = set()

            matched_groups: List[Dict[str, Any]] = []
            try:
                for group_info in self.group_manager.get_all_groups():
                    group_name = group_info.get('name', '')
                    if search_text in group_name.lower():
                        group_id = group_info.get('id')
                        if group_id and group_id in getattr(self.group_manager, 'groups', {}):
                            matched_groups.append(
                                copy.deepcopy(self.group_manager.groups[group_id])
                            )
            except Exception as error:
                logger.error(f"Error gathering matching groups: {error}")
                matched_groups = []

            for group_info in matched_groups:
                group_row = GroupRow(group_info, self.group_manager, connections_dict)
                group_row.connect('group-toggled', self._on_group_toggled)
                self.connection_list.append(group_row)

                for conn_nickname in group_info.get('connections', []):
                    if conn_nickname in connections_dict:
                        conn = connections_dict[conn_nickname]
                        self.add_connection_row(
                            conn,
                            indent_level=1,
                            display_group_id=group_info.get('id'),
                        )
                        displayed_connections.add(conn.id)

            matches = [
                c for c in connections
                if connection_matches(c, search_text)
                and c.id not in displayed_connections
            ]
            for conn in sorted(matches, key=lambda c: c.nickname.lower()):
                self.add_connection_row(conn)
                displayed_connections.add(conn.id)
            self._finish_rebuild(scroll_position, selected_connection_rows)
            return


        # Get group hierarchy
        hierarchy = self.group_manager.get_group_hierarchy()

        # Build the list with groups
        self._build_grouped_list(hierarchy, connections_dict, 0)

        # Add ungrouped connections at the end. A connection is only ungrouped
        # when it does not belong to any group (it may belong to several).
        ungrouped_nicks = [
            conn.id for conn in connections
            if not self.group_manager.get_connection_groups(conn.id)
        ]

        if ungrouped_nicks:
            # Keep root connection order in sync
            updated = False
            for nick in ungrouped_nicks:
                if nick not in self.group_manager.root_connections:
                    self.group_manager.root_connections.append(nick)
                    updated = True

            existing = set(ungrouped_nicks)
            if any(nick not in existing for nick in self.group_manager.root_connections):
                self.group_manager.root_connections = [
                    nick for nick in self.group_manager.root_connections
                    if nick in existing
                ]
                updated = True

            if updated:
                self.group_manager._save_groups()

            for nick in self.group_manager.root_connections:
                conn = connections_dict.get(nick)
                if conn:
                    self.add_connection_row(conn)


        self._finish_rebuild(scroll_position, selected_connection_rows)
    def _build_grouped_list(self, hierarchy, connections_dict, level):
        """Recursively build the grouped connection list.

        Returns the list of top-level GroupRows created at this level so the
        caller can register them as children for in-place expand/collapse.
        """
        created_rows = []
        for group_info in hierarchy:
            # Add group row
            group_row = GroupRow(group_info, self.group_manager, connections_dict)
            group_row.connect('group-toggled', self._on_group_toggled)
            if hasattr(group_row, "set_indentation"):
                group_row.set_indentation(level)
            self.connection_list.append(group_row)

            # Build direct connection rows once, then let expand/collapse toggle
            # their visibility in place to avoid full-list flicker.
            for conn_nickname in group_info.get('connections', []):
                if conn_nickname in connections_dict:
                    conn = connections_dict[conn_nickname]
                    row = self.add_connection_row(
                        conn,
                        level + 1,
                        display_group_id=group_info.get('id'),
                    )
                    if row is not None and hasattr(group_row, "add_member_row"):
                        group_row.add_member_row(row)

            # Recursively add child groups and register them directly so a
            # parent collapse hides nested subgroups in place.
            if group_info.get('children'):
                child_group_rows = self._build_grouped_list(
                    group_info['children'], connections_dict, level + 1
                )
                if hasattr(group_row, "add_child_group_row"):
                    for child_row in child_group_rows:
                        group_row.add_child_group_row(child_row)

            if hasattr(group_row, "apply_descendant_visibility"):
                group_row.apply_descendant_visibility(True)

            created_rows.append(group_row)
        return created_rows

    def _on_group_toggled(self, group_row, group_id, expanded):
        """Handle group expand/collapse"""
        if hasattr(group_row, "apply_descendant_visibility"):
            group_row.apply_descendant_visibility(True)

        # Reselect the toggled group so focus doesn't jump to another row
        for row in self.connection_list:
            if hasattr(row, "group_id") and row.group_id == group_id:
                self._select_only_row(row)
                break

    def add_connection_row(
        self,
        connection: Connection,
        indent_level: int = 0,
        display_group_id: Optional[str] = None,
        in_tag_section: bool = False,
    ):
        """Add a connection row to the list with optional indentation"""
        runtime_status = getattr(self, 'connection_runtime_status', None)
        status_resolver = (
            runtime_status.status_for if runtime_status is not None else None
        )
        row = ConnectionRow(
            connection,
            self.group_manager,
            self.config,
            file_manager_callback=self._open_manage_files_for_connection,
            effective_warning_callback=self._request_effective_warning,
            status_resolver=status_resolver,
            display_group_id=display_group_id,
            in_tag_section=in_tag_section,
        )

        # Apply indentation preference for grouped connections
        row.set_indentation(indent_level)

        self.connection_list.append(row)
        # A connection can appear under multiple groups, so keep a list of rows
        self.connection_rows.setdefault(connection, []).append(row)

        # Apply current hide-hosts setting to new row
        if hasattr(row, 'apply_hide_hosts'):
            row.apply_hide_hosts(getattr(self, '_hide_hosts', False))

        return row

    def _on_runtime_connection_status_changed(self, connection_id, _status):
        """Refresh every row representing a changed daemon connection status."""
        connection = self.connection_manager.get_connection_by_id(connection_id)
        if connection is None:
            return
        for row in self._rows_for_connection(connection):
            row.update_status()
            row.queue_draw()

    def on_search_changed(self, entry):
        """Handle search text changes and update connection list."""
        self.rebuild_connection_list()
        first_row = self.connection_list.get_row_at_index(0)
        if first_row:
            self._select_only_row(first_row)

    def on_search_stopped(self, entry):
        """Handle search stop (Esc key)."""
        entry.set_text('')
        self.rebuild_connection_list()
        # Hide the search container
        if hasattr(self, 'search_container') and self.search_container:
            self.search_container.set_visible(False)
        self._restore_sidebar_after_search()
        # Return focus to connection list
        if hasattr(self, 'connection_list') and self.connection_list:
            self.connection_list.grab_focus()

    def _on_search_entry_key_pressed(self, controller, keyval, keycode, state):
        """Handle key presses in search entry."""
        if keyval == Gdk.KEY_Down:
            # Move focus into the connection list and select the first result so
            # the user can navigate matches with the arrow keys. (Arrow Up from
            # the first result returns here — see _on_connection_list_nav_key.)
            if hasattr(self, 'connection_list') and self.connection_list:
                first_row = self.connection_list.get_row_at_index(0)
                if first_row:
                    self._select_only_row(first_row)
                    # Focus the row directly (not the container) so GTK4
                    # ListBox arrow-key navigation works immediately.
                    first_row.grab_focus()
                else:
                    self.connection_list.grab_focus()
            return True
        elif keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
            # Enter connects to the first matching host. Picking a different
            # match is done with the arrow keys.
            row = self._first_connection_row()
            if row is not None:
                self._select_only_row(row)
                self._return_to_tab_view_if_welcome()
                self._cycle_connection_tabs_or_open(row.connection)
            return True
        return False

    def setup_signals(self):
        """Connect to manager signals"""
        # Connection manager signals - use connect_after to avoid conflict with GObject.connect
        self.connection_manager.connect_after('connection-added', self.on_connection_added)
        self.connection_manager.connect_after('connection-removed', self.on_connection_removed)
        self.connection_manager.connect_after('projection-reset', self.on_projection_reset)
        self.connection_manager.connect_after('connection-status-changed', self.on_connection_status_changed)

        # Config signals
        self.config.connect('setting-changed', self.on_setting_changed)


    # --- Fullscreen: the window owns the state; everything else delegates ---

    def _update_fullscreen_button_tooltip(self) -> None:
        """Label the button with whatever accelerator is configured.

        Mirrors the sidebar toggle: a hard-coded "F11" would be wrong on macOS
        (Control+Command+F) and wrong for anyone who rebound it.
        """
        button = getattr(self, 'fullscreen_button', None)
        if button is None:
            return
        try:
            accels = self._get_safe_current_shortcuts().get(
                TOGGLE_FULLSCREEN_ACTION
            ) or []
            labels = ', '.join(
                label for label in (_accelerator_label(a) for a in accels) if label
            )
            button.set_tooltip_text(
                _('Exit Fullscreen ({accels})').format(accels=labels)
                if labels else _('Exit Fullscreen')
            )
        except Exception:
            logger.debug('Failed to build fullscreen button tooltip', exc_info=True)

    def toggle_fullscreen(self) -> None:
        """Toggle window fullscreen (F11). Works with or without terminals."""
        controller = getattr(self, 'fullscreen_controller', None)
        if controller is not None:
            controller.toggle()

    def is_fullscreen_active(self) -> bool:
        controller = getattr(self, 'fullscreen_controller', None)
        return bool(controller is not None and controller.active)

    def _is_start_tab_page(self, page) -> bool:
        return page is not None and page is getattr(self, '_start_tab_page', None)

    def has_user_tabs(self) -> bool:
        try:
            return self.tab_view.get_n_pages() > 1
        except Exception:
            return False

    def is_start_tab_selected(self) -> bool:
        try:
            page = self.tab_view.get_selected_page()
            return self._is_start_tab_page(page)
        except Exception:
            return False

    def _pin_start_tab_page(self) -> None:
        """Pin the Start tab so the tab bar hides its close button."""
        page = getattr(self, '_start_tab_page', None)
        if page is None:
            return
        try:
            self.tab_view.set_page_pinned(page, True)
        except Exception:
            try:
                page.set_pinned(True)
            except Exception:
                logger.debug("Could not pin Start tab", exc_info=True)

    def _create_start_tab(self) -> None:
        """Add the pinned Start tab (WelcomePage) if it is not already present."""
        if getattr(self, '_start_tab_page', None) is not None:
            try:
                if self._start_tab_page in list(self.tab_view.get_pages()):
                    self._pin_start_tab_page()
                    return
            except Exception:
                pass

        self.welcome_view = WelcomePage(self)
        self._start_tab_page = self.tab_view.prepend(self.welcome_view)
        self._start_tab_page.set_title(_('Start'))
        try:
            from sshpilot import icon_utils
            self._start_tab_page.set_icon(
                icon_utils.new_gicon_from_icon_name('go-home-symbolic')
            )
        except Exception:
            pass
        self._pin_start_tab_page()
        self.tab_view.set_selected_page(self._start_tab_page)
        self._update_content_theme_for_selected_tab()

    def _update_content_theme_for_selected_tab(self) -> None:
        """Use terminal background only when a non-Start tab is selected."""
        if not hasattr(self, 'tab_view'):
            return
        try:
            if self.is_start_tab_selected():
                self.tab_view.remove_css_class('terminal-bg')
            else:
                self.tab_view.add_css_class('terminal-bg')
        except Exception:
            logger.debug("Failed to update tab content theme", exc_info=True)

    def show_start_tab(self) -> None:
        """Select the pinned Start tab."""
        self._create_start_tab()
        try:
            self.tab_view.set_selected_page(self._start_tab_page)
        except Exception:
            pass
        self._update_content_theme_for_selected_tab()
        self._schedule_start_tab_focus()

        try:
            if (self.config.get_setting('ui.sidebar_show_when_no_tabs', False)
                    and not self.has_user_tabs()):
                self._apply_sidebar_visible(True)
        except Exception:
            logger.debug("sidebar show-when-no-tabs failed", exc_info=True)

        logger.info("Showing Start tab")

    def show_welcome_view(self):
        """Select the pinned Start tab (legacy name)."""
        self.show_start_tab()

    def show_tab_view(self):
        """Select the first user tab, or keep the current selection."""
        if self.has_user_tabs() and self.is_start_tab_selected():
            try:
                for page in self.tab_view.get_pages():
                    if not self._is_start_tab_page(page):
                        self.tab_view.set_selected_page(page)
                        break
            except Exception:
                pass
        self._update_content_theme_for_selected_tab()
        self._update_layout_toggle_state()
        logger.info("Showing tab view")

    def _connection_list_is_focusable(self) -> bool:
        """True when the connection list exists and is a usable focus target.

        The omni-search popup takes precedence over the sidebar while it is up,
        so every focus path here bails out for it.
        """
        omni = getattr(self, '_omni_search', None)
        if omni is not None and omni.popup.visible:
            return False
        connection_list = getattr(self, 'connection_list', None)
        if connection_list is None:
            return False
        return bool(connection_list.get_parent())

    def _first_selected_connection_list_row(self) -> Optional[Gtk.ListBoxRow]:
        """Return the first selected row of any kind, or None when nothing is selected."""
        connection_list = getattr(self, 'connection_list', None)
        if connection_list is None:
            return None
        try:
            selected_rows = list(connection_list.get_selected_rows())
        except Exception:
            selected_row = connection_list.get_selected_row()
            selected_rows = [selected_row] if selected_row else []
        return selected_rows[0] if selected_rows else None

    def _startup_first_row_focus_allowed(self) -> bool:
        """True only while first-row focusing is still a startup behavior.

        Startup is over once ``_on_startup_complete`` has fired *and* the
        connection store has populated the sidebar at least once. Both halves
        matter: the daemon attaches asynchronously, so the initial store
        projection legitimately lands after the 500 ms timer, and that
        projection is the real "focus the first row on startup" moment (see
        ``on_projection_reset``). After that, moving focus to row 0 would drag
        the sidebar back to the top behind the user's back (issue #1175).
        """
        if not getattr(self, '_startup_complete', False):
            return True
        return not getattr(self, '_initial_connection_list_focus_done', True)

    def _focus_connection_list_first_row(self):
        """Focus the first connection row so arrow-key navigation works immediately.

        Startup-only. Runtime transitions back to the Start tab go through
        ``_focus_start_tab_sidebar`` instead, which never touches row 0.
        """
        try:
            if not self._startup_first_row_focus_allowed():
                return False
            if not self._connection_list_is_focusable():
                return False

            first_row = self.connection_list.get_row_at_index(0)

            # During startup: auto-select first row if nothing is selected yet.
            if not getattr(self, '_startup_complete', False):
                if self._first_selected_connection_list_row() is None and first_row:
                    self._select_only_row(first_row)

            # Focus the first row directly — not just the ListBox container.
            # GTK4 ListBox arrow-key navigation only works when a *row* has
            # focus; grab_focus() on the container leaves no row focused.
            if first_row:
                first_row.grab_focus()
            else:
                self.connection_list.grab_focus()
        except Exception as e:
            logger.debug(f"Focus connection list failed: {e}")
        return False

    def _schedule_start_tab_focus(self) -> None:
        """Queue the single Start-tab focus pass, coalescing duplicate requests.

        Selecting the Start tab reaches here twice — once from
        ``show_start_tab`` and once from the ``notify::selected-page`` handler
        — so collapse both into one idle callback.
        """
        if getattr(self, '_start_tab_focus_idle_id', None):
            return
        try:
            self._start_tab_focus_idle_id = GLib.idle_add(self._focus_start_tab_sidebar)
        except Exception:
            logger.debug("Failed to schedule Start tab focus", exc_info=True)

    def _focus_start_tab_sidebar(self):
        """Sole owner of sidebar focus when the Start tab becomes current.

        On startup this is the first-row focus that makes arrow keys live
        immediately. Afterwards — typically returning to Start because the last
        session tab closed, which destroys the widget that held focus — it hands
        the keyboard back to the row the user already has selected. It never
        falls back to row 0, so the sidebar keeps its scroll position (#1175).
        """
        self._start_tab_focus_idle_id = None
        # This runs at PRIORITY_DEFAULT_IDLE, below GTK's event dispatch, so the
        # user can leave Start again before it fires. Re-check the selection or
        # a stale pass would pull focus out of the tab they switched to.
        if not self.is_start_tab_selected():
            return False
        if self._startup_first_row_focus_allowed():
            return self._focus_connection_list_first_row()
        try:
            if not self._connection_list_is_focusable():
                return False
            # Focus already lives in the sidebar (e.g. the user switched tabs
            # from the row list): leave it exactly where it is.
            if self._focus_is_in_connection_list():
                return False
            selected_row = self._first_selected_connection_list_row()
            if selected_row is not None:
                selected_row.grab_focus()
        except Exception as e:
            logger.debug(f"Start tab sidebar focus failed: {e}")
        return False

    def focus_connection_list(self):
        """Focus the connection list and show a toast notification."""
        try:
            if hasattr(self, 'connection_list') and self.connection_list:
                # If sidebar is hidden, show it first
                if hasattr(self, 'sidebar_toggle_button') and self.sidebar_toggle_button:
                    if self.sidebar_toggle_button.get_active():
                        self.sidebar_toggle_button.set_active(False)

                # Close the search bar (if open) and clear the filter so the
                # full connection list is shown when focus moves here.
                if (
                    getattr(self, 'search_container', None)
                    and self.search_container.get_visible()
                ):
                    if getattr(self, 'search_entry', None):
                        self.search_entry.set_text('')
                    self.rebuild_connection_list()
                    self.search_container.set_visible(False)
                    self._restore_sidebar_after_search()

                # Ensure a row is selected before focusing
                target_row = self._first_selected_connection_list_row()
                logger.debug(f"Focus connection list - current selection: {target_row}")
                if target_row is None:
                    # Select the first row regardless of type
                    target_row = self.connection_list.get_row_at_index(0)
                    logger.debug(f"Focus connection list - first row: {target_row}")
                    if target_row:
                        self._select_only_row(target_row)
                        logger.debug(f"Focus connection list - selected first row: {target_row}")

                # Focus the row directly (not the ListBox container): GTK4
                # arrow-key navigation only works when a row holds focus, and
                # after a rebuild the container won't delegate focus to a row.
                if target_row is not None:
                    target_row.grab_focus()
                else:
                    self.connection_list.grab_focus()


                # Show toast notification
                toast = Adw.Toast.new(
                    _("Switched to connection list — ↑/↓ navigate, Enter open, {modifier}+Enter new tab").format(
                        modifier=get_primary_modifier_label())
                )
                toast.set_timeout(3)  # seconds
                if hasattr(self, 'toast_overlay'):
                    self.toast_overlay.add_toast(toast)
        except Exception as e:
            logger.error(f"Error focusing connection list: {e}")

    def _expand_sidebar_for_search(self, sidebar_hidden: bool = False):
        """Give search room when the sidebar is unavailable as a full pane.

        Detaches the sidebar into the floating popup (looks like the expanded
        sidebar, floats over the content) rather than expanding the split view —
        so the terminal never resizes. This applies both to minimal mode and a
        sidebar manually hidden with F9. Remembered so it re-attaches on close.
        """
        self._search_expanded_sidebar = (
            getattr(self, '_sidebar_minimal', False) or sidebar_hidden
        )
        if self._search_expanded_sidebar:
            self._search_popup.show()

    def _restore_sidebar_after_search(self):
        """Re-attach the strip if opening search is what detached it."""
        if getattr(self, '_search_expanded_sidebar', False):
            self._search_expanded_sidebar = False
            self._search_popup.hide()

    def _close_search_if_open(self):
        """Dismiss the search bar (clear filter, rebuild, restore the sidebar).

        A no-op when search isn't showing, so it can be called from every
        connection-open path to make executing a result stop the search.
        """
        if not (getattr(self, 'search_container', None) and self.search_container.get_visible()):
            return
        if getattr(self, 'search_entry', None):
            self.search_entry.set_text('')
        self.rebuild_connection_list()
        self.search_container.set_visible(False)
        self._restore_sidebar_after_search()

    def activate_search_entry(self):
        """Show (if hidden) and focus the connection search entry.

        Bound to the Ctrl/Cmd+F shortcut. It only ever turns search *on* and
        focuses it so the user can always press the shortcut and start typing.
        Hiding the search bar is done exclusively via the toolbar search button
        (``focus_search_entry``)."""
        try:
            if not (hasattr(self, 'search_entry') and self.search_entry):
                return

            sidebar_hidden = bool(
                getattr(self, 'sidebar_toggle_button', None)
                and self.sidebar_toggle_button.get_active()
            )

            was_visible = True
            if hasattr(self, 'search_container') and self.search_container:
                was_visible = self.search_container.get_visible()
                if not was_visible or sidebar_hidden:
                    self._expand_sidebar_for_search(sidebar_hidden)
                    self.search_container.set_visible(True)

            # Always focus and select any existing text so typing replaces it
            self.search_entry.grab_focus()
            text = self.search_entry.get_text()
            if text:
                self.search_entry.select_region(0, len(text))

            # Only show the hint toast when search was just revealed
            if not was_visible:
                toast = Adw.Toast.new(
                    _("Search mode — Type to filter connections, Esc to clear and hide")
                )
                toast.set_timeout(3)  # seconds
                if hasattr(self, 'toast_overlay'):
                    self.toast_overlay.add_toast(toast)
        except Exception as e:
            logger.error(f"Failed to activate search entry: {e}")

    def activate_omni_search(self):
        """Show and focus the global omni-search."""
        omni = getattr(self, '_omni_search', None)
        if omni is not None:
            omni.show()

    def _setup_omnisearch_shortcut(self) -> None:
        """Install the window-level double-Shift gesture detector."""
        self._omnisearch_double_shift = DoubleShiftDetector()
        controller = Gtk.EventControllerKey()
        controller.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
        controller.connect('key-pressed', self._on_omnisearch_key_pressed)
        controller.connect('key-released', self._on_omnisearch_key_released)
        self.add_controller(controller)
        self._omnisearch_key_controller = controller

    def _omnisearch_uses_double_shift(self) -> bool:
        app = self.get_application()
        if app is None or not getattr(app, 'accelerators_enabled', True):
            return False
        try:
            shortcuts = app.get_effective_shortcuts('omnisearch') or []
        except Exception:
            return False
        return DOUBLE_SHIFT_SHORTCUT in shortcuts

    @staticmethod
    def _is_shift_key(keyval: int) -> bool:
        return keyval in (Gdk.KEY_Shift_L, Gdk.KEY_Shift_R)

    @staticmethod
    def _shortcut_event_time() -> float:
        return GLib.get_monotonic_time() / 1_000_000

    def _on_omnisearch_key_pressed(
        self, _controller, keyval, _keycode, _state
    ) -> bool:
        detector = self._omnisearch_double_shift
        if not self._omnisearch_uses_double_shift():
            detector.reset()
            return False
        detector.key_pressed(
            self._is_shift_key(keyval), self._shortcut_event_time()
        )
        # Shift itself must continue to the focused widget, especially VTE.
        return False

    def _on_omnisearch_key_released(
        self, _controller, keyval, _keycode, _state
    ) -> None:
        detector = self._omnisearch_double_shift
        if not self._omnisearch_uses_double_shift():
            detector.reset()
            return
        if detector.key_released(
            self._is_shift_key(keyval), self._shortcut_event_time()
        ):
            self.activate_omni_search()

    def open_omni_transfer_intent(self, intent, connection=None):
        """Route command-like transfer searches to their existing GUI flows."""
        if intent == 'sftp':
            self._open_builtin_file_manager(connection)
            return
        if intent == 'ssh-copy-id':
            if connection is None:
                self.show_connection_selection_for_ssh_copy()
            elif self.key_manager is None:
                self._show_key_service_unavailable()
            else:
                from .sshcopyid_window import SshCopyIdWindow
                SshCopyIdWindow(
                    self, connection, self.key_manager, self.connection_manager,
                )
            return
        if intent != 'scp':
            return
        if connection is None and not self.connection_manager.get_connections():
            self.get_application().activate_action('new-connection')
            return
        # The transfer chooser has its own built-in server picker, so it
        # handles a missing connection itself — no separate host selector.
        self.scp_controller.open_for_connection(connection)

    def focus_search_entry(self):
        """Toggle search on/off and show appropriate toast notification."""
        try:
            if hasattr(self, 'search_entry') and self.search_entry:
                sidebar_hidden = bool(
                    getattr(self, 'sidebar_toggle_button', None)
                    and self.sidebar_toggle_button.get_active()
                )

                # Toggle search container visibility
                if hasattr(self, 'search_container') and self.search_container:
                    is_visible = self.search_container.get_visible()
                    if not is_visible:
                        self._expand_sidebar_for_search(sidebar_hidden)
                    self.search_container.set_visible(not is_visible)

                    if not is_visible:
                        # Search was hidden, now showing it
                        # Focus the search entry
                        self.search_entry.grab_focus()

                        # Select all text if there's any
                        text = self.search_entry.get_text()
                        if text:
                            self.search_entry.select_region(0, len(text))

                        # Show toast notification
                        toast = Adw.Toast.new(
                            _("Search mode — Type to filter connections, Esc to clear and hide")
                        )
                        toast.set_timeout(3)  # seconds
                        if hasattr(self, 'toast_overlay'):
                            self.toast_overlay.add_toast(toast)
                    else:
                        # Search was visible, now hiding it
                        # Clear search text
                        self.search_entry.set_text('')
                        self.rebuild_connection_list()
                        self._restore_sidebar_after_search()

                        # Return focus to connection list
                        if hasattr(self, 'connection_list') and self.connection_list:
                            self.connection_list.grab_focus()

                        # Show toast notification
                        toast = Adw.Toast.new(
                            _("Search hidden — {modifier}+F to search again").format(
                                modifier=get_primary_modifier_label())
                        )
                        toast.set_timeout(2)  # seconds
                        if hasattr(self, 'toast_overlay'):
                            self.toast_overlay.add_toast(toast)
        except Exception as e:
            logger.error(f"Failed to toggle search entry: {e}")

    def _return_to_tab_view_if_welcome(self):
        """Switch to a user tab when an action fires while Start is selected."""
        try:
            if not self.is_start_tab_selected():
                return
            if not self.has_user_tabs():
                return
            logger.debug("Leaving Start tab due to user interaction")
            self.show_tab_view()
        except Exception as exc:
            logger.debug(f"Failed to return to tab view: {exc}")

    def show_connection_dialog(
            self,
            connection: Connection = None,
            *,
            skip_group_warning: bool = False,
            force_split_from_group: bool = False,
            split_group_source: Optional[str] = None,
            split_original_nickname: Optional[str] = None,
            as_new: bool = False,
    ):
        """Show connection dialog for adding/editing connections"""
        logger.info(f"Show connection dialog for: {connection}")

        # Refresh connection from disk to ensure latest auth method
        if connection is not None and not as_new:
            try:
                self.connection_manager.refresh()
                refreshed = self.connection_manager.find_connection_by_nickname(connection.nickname)
                if refreshed:
                    connection = refreshed
            except Exception:
                pass

        if connection is not None and not as_new and not skip_group_warning:
            block_info = None
            inspect_host_block = getattr(
                self.connection_manager, 'get_host_block_details', None
            )
            if callable(inspect_host_block):
                try:
                    source_path = split_group_source or getattr(connection, 'source', None)
                    block_info = inspect_host_block(connection.nickname, source_path)
                except Exception as e:
                    logger.debug(f"Failed to inspect host block for {connection.nickname}: {e}")
            if block_info and len(block_info.get('hosts') or []) > 1:
                self._prompt_group_edit_options(connection, block_info)
                return

        split_source_for_dialog = split_group_source or (getattr(connection, 'source', None) if connection else None)
        original_token = split_original_nickname or (connection.nickname if connection else None)

        # Create connection dialog (imported lazily to keep it off startup path)
        from .connection_dialog import ConnectionDialog
        dialog = ConnectionDialog(
            self,
            connection,
            self.connection_manager,
            force_split_from_group=force_split_from_group,
            split_group_source=split_source_for_dialog,
            split_original_nickname=original_token,
            as_new=as_new,
        )
        dialog.connect('connection-saved', self.on_connection_saved)

        # The authoritative daemon editor snapshot gates existing SSH edits.  A
        # blank/new connection (``connection is None`` or ``as_new``) needs no
        # generation and must never enter the load-failed state; an existing
        # edit is populated (and Save enabled) only once the daemon DTO
        # arrives.
        if connection is None or as_new:
            dialog._daemon_generation = 0
            dialog.set_editor_source('local')
        elif getattr(connection, 'protocol', 'ssh') == 'ssh':
            dialog.set_editor_source('daemon')
            dialog.set_daemon_editor_loading()
            if self._daemon_ready():
                self._fetch_daemon_editor_generation(dialog, connection)
            else:
                dialog.set_daemon_editor_load_failed()
                try:
                    dialog.show_daemon_editor_load_error(
                        on_retry=lambda: self._retry_daemon_editor_load(dialog, connection)
                    )
                except Exception:
                    pass
        else:
            dialog.set_editor_source('local')

        dialog.present()

    def _fetch_daemon_editor_generation(self, dialog, connection):
        """Load the authoritative daemon editor snapshot (non-blocking).

        Fetches ``ConnectionEditorDetails`` through the GTK client bridge so
        the form is populated from, and ``expected_generation`` derives from,
        the same daemon response.  Transport churn is retried with a short
        delay — every retry re-resolves ``self.client`` and
        ``self.client_bridge`` at call time, so a reconnected client is used
        once the daemon is back.

        The chain has explicit ownership: exactly one of success, terminal
        failure, or dialog cancellation terminates it; any late callback is
        ignored.  Save stays gated on ``dialog._daemon_editor_loaded``: a
        terminal failure disables Save rather than silently degrading
        stale-editor protection, and pending is never treated as a real
        generation (``_daemon_generation`` stays None until loaded).
        """
        from .api import ErrorCode, SshPilotError
        from .api.models.common import ConnectionId

        # Defensive entry guard: this loader only exists for active daemon
        # edits.  A missing connection is a hard failure (Save stays off);
        # leaving daemon mode mid-flight is left to the caller's gating.
        if connection is None:
            dialog.set_daemon_editor_load_failed()
            return
        if not self._daemon_ready():
            return

        class _TransportUnavailable(Exception):
            pass

        state: Dict[str, Any] = {
            'finished': False,
            'closed': False,
            'request': None,
            'timeout_source': None,
            'remaining_retries': 3,
        }

        def _finished():
            return state['finished']

        def _clear_timer():
            source = state['timeout_source']
            if source is not None:
                try:
                    GLib.source_remove(source)
                except Exception:
                    pass
                state['timeout_source'] = None

        def _on_success(editor):
            if _finished():
                return
            state['finished'] = True
            state['request'] = None
            _clear_timer()
            try:
                dialog.populate_from_editor_details(editor)
            except Exception as error:
                # A populate failure must never wedge Save or smuggle a zero
                # generation into the stale-editor check.  Report it so the
                # empty form is not left unexplained.
                logger.warning(
                    "Failed to populate editor from daemon snapshot type=%s",
                    type(error).__name__,
                )
                dialog.set_daemon_editor_load_failed()
                try:
                    dialog.show_daemon_editor_load_error(on_retry=None)
                except Exception:
                    pass

        def _handle_error(error):
            if _finished():
                return
            state['request'] = None
            retryable = isinstance(error, _TransportUnavailable) or (
                isinstance(error, SshPilotError)
                and (
                    error.retryable
                    or error.code
                    in (ErrorCode.TRANSPORT_CLOSED, ErrorCode.TRANSPORT_TIMEOUT)
                )
            )
            if retryable and state['remaining_retries'] > 0:
                state['remaining_retries'] -= 1
                state['timeout_source'] = GLib.timeout_add(500, _schedule_retry)
                return
            state['finished'] = True
            _clear_timer()
            logger.debug("Could not fetch daemon editor snapshot: %s", error)
            dialog.set_daemon_editor_load_failed()
            try:
                dialog.show_daemon_editor_load_error(
                    on_retry=lambda: self._retry_daemon_editor_load(dialog, connection)
                )
            except Exception:
                pass

        def _schedule_retry():
            state['timeout_source'] = None
            _submit_fetch()
            return False

        def _submit_fetch():
            if _finished():
                return
            # Re-resolve the live client and bridge on every attempt so a
            # reconnect between retries is picked up immediately.
            bridge = self.client_bridge
            client = self.client
            if bridge is None or client is None:
                _handle_error(_TransportUnavailable('daemon transport unavailable'))
                return
            try:
                state['request'] = bridge.submit(
                    lambda: client.get_connection_editor(
                        ConnectionId(connection.nickname)
                    ),
                    on_success=_on_success,
                    on_error=_handle_error,
                )
            except RuntimeError as exc:
                # A bridge that raises on submit() is mid-shutdown/replacement —
                # the transport-churn case this retry loop exists for.  Classify
                # it as retryable so the replacement bridge is picked up on the
                # next attempt; the message is logged, never shown to the user.
                state['request'] = None
                _handle_error(_TransportUnavailable(str(exc)))

        def _cancel(_dialog=None):
            if state['finished']:
                return
            state['closed'] = True
            state['finished'] = True
            _clear_timer()
            request = state['request']
            state['request'] = None
            if request is not None:
                try:
                    cancel = getattr(request, 'cancel', None)
                    if callable(cancel):
                        cancel()
                except Exception:
                    pass

        # Hook the real close lifecycle.  Adw.Dialog exposes `closed`; our
        # dialog is an Adw.Window, so fall back to `destroy`.  Connecting an
        # unknown signal raises under real GTK, hence the defensive wiring.
        connect = getattr(dialog, 'connect', None)
        if callable(connect):
            for signal_name in ('closed', 'destroy'):
                try:
                    connect(signal_name, _cancel)
                except Exception:
                    pass

        _submit_fetch()

    def _retry_daemon_editor_load(self, dialog, connection):
        """Re-attempt a failed daemon editor snapshot load (Retry action).

        Resets the dialog to the loading state and starts a fresh fetch; the
        new chain re-resolves the current client and bridge.
        """
        dialog.set_daemon_editor_loading()
        self._fetch_daemon_editor_generation(dialog, connection)

    def open_cli_connect(self, ssh_tokens):
        """Resolve CLI tokens and open a tab, or refuse without starting SSH.

        Prefer :meth:`open_cli_connect_resolved` when the caller already
        resolved (so failure can exit before any connection is started).
        """
        from .cli_connect import describe_cli_error, resolve_cli_connect

        try:
            resolved = resolve_cli_connect(ssh_tokens, self.connection_manager)
        except ValueError as exc:
            message = describe_cli_error(exc)
            print(f'sshpilot: {message}', file=sys.stderr)
            logger.warning('CLI connect refused: %s', message)
            return False
        return self.open_cli_connect_resolved(resolved)

    def open_cli_connect_resolved(self, resolved, sftp=False):
        """Open an already-resolved CLI target: file manager if *sftp*, else a tab."""
        if sftp:
            self._open_manage_files_for_connection(resolved.connection)
            return True
        self.terminal_manager.connect_to_host(
            resolved.connection, force_new=True)
        return True

    def _prompt_group_edit_options(self, connection: Connection, block_info: Dict[str, Any]):
        """Present options when editing a grouped host"""
        try:
            host_label = getattr(connection, 'nickname', '')
            other_hosts = max(0, len(block_info.get('hosts') or []) - 1)
            message = _("\"{host}\" is part of a configuration block with [{count}] other hosts. How would you like to apply your changes?").format(host=host_label, count=other_hosts)

            dialog = Adw.MessageDialog.new(self, _("Warning"), message)
            dialog.add_response('cancel', _('Cancel'))
            dialog.add_response('manual', _('Manually Edit SSH Configuration'))
            dialog.add_response('split', _('Edit as Separate Connection'))
            dialog.set_response_appearance('manual', Adw.ResponseAppearance.SUGGESTED)
            dialog.set_default_response('manual')
            dialog.set_close_response('cancel')

            source_path = block_info.get('source') or getattr(connection, 'source', None)
            original_name = getattr(connection, 'nickname', None)

            def on_response(dlg, response):
                dlg.destroy()
                if response == 'manual':
                    self._open_ssh_config_editor()
                elif response == 'split':
                    self.show_connection_dialog(
                        connection,
                        skip_group_warning=True,
                        force_split_from_group=True,
                        split_group_source=source_path,
                        split_original_nickname=original_name,
                    )

            dialog.connect('response', on_response)
            dialog.present()
        except Exception as e:
            logger.error(f"Failed to present group edit options: {e}")
            self.show_connection_dialog(connection, skip_group_warning=True)

    def _open_ssh_config_editor(self):
        """Open the SSH config text editor backed by the daemon.

        GTK never decides which SSH config file is active and never writes it:
        the daemon resolves the root (normal or isolated mode), serves the
        text, performs the hardened atomic write, and reloads connection
        state. This reuses the existing raw text editor UI unchanged.
        """
        try:
            from .text_editor import RemoteFileEditorWindow
            from .ssh_config_editor_service import DaemonSshConfigTextService

            client = getattr(self, 'client', None)
            try:
                supports_config = client is not None and client.get_capabilities().supports(
                    ApiCapability.CONNECTIONS_CONFIG_READ
                )
            except Exception:
                supports_config = False
            if not supports_config:
                self.show_toast(_(
                    "SSH config editing requires the background service. "
                    "Start it and try again."
                ))
                return

            service = DaemonSshConfigTextService(client)
            editor = RemoteFileEditorWindow(
                parent=self,
                file_path="",  # the daemon resolves the active config file
                file_name=_("SSH Config"),
                is_local=False,
                daemon_file_service=service,
                file_manager_window=None,
                language_id="sshconfig",
                show_outline=True,
                on_saved=self._on_ssh_config_editor_saved,
            )

            # Window/taskbar title; the header title shows the daemon-resolved
            # display name as the subtitle automatically.
            editor.set_title(_("Edit SSH Config"))
            if hasattr(editor, 'set_editor_title'):
                editor.set_editor_title(_("SSH Config"))

            editor.present()
        except Exception as e:
            logger.error(f"Failed to open SSH config editor: {e}")
            self.show_toast(_("Could not open the SSH config editor."))

    def _on_ssh_config_editor_saved(self):
        """SSH config editor wrote the config: refresh effective-config warnings.

        A global-scope edit (e.g. removing a ``Host *`` block) can change every
        connection's effective config, so drop ALL cached checker results and
        recompute — fresh results reach sidebar rows via the checker callback.
        """
        checker = getattr(self, 'effective_config_checker', None)
        if checker is None:
            return
        try:
            checker.invalidate()
            for connection in self.connection_manager.get_connections():
                checker.schedule(connection)
        except Exception:
            logger.debug("effective-config refresh after config save failed",
                         exc_info=True)

    def show_connection_selection_for_ssh_copy(self):
        """Open the ssh-copy-id dialog with no server preselected; its
        embedded server picker handles the choice."""
        if not self.connection_manager.get_connections():
            # No connections available, show new connection dialog instead
            logger.info("No connections available, showing new connection dialog")
            self.show_connection_dialog()
            return
        if self.key_manager is None:
            self._show_key_service_unavailable()
            return
        try:
            from .sshcopyid_window import SshCopyIdWindow
            SshCopyIdWindow(self, None, self.key_manager, self.connection_manager)
        except Exception as e:
            logger.error(f"Failed to show SSH key copy dialog: {e}")

    # --- Helpers (use your existing ones if already present) ---------------------

    def _error_dialog(self, heading: str, body: str, detail: str = ""):
        try:
            msg = Adw.MessageDialog(transient_for=self, modal=True,
                                    heading=heading, body=(body + (f"\n\n{detail}" if detail else "")))
            msg.add_response("ok", _("OK"))
            msg.set_default_response("ok")
            msg.set_close_response("ok")
            msg.present()
        except Exception:
            pass

    def _show_daemon_unavailable_dialog(self, detail=None) -> None:
        detail_text = getattr(detail, "message", None) if detail is not None else None
        if not detail_text and detail is not None:
            detail_text = str(detail)
        self._error_dialog(
            _("Daemon unavailable"),
            _("Connect to the sshPilot daemon and retry this operation."),
            detail=detail_text or "",
        )

    def _show_operation_mode_rejection(self, detail=None) -> None:
        """Explain a daemon-confirmed rejection without calling it unavailable."""
        detail_text = getattr(detail, "message", None) if detail is not None else None
        if not detail_text and detail is not None:
            detail_text = str(detail)
        self._error_dialog(
            _("Operation mode change rejected"),
            _("SSH Pilot could not change the active SSH configuration scope."),
            detail=detail_text or "The daemon kept the confirmed active mode.",
        )

    def _show_operation_mode_recovery(self, detail=None) -> None:
        """Explain a mode transaction that needs restart/manual recovery."""
        detail_text = getattr(detail, "message", None) if detail is not None else None
        if not detail_text and detail is not None:
            detail_text = str(detail)
        self._error_dialog(
            _("Operation mode recovery required"),
            _(
                "The daemon could not restore a consistent SSH configuration mode. "
                "Do not start new operations until the daemon is restarted or the "
                "reported state is repaired."
            ),
            detail=detail_text or "",
        )

    def _info_dialog(self, heading: str, body: str):
        try:
            msg = Adw.MessageDialog(transient_for=self, modal=True,
                                    heading=heading, body=body)
            msg.add_response("ok", _("OK"))
            msg.set_default_response("ok")
            msg.set_close_response("ok")
            msg.present()
        except Exception:
            pass


    # --- Single, simplified key generator (no copy-to-server inside) ------------

    def _show_key_service_unavailable(self) -> None:
        """Recovery message when the daemon (and therefore keys) is unavailable."""
        try:
            dialog = Adw.AlertDialog.new(
                _("Key Service Unavailable"),
                _(
                    "SSH keys require the background service. "
                    "Restart the service and try again."
                ),
            )
            dialog.add_response("ok", _("OK"))
            dialog.set_default_response("ok")
            dialog.set_close_response("ok")
            dialog.present(self)
        except Exception:
            logger.warning("Unable to show key service unavailable dialog", exc_info=True)

    def show_key_dialog(self, on_success=None):
        """
        Single key generation dialog (Adw). Optional passphrase.
        No copy-to-server in this dialog. If provided, `on_success(key)` is called.
        """
        if self.key_manager is None:
            self._show_key_service_unavailable()
            return None
        try:
            dlg = Adw.Dialog.new()
            dlg.set_title(_("Generate SSH Key"))

            tv = Adw.ToolbarView()
            hb = Adw.HeaderBar()
            hb.set_title_widget(Gtk.Label(label=_("New SSH Key")))
            tv.add_top_bar(hb)

            content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
            content.set_margin_top(18); content.set_margin_bottom(18)
            content.set_margin_start(18); content.set_margin_end(18)
            content.set_size_request(500, -1)

            form = Adw.PreferencesGroup()

            name_row = Adw.EntryRow()
            name_row.set_title(_("Key file name"))
            name_row.set_text("id_ed25519")

            # Add real-time validation
            def on_name_changed(entry):
                key_name = (entry.get_text() or "").strip()
                if key_name and not key_name.startswith(".") and "/" not in key_name:
                    entry.remove_css_class("error")
                    entry.set_title(_("Key file name"))
                else:
                    entry.add_css_class("error")
                    entry.set_title(_("Key file name"))

            name_row.connect("changed", on_name_changed)
            form.add(name_row)

            type_row = Adw.ComboRow()
            type_row.set_title(_("Key type"))
            types = Gtk.StringList.new(["ed25519", "rsa"])
            type_row.set_model(types)
            type_row.set_selected(0)
            form.add(type_row)

            pass_switch = Adw.SwitchRow()
            pass_switch.set_title(_("Encrypt with passphrase"))
            pass_switch.set_active(False)
            form.add(pass_switch)

            # Buttons
            btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
            btn_box.set_halign(Gtk.Align.END)
            btn_cancel = Gtk.Button.new_with_label(_("Cancel"))
            btn_primary = Gtk.Button.new_with_label(_("Generate"))
            try:
                btn_primary.add_css_class("suggested-action")
            except Exception:
                pass
            btn_box.append(btn_cancel); btn_box.append(btn_primary)

            # Compose
            content.append(form)
            content.append(btn_box)
            tv.set_content(content)
            dlg.set_child(tv)

            def close_dialog(*args):
                try:
                    dlg.force_close()
                except Exception:
                    pass

            btn_cancel.connect("clicked", close_dialog)

            def do_generate(*args):
                dialogs = None
                try:
                    key_name = (name_row.get_text() or "").strip()
                    if not key_name:
                        raise ValueError(_("Enter a key file name (e.g. id_ed25519)"))
                    if "/" in key_name or key_name.startswith("."):
                        raise ValueError(_("Key file name must not contain '/' or start with '.'"))

                    # Existence check against the daemon inventory — never the filesystem.
                    existing_names = {
                        key.name for key in (self.key_manager.discover_keys() or [])
                    }
                    if key_name in existing_names:
                        base_name = key_name
                        counter = 1
                        while f"{base_name}_{counter}" in existing_names:
                            counter += 1
                        suggestion = f"{base_name}_{counter}"

                        raise ValueError(_("A key named '{name}' already exists. Try '{suggestion}' instead.").format(name=key_name, suggestion=suggestion))

                    kt = "ed25519" if type_row.get_selected() == 0 else "rsa"

                    encrypted = bool(pass_switch.get_active())
                    interaction_scope_id = None
                    if encrypted:
                        from .api.models import SessionId
                        from .daemon_interaction_dialogs import DaemonInteractionDialogs
                        from .runtime_identity import new_operation_id

                        interaction_scope_id = SessionId(
                            f"key-{new_operation_id()}"
                        )
                        dialogs = DaemonInteractionDialogs(
                            self.client,
                            self.client_bridge,
                            self,
                        )
                        dialogs.set_session(interaction_scope_id)

                    btn_primary.set_sensitive(False)

                    def _generate():
                        return self.key_manager.generate_key(
                            key_name=key_name,
                            key_type=kt,
                            key_size=3072 if kt == "rsa" else 0,
                            comment=None,
                            encrypted=encrypted,
                            interaction_scope_id=interaction_scope_id,
                        )

                    def _generated(new_key):
                        if dialogs is not None:
                            dialogs.close()
                        btn_primary.set_sensitive(True)
                        if not new_key:
                            _failed(RuntimeError("Key generation failed"))
                            return
                        logger.info("SSH key generated")
                        if on_success:
                            on_success(new_key)
                        close_dialog()

                    def _failed(error):
                        if dialogs is not None:
                            dialogs.close()
                        btn_primary.set_sensitive(True)
                        logger.error(
                            "Key generation failed type=%s",
                            type(error).__name__,
                        )
                        self._error_dialog(
                            _("Key Generation Failed"),
                            str(error) or _("Could not generate the SSH key."),
                        )

                    self.client_bridge.submit(
                        _generate,
                        on_success=_generated,
                        on_error=_failed,
                    )

                except Exception as e:
                    if dialogs is not None:
                        dialogs.close()
                    logger.error("Key generation failed: %s", type(e).__name__)
                    try:
                        self._error_dialog(
                            _("Key Generation Failed"),
                            str(e) or _("Could not generate the SSH key."),
                        )
                    except Exception:
                        logger.debug("Unable to show key generation error dialog", exc_info=True)

            btn_primary.connect("clicked", do_generate)
            dlg.present()
            return dlg
        except Exception as e:
            logger.error("Failed to present key generator: %s", e)


    # --- Integrate generator into ssh-copy-id chooser ---------------------------

    def on_copy_key_to_server_clicked(self, _button):
        logger.info("Main window: ssh-copy-id button clicked")
        logger.debug("Main window: Starting ssh-copy-id process")

        selected_row = self.connection_list.get_selected_row()
        if not selected_row or not getattr(selected_row, "connection", None):
            logger.warning("Main window: No connection selected for ssh-copy-id")
            # Show message dialog
            try:
                error_dialog = Adw.MessageDialog(
                    transient_for=self,
                    modal=True,
                    heading=_("No Server Selected"),
                    body=_("Select a server first!")
                )
                error_dialog.add_response('ok', _('OK'))
                error_dialog.present()
            except Exception as e:
                logger.error(f"Failed to show error dialog: {e}")
            return
        connection = selected_row.connection
        if PluginCapability.KEY_DEPLOYMENT not in capabilities_for(connection):
            logger.debug("ssh-copy-id unavailable: protocol %r has no key deployment",
                         getattr(connection, 'protocol', 'ssh'))
            return
        if self.key_manager is None:
            self._show_key_service_unavailable()
            return
        logger.info(f"Main window: Selected connection: {getattr(connection, 'nickname', 'unknown')}")
        logger.debug(f"Main window: Connection details - host: {getattr(connection, 'hostname', getattr(connection, 'host', 'unknown'))}, "
                    f"username: {getattr(connection, 'username', 'unknown')}, "
                    f"port: {getattr(connection, 'port', 22)}")

        try:
            logger.info("Main window: Creating SshCopyIdWindow")
            logger.debug("Main window: Initializing SshCopyIdWindow with key_manager and connection_manager")
            from .sshcopyid_window import SshCopyIdWindow
            win = SshCopyIdWindow(self, connection, self.key_manager, self.connection_manager)
            logger.info("Main window: SshCopyIdWindow created successfully, presenting")
            win.present()
        except Exception as e:
            logger.error(f"Main window: ssh-copy-id window failed: {e}")
            logger.debug(f"Main window: Exception details: {type(e).__name__}: {e!s}")
            # Fallback error if window cannot be created
            try:
                md = Adw.MessageDialog(transient_for=self, modal=True,
                                       heading=_("Error"),
                                       body=_("Could not open the Copy Key window.\n\n{error}").format(error=e))
                md.add_response("ok", _("OK"))
                md.present()
            except Exception:
                pass

    def toggle_list_focus(self):
        """Toggle focus between connection list and terminal"""
        # Use the focus-ancestry check (not connection_list.has_focus()): after
        # focusing, a child *row* holds focus, so has_focus() on the ListBox
        # itself is False and the toggle would never return to the terminal.
        if self._focus_is_in_connection_list():
            # Focus the active terminal. Use the split-view-aware lookup so this
            # also returns focus to the active pane of a SplitViewTab (a
            # SplitViewTab owns a child terminal container.
            terminal = self._get_active_terminal_widget()
            if terminal is not None:
                self._focus_terminal_widget(terminal)
        else:
            # Focus connection list with toast notification
            self.focus_connection_list()


    # Signal handlers
    def on_connection_activated(self, list_box, row):
        """Handle connection activation (Enter key)"""
        if row is not None and hasattr(row, 'command_action'):
            # Close the popup first (restores the sidebar), then run the command
            # so the dialog/page it opens isn't fighting the popup for the overlay.
            action, target = row.command_action, row.command_target
            self._dismiss_search_popup()
            Gtk.Widget.activate_action(self, action, target)
            return
        self._return_to_tab_view_if_welcome()
        logger.debug(f"Connection activated - row: {row}, has connection: {hasattr(row, 'connection') if row else False}")
        if row and hasattr(row, 'connection'):
            self._cycle_connection_tabs_or_open(row.connection)
        elif row and hasattr(row, 'group_id'):
            # Handle group row activation - toggle expand/collapse
            logger.debug(f"Group row activated - toggling expand/collapse for group: {row.group_id}")
            row._toggle_expand()



    def on_connection_activate(self, list_box, row):
        """Handle connection activation (Enter key or double-click)"""
        self._return_to_tab_view_if_welcome()
        if row and hasattr(row, 'connection'):
            self._cycle_connection_tabs_or_open(row.connection)
            return True  # Stop event propagation
        return False

    def on_activate_connection(self, action, param):
        """Handle the activate-connection action"""
        self._return_to_tab_view_if_welcome()
        row = self.connection_list.get_selected_row()
        if row and hasattr(row, 'connection'):
            self._cycle_connection_tabs_or_open(row.connection)

    def toggle_terminal_search_overlay(self, select_all: bool = False) -> None:
        """Toggle the search overlay for the currently focused terminal tab."""
        terminal = self._get_active_terminal_widget()
        if terminal is None:
            return

        revealer = getattr(terminal, 'search_revealer', None)
        is_revealed = False
        if revealer is not None:
            try:
                is_revealed = bool(revealer.get_reveal_child())
            except Exception as exc:
                logger.debug('Failed to query terminal search revealer state: %s', exc)

        try:
            if is_revealed and hasattr(terminal, '_hide_search_overlay'):
                terminal._hide_search_overlay()
            elif hasattr(terminal, '_show_search_overlay'):
                terminal._show_search_overlay(select_all=select_all)
        except Exception as exc:
            logger.debug('Failed to toggle terminal search overlay: %s', exc)

    def on_connection_selected(self, list_box, row):
        """Handle connection list selection change"""
        try:
            connection_rows = self._get_selected_connection_rows()
            group_rows = self._get_selected_group_rows()
        except Exception:
            connection_rows = []
            group_rows = []

        has_connections = bool(connection_rows)
        has_groups = bool(group_rows)

        if has_connections and not has_groups:
            self._set_sidebar_selection_toolbar('connection')

            multiple_connections = len(connection_rows) > 1
            selected_conn = getattr(connection_rows[0], 'connection', None)
            caps = capabilities_for(selected_conn) if (not multiple_connections and selected_conn) else frozenset()
            self.edit_button.set_sensitive(not multiple_connections)
            if hasattr(self, 'copy_key_button'):
                self.copy_key_button.set_sensitive(
                    not multiple_connections and PluginCapability.KEY_DEPLOYMENT in caps
                )
            if hasattr(self, 'scp_button'):
                self.scp_button.set_sensitive(
                    not multiple_connections and PluginCapability.FILE_TRANSFER in caps
                )
            self.manage_files_button.set_sensitive(
                not multiple_connections
                and PluginCapability.FILE_TRANSFER in caps
                and not should_hide_file_manager_options()
            )
            self.manage_files_button.set_visible(not should_hide_file_manager_options())
            if hasattr(self, 'system_terminal_button') and self.system_terminal_button:
                # System terminal uses a daemon-prepared launch specification.
                self.system_terminal_button.set_sensitive(
                    not multiple_connections
                    and getattr(selected_conn, 'protocol', 'ssh') == 'ssh'
                )
            self.delete_button.set_sensitive(True)
            self.rename_group_button.set_sensitive(False)
            self.delete_group_button.set_sensitive(False)
        elif has_groups and not has_connections:
            self._set_sidebar_selection_toolbar('group')

            # Rename works for tag groups too (renames the tag); delete does not.
            allow_single_group = len(group_rows) == 1
            allow_group_delete = (
                allow_single_group
                and not getattr(group_rows[0], 'is_tag_group', False)
            )
            self.delete_button.set_sensitive(False)
            if hasattr(self, 'copy_key_button'):
                self.copy_key_button.set_sensitive(False)
            if hasattr(self, 'scp_button'):
                self.scp_button.set_sensitive(False)
            self.manage_files_button.set_sensitive(False)
            self.manage_files_button.set_visible(not should_hide_file_manager_options())
            if hasattr(self, 'system_terminal_button') and self.system_terminal_button:
                self.system_terminal_button.set_sensitive(False)
            self.rename_group_button.set_sensitive(allow_single_group)
            self.delete_group_button.set_sensitive(allow_group_delete)
        else:
            self._set_sidebar_selection_toolbar('empty')
            self.delete_button.set_sensitive(False)
            if hasattr(self, 'copy_key_button'):
                self.copy_key_button.set_sensitive(False)
            if hasattr(self, 'scp_button'):
                self.scp_button.set_sensitive(False)
            self.manage_files_button.set_sensitive(False)
            self.manage_files_button.set_visible(not should_hide_file_manager_options())
            if hasattr(self, 'system_terminal_button') and self.system_terminal_button:
                self.system_terminal_button.set_sensitive(False)
            self.rename_group_button.set_sensitive(False)
            self.delete_group_button.set_sensitive(False)

    def _set_sidebar_selection_toolbar(self, name: str) -> None:
        """Switch toolbar contents without changing the sidebar's natural size."""
        stack = getattr(self, '_sidebar_selection_toolbar', None)
        if stack is not None:
            stack.set_visible_child_name(name)
            return

        # Defensive fallback for partially constructed/test windows.
        self.connection_toolbar.set_visible(name == 'connection')
        self.group_toolbar.set_visible(name == 'group')

    def on_add_connection_clicked(self, button):
        """Handle add connection button click"""
        self.show_connection_dialog()

    def on_edit_connection_clicked(self, button):
        """Handle edit connection button click"""
        selected_row = self.connection_list.get_selected_row()
        if selected_row:
            if hasattr(selected_row, 'connection'):
                self.show_connection_dialog(selected_row.connection)
            else:
                logger.debug("Cannot edit group row")

    def on_sidebar_toggle(self, button):
        """Handle sidebar toggle button click"""
        try:
            # Button active state now represents the action to perform
            # True = hide sidebar, False = show sidebar
            should_hide = button.get_active()
            is_visible = not should_hide
            self._toggle_sidebar_visibility(is_visible)
            # A manual toggle cancels any pending "hide on terminal open" delay.
            self._cancel_pending_sidebar_hide()

            # Update button icon and tooltip based on current sidebar state
            from sshpilot import icon_utils
            if is_visible:
                icon_utils.set_button_icon(button, 'sidebar-show-symbolic')
                button.set_tooltip_text(
                    _('Hide Sidebar (F9, {modifier}+B)').format(
                        modifier=get_primary_modifier_label())
                )
            else:
                icon_utils.set_button_icon(button, 'sidebar-show-symbolic')
                button.set_tooltip_text(
                    _('Show Sidebar (F9, {modifier}+B)').format(
                        modifier=get_primary_modifier_label())
                )

            # No need to save state - sidebar always starts visible

        except Exception as e:
            logger.error(f"Failed to toggle sidebar: {e}")

    # --- Sidebar behavior (Settings ▸ Sidebar ▸ Sidebar behavior) ------------
    def set_sidebar_overlay(self, overlay: bool) -> None:
        """Switch the sidebar between its two reusable presentation modes.

        - ``False`` (default): side-by-side — the sidebar takes its own column
          and the content is laid out beside it.
        - ``True`` (overlay): the sidebar is drawn as an overlay *above* the
          content (covering it) with a dimming scrim; the content keeps the full
          width underneath.

        This is a pure presentation switch — sidebar visibility is untouched, so
        it composes with show/hide and with minimal mode. Only the
        ``AdwOverlaySplitView`` backend supports a true overlay; other split
        variants stay side-by-side.
        """
        overlay = bool(overlay)
        self._sidebar_overlay = overlay
        sv = getattr(self, 'split_view', None)
        if sv is None:
            return
        if getattr(self, '_split_variant', '') == 'overlay' and hasattr(sv, 'set_collapsed'):
            try:
                sv.set_collapsed(overlay)
            except Exception:
                logger.debug("set_sidebar_overlay failed", exc_info=True)

    # --- Search popup owner callbacks (see search_popup.SearchPopup) ---------
    def _popup_target_width(self) -> int:
        """Panel width for the search popup: the *actual* expanded-sidebar width
        (fraction-based, clamped to [base_min, max]), not the raw max."""
        saved_max = _effective_max_sidebar_width(
            self.config.get_setting('ui.max-sidebar-width', None))
        try:
            sv = getattr(self, 'split_view', None)
            if sv is not None and hasattr(sv, 'get_sidebar_width_fraction'):
                base_min = 180 if HAS_OVERLAY_SPLIT else 200
                win_w = sv.get_width() or 0
                if win_w > 0:
                    frac = sv.get_sidebar_width_fraction()
                    return max(base_min, min(saved_max, int(frac * win_w)))
        except Exception:
            pass
        return saved_max

    def _on_search_popup_shown(self) -> None:
        """Detached: show the full sidebar even when the strip is minimal, and
        hide the connection list for search-only modes (spotlight)."""
        self._set_sidebar_clipping(False)
        self._apply_sidebar_minimal_chrome(False)
        self._apply_sidebar_minimal_rows(False)
        if getattr(self, 'connection_scrolled', None):
            self.connection_scrolled.set_visible(not self._search_popup.search_only)

    def _on_search_popup_hidden(self) -> None:
        """Re-attached: restore the list and re-collapse the strip if minimal."""
        if getattr(self, 'connection_scrolled', None):
            self.connection_scrolled.set_visible(True)
        if getattr(self, '_sidebar_minimal', False):
            self._apply_sidebar_minimal_chrome(True)
            self._apply_sidebar_minimal_rows(True)

    def _dismiss_search_popup(self) -> None:
        """Esc / click-outside: route through search teardown when search is
        active (cleans the filter/entry) so the popup also closes."""
        if getattr(self, 'search_container', None) and self.search_container.get_visible():
            self._close_search_if_open()
        else:
            self._search_popup.hide()

    def _sidebar_mode_is_minimal(self) -> bool:
        """True when the icon strip is the user's configured resting mode."""
        try:
            return str(self.config.get_setting('ui.sidebar_mode', 'full')).lower() == 'minimal'
        except Exception:
            return False

    def _apply_sidebar_visible(self, visible: bool) -> None:
        """Programmatically show/hide the sidebar and keep the toggle button in
        sync (used by the behavior hooks)."""
        # Leave a *transient* minimal strip (minimize-on-connect) so the next
        # reveal is the full sidebar; keep it when minimal is the configured
        # resting mode so it survives show/hide.
        if getattr(self, '_sidebar_minimal', False) and not self._sidebar_mode_is_minimal():
            self.set_sidebar_minimal(False, animate=False)
        try:
            self._toggle_sidebar_visibility(visible)
            if hasattr(self, 'sidebar_toggle_button'):
                # Button 'active' means "hidden" (see on_sidebar_toggle).
                self.sidebar_toggle_button.set_active(not visible)
        except Exception:
            logger.debug("apply_sidebar_visible failed", exc_info=True)

    def _cancel_pending_sidebar_hide(self) -> None:
        tid = getattr(self, '_sidebar_hide_timer_id', None)
        if tid:
            try:
                GLib.source_remove(tid)
            except Exception:
                pass
        self._sidebar_hide_timer_id = None

    def _hide_sidebar_after_terminal(self) -> bool:
        """Deferred hide so a freshly-opened terminal settles before the sidebar
        slides away (smoother than hiding instantly)."""
        self._sidebar_hide_timer_id = None
        try:
            self._apply_sidebar_visible(False)
        except Exception:
            logger.debug("hide_sidebar_after_terminal failed", exc_info=True)
        return GLib.SOURCE_REMOVE

    def _sidebar_on_terminal_open(self) -> str:
        """What to do to the sidebar when a session opens: 'none'|'minimize'|'hide'.

        Falls back to the legacy boolean settings for configs saved before the
        options were merged into one selector.
        """
        try:
            value = self.config.get_setting('ui.sidebar_on_terminal_open', None)
        except Exception:
            value = None
        if value in ('none', 'minimize', 'hide'):
            return value
        # Legacy fallback.
        try:
            if self.config.get_setting('ui.sidebar_hide_on_terminal_open', False):
                return 'hide'
            if self.config.get_setting('ui.sidebar_minimize_on_connect', False):
                return 'minimize'
        except Exception:
            pass
        return 'none'

    def _minimize_sidebar_after_terminal(self) -> bool:
        """Deferred collapse to the icon strip once a session settles."""
        self._sidebar_hide_timer_id = None
        try:
            self.set_sidebar_minimal(True)
        except Exception:
            logger.debug("minimize_sidebar_after_terminal failed", exc_info=True)
        return GLib.SOURCE_REMOVE

    def _toggle_sidebar_visibility(self, is_visible):
        """Helper method to toggle sidebar visibility"""
        try:
            logger.debug(f"Toggle sidebar visibility requested: {is_visible}, split variant: {getattr(self, '_split_variant', 'unknown')}")
            if HAS_OVERLAY_SPLIT and getattr(self, '_split_variant', '') == 'overlay':
                # For OverlaySplitView
                self.split_view.set_show_sidebar(is_visible)
                logger.debug(f"Set OverlaySplitView sidebar visibility to: {is_visible}")
            elif HAS_NAV_SPLIT and getattr(self, '_split_variant', '') == 'navigation':
                # NavigationSplitView doesn't have set_show_sidebar method
                # Use collapsed property to hide/show sidebar
                # When collapsed=True and show-content=False, sidebar is visible
                # When collapsed=True and show-content=True, content is visible
                # When collapsed=False, both are visible side by side
                self._sidebar_visible = is_visible
                if is_visible:
                    # Show sidebar: un-collapse or show sidebar
                    try:
                        self.split_view.set_collapsed(False)
                    except Exception:
                        # If un-collapsing fails, try showing sidebar via show-content
                        try:
                            self.split_view.set_show_content(False)
                        except Exception:
                            pass
                else:
                    # Hide sidebar: collapse and show content
                    try:
                        self.split_view.set_collapsed(True)
                        self.split_view.set_show_content(True)
                    except Exception:
                        pass
                logger.debug(f"NavigationSplitView sidebar visibility set to: {is_visible}")
            else:
                # For Gtk.Paned fallback
                sidebar_widget = self.split_view.get_start_child()
                if sidebar_widget:
                    sidebar_widget.set_visible(is_visible)
                    logger.debug(f"Set Gtk.Paned sidebar visibility to: {is_visible}")
        except Exception as e:
            logger.error(f"Failed to toggle sidebar visibility: {e}")


    def on_scp_button_clicked(self, button):
        return self.scp_controller.on_scp_button_clicked(button)

    def on_manage_files_button_clicked(self, button):
        """Handle manage files button click from toolbar"""
        try:
            selected_row = self.connection_list.get_selected_row()
            if not selected_row:
                return
            connection = getattr(selected_row, 'connection', None)
            if not connection:
                return

            self._open_manage_files_for_connection(connection)
        except Exception as e:
            logger.error(f"Manage files button click failed: {e}")

    def on_system_terminal_button_clicked(self, button):
        """Handle system terminal button click from toolbar"""
        try:
            selected_row = self.connection_list.get_selected_row()
            if not selected_row:
                return
            connection = getattr(selected_row, 'connection', None)
            if not connection:
                return

            # Use the same logic as the context menu action
            self.open_in_system_terminal(connection)
        except Exception as e:
            logger.error(f"System terminal button click failed: {e}")

    def _show_ssh_copy_id_terminal_using_main_widget(self, connection, ssh_key, force=False):
        return self.sshcopyid_runner.run(connection, ssh_key, force)

    def on_delete_connection_clicked(self, button):
        """Handle delete connection button click"""
        target_rows = self._get_target_connection_rows()
        if not target_rows:
            logger.debug("Delete requested without any connection selection")
            return

        connections = self._connections_from_rows(target_rows)
        neighbor_row = self._determine_neighbor_connection_row(target_rows)

        self._prompt_delete_connections(connections, neighbor_row)

    def on_rename_group_clicked(self, button):
        """Handle rename group button click"""
        selected_row = self.connection_list.get_selected_row()
        if selected_row and getattr(selected_row, 'is_tag_group', False):
            self.on_rename_tag_action(selected_row)
        elif selected_row and hasattr(selected_row, 'group_id'):
            # Pin the context row to the selection so a stale context-menu
            # row (possibly a tag row) can't divert the action.
            self._context_menu_group_row = selected_row
            self.on_edit_group_action(None, None)

    def on_delete_group_clicked(self, button):
        """Handle delete group button click"""
        selected_row = self.connection_list.get_selected_row()
        if (selected_row and hasattr(selected_row, 'group_id')
                and not getattr(selected_row, 'is_tag_group', False)):
            # Pin the context row to the selection so a stale context-menu
            # row (possibly a tag row) can't divert the action.
            self._context_menu_group_row = selected_row
            self.on_delete_group_action(None, None)

    def on_delete_connection_response(self, dialog, response, payload):
        """Handle delete connection dialog response"""
        try:
            connections: List[Connection]

            if isinstance(payload, dict):
                connections = payload.get('connections', []) or []
            elif isinstance(payload, (list, tuple)):
                connections = list(payload)
            else:
                connections = [payload] if payload else []

            if response not in {'delete', 'close_remove'}:
                return
            self._delete_connections_via_client(
                connections,
                close_terminals=response == 'close_remove',
            )
        except Exception as e:
            self._deleting_connections_batch = False
            logger.error(f"Failed to delete connections: {e}")

    def _delete_connections_via_client(
        self,
        connections,
        *,
        close_terminals=False,
    ) -> None:
        """Delete daemon-owned connections serially without optimistic rows."""

        from .api import SshPilotError
        from .api.models import DeleteConnectionRequest

        bridge = getattr(self, 'client_bridge', None)
        if bridge is None:
            self._deleting_connections_batch = False
            if not self._is_quitting:
                self._error_dialog(
                    _("Daemon unavailable"),
                    _("Connect to the sshPilot daemon and retry the deletion."),
                )
            return
        pending = iter(connection for connection in connections if connection)
        self._deleting_connections_batch = True

        def _finish():
            self._deleting_connections_batch = False
            if self._is_quitting:
                return
            try:
                self.connection_manager.refresh()
                self.rebuild_connection_list()
            except Exception as error:
                logger.warning(
                    "Post-delete connection refresh failed type=%s",
                    type(error).__name__,
                )

        def _delete_next():
            if self._is_quitting:
                self._deleting_connections_batch = False
                return
            try:
                connection = next(pending)
            except StopIteration:
                _finish()
                return
            request = DeleteConnectionRequest(
                connection_id=connection_id_for(connection)
            )

            def _success(_result):
                if close_terminals:
                    self._disconnect_connection_terminals(connection)
                _delete_next()

            def _failure(error):
                self._deleting_connections_batch = False
                if isinstance(error, SshPilotError):
                    logger.warning(
                        "Daemon connection delete failed code=%s",
                        error.code.value,
                    )
                else:
                    logger.error(
                        "Daemon connection delete failed type=%s",
                        type(error).__name__,
                    )
                if not self._is_quitting:
                    self._error_dialog(
                        _("Failed to delete connection"),
                        _(
                            "The connection could not be deleted. "
                            "No list changes were applied."
                        ),
                    )

            try:
                bridge.submit(
                    lambda: self.client.delete_connection(request),
                    on_success=_success,
                    on_error=_failure,
                )
            except RuntimeError:
                _failure(RuntimeError("client bridge closed"))

        _delete_next()

    def on_connection_added(self, manager, connection):
        """Handle new connection added"""
        self.group_manager.bind_connections(self.connection_manager.connections)
        self.group_manager.connections.setdefault(connection.id, None)
        if connection.id not in self.group_manager.root_connections:
            self.group_manager.root_connections.append(connection.id)
            self.group_manager._save_groups()
        self.rebuild_connection_list()

    def on_projection_reset(self, manager, _connection=None):
        """Rebuild presentation state after an authoritative store refresh."""
        self.group_manager.bind_connections(manager.connections)
        self.rebuild_connection_list()
        if not self._initial_connection_list_focus_done:
            # The daemon-backed client attaches asynchronously (client_bridge
            # selection + connection store fetch), so this reset — not window
            # construction — is the first point the connection list actually
            # has rows. The fixed 100ms/700ms startup timers in __init__ can
            # fire before the daemon responds and find nothing to focus; this
            # is what makes "focus the first row on startup" work regardless
            # of how long that took. Only the first reset ever counts, so a
            # later reconnect/refresh never steals focus from the user.
            startup_behavior = 'welcome'
            try:
                startup_behavior = self.config.get_setting(
                    'app-startup-behavior', 'welcome'
                )
            except Exception:
                pass
            if startup_behavior != 'terminal':
                # Runs before the flag flips: this call *is* the startup
                # first-row focus, and _startup_first_row_focus_allowed()
                # keys off that same flag once the 500 ms timer has passed.
                self._focus_connection_list_first_row()
            self._initial_connection_list_focus_done = True

    def on_connection_removed(self, manager, connection):
        """Handle connection removed from the connection manager"""
        logger.info(f"Connection removed: {connection.nickname}")

        # Save current scroll position before any UI changes
        scroll_position = None
        if hasattr(self, 'connection_scrolled') and self.connection_scrolled:
            vadj = self.connection_scrolled.get_vadjustment()
            if vadj:
                scroll_position = vadj.get_value()

        # Remove from UI if it exists (a connection may have several rows)
        if connection in self.connection_rows:
            for row in self._rows_for_connection(connection):
                self.connection_list.remove(row)
            del self.connection_rows[connection]

        # Remove from group manager, including any group it was copied into
        self.group_manager.connections.pop(connection.id, None)
        if connection.id in self.group_manager.root_connections:
            self.group_manager.root_connections.remove(connection.id)
        for group in self.group_manager.groups.values():
            if connection.id in group.get('connections', []):
                group['connections'] = [
                    n for n in group['connections'] if n != connection.id
                ]
        if not getattr(self, '_deleting_connections_batch', False):
            self.group_manager._save_groups()
        self._refresh_group_rows_after_connection_removed(connection)

        # Close all terminals for this connection and clean up maps
        terminals = list(self.connection_to_terminals.get(connection, []))
        # Suppress confirmation while we programmatically close pages
        self._suppress_close_confirmation = True
        try:
            for term in terminals:
                try:
                    page = self._page_for_child(term)
                    if page:
                        self.tab_view.close_page(page)
                except Exception:
                    pass
                try:
                    if hasattr(term, 'disconnect'):
                        term.disconnect()
                except Exception:
                    pass
                # Remove reverse map entry for each terminal
                try:
                    if term in self.terminal_to_connection:
                        del self.terminal_to_connection[term]
                except Exception:
                    pass
        finally:
            self._suppress_close_confirmation = False
        if connection in self.connection_to_terminals:
            del self.connection_to_terminals[connection]
        if connection in self.active_terminals:
            del self.active_terminals[connection]

        # Restore scroll position without auto-selecting any row
        def _restore_scroll_only():
            if scroll_position is not None and hasattr(self, 'connection_scrolled') and self.connection_scrolled:
                vadj = self.connection_scrolled.get_vadjustment()
                if vadj:
                    vadj.set_value(scroll_position)
            return False

        # Use idle_add to restore scroll position after UI updates complete
        GLib.idle_add(_restore_scroll_only)

    def _refresh_group_rows_after_connection_removed(self, connection):
        """Refresh visible group counts after an incremental row removal."""
        connections_dict = {
            conn.nickname: conn
            for conn in self.connection_manager.get_connections()
        }
        connections_dict.update(
            {
                conn.id: conn
                for conn in self.connection_manager.get_connections()
                if getattr(conn, 'id', None)
            }
        )
        row = self.connection_list.get_first_child()
        while row:
            if isinstance(row, GroupRow):
                if getattr(row, 'is_tag_group', False):
                    row.group_info['connections'] = [
                        nickname
                        for nickname in row.group_info.get('connections', [])
                        if nickname != connection.nickname
                    ]
                else:
                    current_group = self.group_manager.groups.get(row.group_id)
                    if current_group is not None:
                        row.group_info['connections'] = list(
                            current_group.get('connections', [])
                        )

                row.connections_dict = connections_dict
                if hasattr(row, '_member_rows'):
                    row._member_rows = [
                        member
                        for member in row._member_rows
                        if getattr(member, 'connection', None) is not connection
                    ]
                row._update_display()
            row = row.get_next_sibling()

    def _recompute_connection_state(self, connection):
        """Aggregate per-terminal state into the connection's authoritative state.

        This is the single place the OR-across-terminals rule lives (moved out of
        the sidebar renderer): a connection is CONNECTED while any of its
        terminals is connected, and only drops to DISCONNECTED when the last one
        goes down. A richer "down" state already set on the connection (FAILED)
        is preserved rather than being flattened to DISCONNECTED.
        """
        # Local terminals use a lightweight LocalConnection without the status
        # API and have no sidebar row — nothing to aggregate.
        if not hasattr(connection, 'get_status'):
            return
        try:
            terminals = []
            if hasattr(self, 'connection_to_terminals'):
                terminals = list(self.connection_to_terminals.get(connection, []) or [])
            # Fall back to the most-recent terminal map for edge cases where the
            # comprehensive map hasn't been populated yet (mirrors prior renderer).
            if not terminals and hasattr(self, 'active_terminals'):
                recent = self.active_terminals.get(connection)
                if recent is not None:
                    terminals = [recent]

            def _term_state(t):
                s = getattr(t, 'connection_state', None)
                if isinstance(s, ConnectionState):
                    return s
                return (
                    ConnectionState.CONNECTED
                    if getattr(t, 'is_connected', False)
                    else ConnectionState.DISCONNECTED
                )

            states = [_term_state(t) for t in terminals]

            # Priority across a connection's terminals: any live tab wins, then a
            # tab still connecting, then a failed attempt (keep its reason), else
            # disconnected.
            if any(s == ConnectionState.CONNECTED for s in states):
                self.connection_manager.update_connection_state(connection, ConnectionState.CONNECTED)
            elif any(s == ConnectionState.CONNECTING for s in states):
                self.connection_manager.update_connection_state(connection, ConnectionState.CONNECTING)
            elif any(s == ConnectionState.FAILED for s in states):
                reason = ''
                for t in terminals:
                    if _term_state(t) == ConnectionState.FAILED:
                        reason = getattr(t, 'connection_state_reason', '') or ''
                        break
                self.connection_manager.update_connection_state(
                    connection, ConnectionState.FAILED, reason
                )
            elif terminals:
                self.connection_manager.update_connection_state(connection, ConnectionState.DISCONNECTED)
            else:
                # No terminals at all (every tab for this connection is closed):
                # there is nothing to report, so go neutral and hide the
                # indicator rather than showing a red "Disconnected"/"failed"
                # icon for what is an intentional close.
                self.connection_manager.update_connection_state(connection, ConnectionState.UNKNOWN)
        except Exception as e:
            logger.error(f"Failed to recompute connection state: {e}")

    def on_connection_status_changed(self, manager, connection, is_connected):
        """Handle connection status change (render-only — state is authoritative)."""
        logger.debug(f"Connection status changed: {connection.nickname} - {'Connected' if is_connected else 'Disconnected'}")
        rows = self._rows_for_connection(connection)
        if rows:
            for row in rows:
                # Render each row from the connection's authoritative state.
                row.update_status()
                row.queue_draw()

        # If this was a controlled reconnect and we are now connected, reset the flag
        if is_connected and getattr(self, '_is_controlled_reconnect', False):
            self._is_controlled_reconnect = False

        # Use the same reliable status to control terminal banners
        try:
            for term in self.connection_to_terminals.get(connection, []) or []:
                if hasattr(term, '_set_disconnected_banner_visible'):
                    if is_connected:
                        term._set_disconnected_banner_visible(False)
                    else:
                        # Do not force-show here to avoid duplicate messages; terminals handle showing on failure/loss
                        pass
        except Exception:
            pass

    def on_setting_changed(self, config, key, value):
        """Handle configuration setting change"""
        logger.debug(f"Setting changed: {key} = {value}")

        # Apply relevant changes
        if key.startswith('terminal.'):
            # Update terminal themes/fonts
            for terms in self.connection_to_terminals.values():
                for terminal in terms:
                    terminal.apply_theme()
        elif key == 'ui.group_row_display':
            normalized = 'nested'
            try:
                normalized = str(value).lower()
            except Exception:
                pass
            if normalized not in {'fullwidth', 'nested'}:
                normalized = 'nested'

            # Covers both connection rows and group headers (nested groups
            # honor the same fullwidth/nested layout).
            for row in self.connection_list:
                if hasattr(row, 'refresh_group_display_mode'):
                    row.refresh_group_display_mode(normalized)

    def on_window_size_changed(self, window, param):
        """Handle window size change"""
        width = self.get_default_size()[0]
        height = self.get_default_size()[1]
        sidebar_width = self._get_sidebar_width()

        self.config.save_window_geometry(width, height, sidebar_width)

    def simple_close_handler(self, window):
        """Handle window close - distinguish between tab close and window close"""
        logger.info("")

        try:
            # Check if we have any tabs open
            n_pages = self.tab_view.get_n_pages()
            logger.info(f" Number of tabs: {n_pages}")

            # If we have tabs, close all tabs first and then quit
            if n_pages > 0:
                logger.info(" CLOSING ALL TABS FIRST")
                # Close all tabs
                while self.tab_view.get_n_pages() > 0:
                    page = self.tab_view.get_nth_page(0)
                    self.tab_view.close_page(page)

            # Now quit the application
            logger.info(" QUITTING APPLICATION")
            app = self.get_application()
            if app:
                app.quit()

        except Exception as e:
            logger.error(f" ERROR IN WINDOW CLOSE: {e}")
            # Force quit even if there's an error
            app = self.get_application()
            self.show_quit_confirmation_dialog()
            return False  # Don't quit yet, let dialog handle it

        # No active connections, safe to quit
        self._do_quit()
        return True  # Safe to quit

    def on_close_request(self, window):
        """Handle window close request - MAIN ENTRY POINT"""
        if self._is_quitting:
            self._teardown_ssh_config_monitor()
            self._invalidate_api_window_callbacks()
            # Drop the window-global fullscreen CSS provider and controllers.
            try:
                controller = getattr(self, 'fullscreen_controller', None)
                if controller is not None:
                    controller.shutdown()
            except Exception:
                logger.debug('Fullscreen controller shutdown failed', exc_info=True)
            return False  # Already quitting, allow close

        # Capture the currently-open tabs so they can be restored next launch
        # when the user has chosen the "previous session" startup behavior.
        try:
            session_manager = getattr(self, 'session_manager', None)
            if session_manager is not None:
                session_manager.save_previous(self.capture_session())
        except Exception as e:
            logger.debug(f"Failed to capture previous session on close: {e}")

        # Check for active connections across all tabs
        actually_connected = {}
        local_terminals = []
        ssh_terminals = []

        for conn, terms in self.connection_to_terminals.items():
            for term in terms:
                if getattr(term, 'is_connected', False):
                    actually_connected.setdefault(conn, []).append(term)
                    # Categorize terminals
                    if hasattr(term, '_is_local_terminal') and term._is_local_terminal():
                        local_terminals.append(term)
                    else:
                        ssh_terminals.append(term)

        # If there are SSH terminals, always show warning
        if ssh_terminals:
            self.show_quit_confirmation_dialog()
            return True  # Prevent close, let dialog handle it

        # If there are only local terminals, check their job status
        if local_terminals:
            # Check if any local terminal has an active job
            has_active_jobs = False
            for term in local_terminals:
                if hasattr(term, 'has_active_job') and term.has_active_job():
                    has_active_jobs = True
                    break

            # If any local terminal has an active job, show warning
            if has_active_jobs:
                self.show_quit_confirmation_dialog()
                return True  # Prevent close, let dialog handle it

        # No active connections, or only idle local terminals. Nothing needs
        # confirming — but a daemon still has to be torn down and *verified*
        # gone before the process exits, so this closes through the same
        # gated path rather than simply allowing the window to go away.
        if getattr(self, "client", None) is not None:
            self._teardown_ssh_config_monitor()
            self._proceed_with_confirmed_quit()
            return True  # Hold the window until teardown is verified.

        self._teardown_ssh_config_monitor()
        self._invalidate_api_window_callbacks()
        return False  # Allow close

    def _proceed_with_confirmed_quit(self) -> None:
        """Quit through the verified-teardown path whenever a daemon exists.

        Every exit that owns runtime resources goes through
        ``apply_terminate_all``: it stops the daemon, reaps what it owned, and
        only calls ``cleanup_and_quit`` once verification proves nothing
        sshPilot started is still running. Without a daemon there is nothing
        to verify and the plain shutdown applies.
        """
        if getattr(self, "client", None) is not None:
            from .daemon_quit_policy import apply_terminate_all

            apply_terminate_all(self)
            return
        shutdown.cleanup_and_quit(self)

    def _invalidate_api_window_callbacks(self) -> None:
        """Detach this window without closing the application-owned client."""

        welcome = getattr(self, 'welcome_view', None)
        if welcome is not None:
            welcome.close()
        request = getattr(self, '_api_client_selection_request', None)
        self._api_client_selection_request = None
        if request is not None:
            request.cancel()

    def prompt_ssh_presence(self, prompt: str = "", register_close=None) -> bool:
        """Show a FIDO/security-key touch reminder (no typed secret).

        Used for ``SSH_ASKPASS_PROMPT=none``. Returns True when the user
        dismisses with Close (reminder acknowledged); False on unexpected
        failure. Touch itself happens on the hardware — this is only UI.
        *register_close* (when given) receives a zero-arg callable that closes
        the dialog programmatically.
        """
        parent = self
        present_for_modal_dialog(parent)
        body = (prompt or "").strip() or _(
            "Touch your security key to continue."
        )
        dialog = Adw.AlertDialog()
        dialog.set_heading(_("Security Key"))
        dialog.set_body(
            body + "\n\n" + _("Touch your security key when it blinks.")
        )
        dialog.add_response("ok", _("Close"))
        dialog.set_default_response("ok")
        dialog.set_close_response("ok")
        result = [True]
        closed = [False]

        def _on_response(_d, _response):
            closed[0] = True
            loop.quit()

        dialog.connect("response", _on_response)
        loop = GLib.MainLoop()
        dialog.present(parent)
        if register_close is not None:
            def _force_close():
                if not closed[0]:
                    try:
                        dialog.close()
                    except Exception:
                        loop.quit()
            try:
                register_close(_force_close)
            except Exception:
                pass
        try:
            loop.run()
        except Exception:
            result[0] = False
        return result[0]

    def prompt_ssh_confirm(self, prompt: str = "") -> bool:
        """Yes/No confirm for ``SSH_ASKPASS_PROMPT=confirm`` (e.g. ssh-add -c)."""
        parent = self
        present_for_modal_dialog(parent)
        dialog = Adw.AlertDialog()
        dialog.set_heading(_("Confirm"))
        dialog.set_body(
            (prompt or "").strip() or _("Allow this key operation?")
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("ok", _("Allow"))
        dialog.set_response_appearance(
            "ok", Adw.ResponseAppearance.SUGGESTED
        )
        dialog.set_default_response("ok")
        dialog.set_close_response("cancel")
        allowed = [False]

        def _on_response(_d, response):
            allowed[0] = response == "ok"
            loop.quit()

        dialog.connect("response", _on_response)
        loop = GLib.MainLoop()
        dialog.present(parent)
        try:
            loop.run()
        except Exception:
            allowed[0] = False
        return allowed[0]

    def prompt_ssh_password(
        self,
        *,
        display_name: str = "",
        host: Optional[str] = None,
        username: Optional[str] = None,
        heading: Optional[str] = None,
        body: Optional[str] = None,
    ) -> Optional[str]:
        """Show an SSH password prompt as a modal child of the main window.

        Thin wrapper around :func:`show_ssh_password_dialog` for callers that
        already have the :class:`MainWindow` instance (e.g. future in-app actions).
        """
        return show_ssh_password_dialog(
            parent_window=self,
            display_name=display_name,
            host=host,
            username=username,
            heading=heading,
            body=body,
        )

    def show_quit_confirmation_dialog(self):
        """Show confirmation dialog when quitting with active connections.

        Quit always tears the daemon and its work down; the app-close policy
        only decides whether that is confirmed first (ASK) or immediate
        (TERMINATE). Local-terminal jobs keep their own Cancel / Quit Anyway
        prompt.
        """
        try:
            self.unminimize()
        except Exception as e:
            logger.debug(f"Failed to unminimize window: {e}")
        try:
            self.present()
        except Exception as e:
            logger.debug(f"Failed to bring window to foreground: {e}")

        from .daemon_quit_policy import (
            DaemonQuitDecision,
            apply_terminate_all,
            has_daemon_active_work,
            present_daemon_quit_dialog,
            resolve_quit_decision_from_policy,
        )

        daemon_work = has_daemon_active_work(self)
        auto = None
        if daemon_work:
            auto = resolve_quit_decision_from_policy(getattr(self, "config", None))
            # ASK (None) → confirm dialog; TERMINATE applies immediately.
            if auto is DaemonQuitDecision.TERMINATE_ALL:
                apply_terminate_all(self)
                return
            if auto is None:
                app = self.get_application()
                if app is not None:
                    app.hold()

                def _on_daemon_decision(decision: DaemonQuitDecision) -> None:
                    try:
                        if decision is DaemonQuitDecision.TERMINATE_ALL:
                            apply_terminate_all(self)
                        else:
                            try:
                                self.unminimize()
                            except Exception:
                                pass
                            try:
                                self.present()
                            except Exception:
                                pass
                    finally:
                        if app is not None:
                            app.release()

                present_daemon_quit_dialog(self, on_decision=_on_daemon_decision)
                return

        # Local terminals / no daemon work: Cancel / Quit Anyway.
        connected_items = []
        local_terminals = []
        ssh_terminals = []

        for conn, terms in self.connection_to_terminals.items():
            for term in terms:
                if getattr(term, 'is_connected', False):
                    connected_items.append((conn, term))
                    if hasattr(term, '_is_local_terminal') and term._is_local_terminal():
                        local_terminals.append((conn, term))
                    else:
                        ssh_terminals.append((conn, term))

        active_count = len(connected_items)

        if ssh_terminals:
            if active_count == 1:
                message = _("You have 1 open terminal tab.")
                detail = _("Closing the application will disconnect this connection.")
            else:
                message = _("You have {count} open terminal tabs.").format(count=active_count)
                detail = _("Closing the application will disconnect all connections.")
        else:
            if active_count == 1:
                message = _("You have 1 local terminal with an active job.")
                detail = _("Closing the application will terminate the running process.")
            else:
                message = _("You have {count} local terminals with active jobs.").format(count=active_count)
                detail = _("Closing the application will terminate all running processes.")

        # Gtk.AlertDialog: own top-level window so Wayland still maps it when
        # the main window is in the background (Adw.AlertDialog stays inside
        # the background surface). Daemon path above uses Adw because the
        # main window was just presented.
        dialog = Gtk.AlertDialog()
        dialog.set_modal(True)
        dialog.set_message(_("Quit SSH Pilot?"))
        dialog.set_detail("{message}\n\n{detail}".format(message=message, detail=detail))
        dialog.set_buttons([_('Cancel'), _('Quit Anyway')])
        dialog.set_cancel_button(0)
        dialog.set_default_button(1)

        app = self.get_application()
        if app is not None:
            app.hold()

        dialog.choose(self, None, self._on_quit_alert_chosen)

    def _on_quit_alert_chosen(self, dialog, result):
        """Handle the quit confirmation Gtk.AlertDialog result."""
        app = self.get_application()
        try:
            try:
                index = dialog.choose_finish(result)
            except GLib.Error:
                # Dismissed via Escape / window close -> treat as Cancel.
                index = -1
            if index == 1:  # "Quit Anyway"
                self._proceed_with_confirmed_quit()
            else:
                # Cancel / dismissed: the user is staying in the app, so bring
                # the main window to the front. The button click provided a
                # valid activation token, so present() is honored on Wayland.
                try:
                    self.unminimize()
                except Exception as e:
                    logger.debug(f"Failed to unminimize window: {e}")
                try:
                    self.present()
                except Exception as e:
                    logger.debug(f"Failed to bring window to foreground: {e}")
        finally:
            if app is not None:
                app.release()


    def on_manage_local_authorized_keys_action(self, action, param=None):
        """Open the daemon-owned local authorized_keys editor."""
        if self.client is None:
            logger.debug("Local authorized_keys editor requires the daemon")
            return
        try:
            from .authorized_keys_window import AuthorizedKeysWindow
            window = AuthorizedKeysWindow(
                parent=self,
                client=self.client,
                local=True,
                key_manager=getattr(self, "key_manager", None),
            )
            window.present()
        except Exception as exc:
            logger.error("Failed to open local authorized_keys editor: %s", type(exc).__name__)

    def on_manage_authorized_keys_action(self, action, param=None):
        """Open the daemon-owned authorized_keys editor for the selected connection."""
        if self.client is None:
            return
        connection = getattr(self, "_context_menu_connection", None)
        if connection is None:
            return
        if PluginCapability.KEY_DEPLOYMENT not in capabilities_for(connection):
            return
        dialogs = None
        try:
            from .authorized_keys_window import AuthorizedKeysWindow
            from .api.models import SessionId
            from .api.models.operations import OpenSftpRequest
            from .api.connection_identity import connection_id_for
            bridge = getattr(self, "client_bridge", None)
            if bridge is not None:
                # Attach the interaction presenter before opening the service
                # so a password/host-key prompt during the handshake is never
                # missed (same ordering as the daemon SFTP backend).
                from .daemon_interaction_dialogs import DaemonInteractionDialogs
                dialogs = DaemonInteractionDialogs(self.client, bridge, self)
            service = self.client.open_sftp(OpenSftpRequest(connection_id=connection_id_for(connection)))
            if dialogs is not None:
                dialogs.set_session(SessionId(str(service.id)))
            window = AuthorizedKeysWindow(
                parent=self,
                client=self.client,
                service_id=service.id,
                connection=connection,
                key_manager=getattr(self, "key_manager", None),
                interaction_dialogs=dialogs,
            )
            if dialogs is not None:
                # Prompts must stack above the editor window, not the main one.
                dialogs.set_parent(window)
            window.present()
        except Exception as exc:
            if dialogs is not None:
                dialogs.close()
            logger.error("Failed to open authorized_keys editor: %s", type(exc).__name__)

    def on_edit_connection_action(self, action, param=None):
        """Handle edit connection action from context menu"""
        try:
            connection = getattr(self, '_context_menu_connection', None)
            if connection is None:
                # Fallback to selected row if any
                row = self.connection_list.get_selected_row()
                connection = getattr(row, 'connection', None) if row else None
            if connection is None:
                return
            self.show_connection_dialog(connection)
        except Exception as e:
            logger.error(f"Failed to edit connection: {e}")

    def on_verify_configuration_action(self, action=None, param=None):
        """Open the effective-config viewer for the context-menu connection."""
        try:
            connection = getattr(self, '_context_menu_connection', None)
            if connection is None:
                row = self.connection_list.get_selected_row()
                connection = getattr(row, 'connection', None) if row else None
            if connection is None:
                return
            self.show_effective_config_for_connection(connection)
        except Exception:
            logger.debug("Failed to open effective config viewer", exc_info=True)

    def on_delete_connection_action(self, action, param=None):
        """Handle delete connection action from context menu"""
        try:
            target_rows = self._get_target_connection_rows(prefer_context=True)
            if not target_rows:
                return

            connections = self._connections_from_rows(target_rows)
            neighbor_row = self._determine_neighbor_connection_row(target_rows)

            self._prompt_delete_connections(connections, neighbor_row)
        except Exception as e:
            logger.error(f"Failed to delete connection: {e}")

    def on_open_in_system_terminal_action(self, action, param=None):
        """Handle open in system terminal action from context menu"""
        try:
            connection = getattr(self, '_context_menu_connection', None)
            if connection is None:
                # Fallback to selected row if any
                row = self.connection_list.get_selected_row()
                connection = getattr(row, 'connection', None) if row else None
            if connection is None:
                return

            self.open_in_system_terminal(connection)
        except Exception as e:
            logger.error(f"Failed to open in system terminal: {e}")

    def on_run_command_action(self, action=None, param=None):
        """Open the command picker for the right-clicked connection(s) or group."""
        panel = self._ensure_command_blocks_panel()
        if panel is None:
            return
        anchor = getattr(self, '_context_menu_row', None) or self
        # Copy the multi-select snapshot by value: it is cleared when the
        # context menu closes, but the picker popover outlives it.
        connections = list(getattr(self, '_context_menu_connections', None) or [])
        connection = getattr(self, '_context_menu_connection', None)
        group_row = getattr(self, '_context_menu_group_row', None)

        # Only target connections whose protocol can run remote commands.
        connections = [
            c for c in connections
            if PluginCapability.REMOTE_COMMAND in capabilities_for(c)
        ]
        if connection is not None and PluginCapability.REMOTE_COMMAND not in capabilities_for(connection):
            connection = None

        if len(connections) > 1:
            panel.show_command_picker_for_target(anchor, connections=connections)
        elif connection is not None:
            panel.show_command_picker_for_target(anchor, connection=connection)
        elif group_row is not None:
            gm = getattr(self, 'group_manager', None)
            if gm is None:
                return
            group = gm.groups.get(group_row.group_id)
            if group:
                panel.show_command_picker_for_target(anchor, group=group)

    def _open_tag_group_split(self, tag_row):
        """Open a tag group's connections in split view (context menu path)."""
        try:
            self._context_menu_group_row = tag_row
            self.on_open_group_in_split_view_action(None, None)
        except Exception as e:
            logger.error(f"Failed to open tag group in split view: {e}")

    def on_move_to_ungrouped_action(self, action, param=None):
        """Handle move to ungrouped / remove from group action.

        When the action is triggered from a connection shown under a specific
        group and that connection belongs to several groups, only the
        membership for that group is removed. Otherwise the connection is fully
        ungrouped (removed from every group).
        """
        try:
            connections = self._get_target_connections(prefer_context=True)
            if not connections:
                return

            controller = getattr(self.group_manager, 'controller', None)
            if controller is None:
                self._simple_dialog(
                    _("Service unavailable"),
                    _("Connect to the sshPilot daemon before moving connections."),
                )
                return

            context_row = getattr(self, '_context_menu_row', None)
            context_group_id = getattr(context_row, '_group_id', None)

            steps = []
            for connection in connections:
                nickname = getattr(connection, 'nickname', None)
                if not nickname:
                    continue
                member_groups = self.group_manager.get_connection_groups(nickname)
                if (
                    context_group_id
                    and len(connections) == 1
                    and context_group_id in member_groups
                    and len(member_groups) > 1
                ):
                    from sshpilot.api.models.connection_store import (
                        ConnectionId, GroupId, RemoveConnectionFromGroupRequest,
                    )
                    steps.append(
                        lambda _prev, n=nickname, gid=context_group_id: (
                            controller.client.remove_connection_from_group(
                                RemoveConnectionFromGroupRequest(
                                    connection_id=ConnectionId(n),
                                    group_id=GroupId(gid),
                                )
                            )
                        )
                    )
                else:
                    steps.append(
                        lambda _prev, n=nickname: (
                            controller.client.assign_connection_to_group(n, "")
                        )
                    )
            if steps:
                controller.run_sequence(
                    steps,
                    on_success=lambda _r: self.rebuild_connection_list(),
                    on_error=lambda e: self._simple_dialog(
                        _("Error"),
                        _("Failed to move connection: {error}").format(error=str(e)),
                    ),
                )

        except Exception as e:
            logger.error(f"Failed to move connection to ungrouped: {e}")

    def move_connection_to_group(self, connection_nickname: str, target_group_id: Optional[str] = None):
        """Move a connection to a specific group"""
        try:
            controller = getattr(self.group_manager, 'controller', None)
            if controller is None:
                self._simple_dialog(
                    _("Service unavailable"),
                    _("Connect to the sshPilot daemon before moving connections."),
                )
                return
            controller.run(
                lambda: controller.client.assign_connection_to_group(
                    connection_nickname, target_group_id or ""
                ),
                on_success=lambda _r: self.rebuild_connection_list(),
                on_error=lambda e: self._simple_dialog(
                    _("Error"),
                    _("Failed to move connection: {error}").format(error=str(e)),
                ),
            )
        except Exception as e:
            logger.error(f"Failed to move connection {connection_nickname} to group: {e}")

    def _find_connection_id_for_nickname(self, nickname: str):
        """Find the daemon connection ID for a connection by nickname."""
        for conn in self.connection_manager.connections:
            if getattr(conn, 'nickname', '') == nickname:
                return connection_id_for(conn)
        return None

    def get_available_groups(self) -> List[Dict]:
        """Get list of available groups for selection"""
        return self.group_manager.get_all_groups()

    def open_in_system_terminal(self, connection):
        """Open a daemon-prepared connection in the selected terminal."""
        if getattr(connection, 'protocol', 'ssh') != 'ssh':
            logger.debug("System terminal unavailable for protocol %r",
                         getattr(connection, 'protocol', 'ssh'))
            return
        if not self._daemon_ready():
            self._show_daemon_unavailable_dialog()
            return

        def on_success(spec):
            use_external = self.config.get_setting('use-external-terminal', False)
            terminal_command = (
                get_user_preferred_terminal(self.config)
                if use_external else get_default_terminal_command()
            ) or find_any_terminal()
            self._launch_external_terminal_spec(spec, terminal_command)

        self._submit_external_terminal_launch(connection, on_success)

    def _open_connection_in_external_terminal(self, connection):
        """Open a daemon-prepared connection in the preferred terminal."""
        if not self._daemon_ready():
            self._show_daemon_unavailable_dialog()
            return
        terminal = get_user_preferred_terminal(self.config) or get_default_terminal_command()
        self._submit_external_terminal_launch(
            connection,
            lambda spec: self._launch_external_terminal_spec(spec, terminal),
        )

    def _submit_external_terminal_launch(self, connection, on_success) -> None:
        bridge = getattr(self, "client_bridge", None)
        client = getattr(self, "client", None)
        if bridge is None or client is None:
            self._show_daemon_unavailable_dialog()
            return
        try:
            if not client.get_capabilities().supports(
                ApiCapability.EXTERNAL_TERMINAL_LAUNCH
            ):
                self._error_dialog(
                    _("External terminal unavailable"),
                    _("This daemon cannot prepare an external SSH launch."),
                )
                return
            bridge.submit(
                lambda: client.prepare_external_terminal_launch(
                    connection_id_for(connection)
                ),
                on_success=on_success,
                on_error=lambda error: self._on_external_terminal_launch_error(error),
            )
        except Exception as error:
            self._on_external_terminal_launch_error(error)

    def _launch_external_terminal_spec(self, spec, terminal_command) -> None:
        if not terminal_command:
            self._show_terminal_error_dialog()
            return
        try:
            ssh_command = shlex.join(tuple(spec.argv))
            if not open_system_terminal(
                terminal_command,
                ssh_command,
                environment=dict(spec.environment),
            ):
                self._show_terminal_error_dialog()
        except Exception as error:
            logger.error("Failed to open daemon-prepared external terminal: %s", error)
            self._show_terminal_error_dialog()

    def _on_external_terminal_launch_error(self, error) -> None:
        from .api import ErrorCode, SshPilotError

        code = getattr(error, "code", None)
        if code is ErrorCode.UNSUPPORTED_CAPABILITY:
            self._error_dialog(
                _("External terminal unavailable"),
                _("This daemon cannot prepare an external SSH launch."),
            )
        elif code is ErrorCode.CONNECTION_NOT_FOUND:
            self._error_dialog(
                _("Connection unavailable"),
                _("The selected connection no longer exists in the daemon."),
            )
        elif isinstance(error, SshPilotError) and code in {
            ErrorCode.DAEMON_UNAVAILABLE,
            ErrorCode.TRANSPORT_CLOSED,
            ErrorCode.TRANSPORT_TIMEOUT,
        }:
            self._show_daemon_unavailable_dialog(error)
        else:
            logger.error(
                "Daemon could not prepare external terminal launch: %s",
                type(error).__name__,
            )
            self._error_dialog(
                _("External terminal unavailable"),
                _("The daemon could not prepare this SSH launch."),
            )

    def _on_effective_config_error(self, error) -> None:
        """Handle effective-config failures without using terminal-launch UI."""
        from .api import ErrorCode, SshPilotError

        code = getattr(error, "code", None)
        if code is ErrorCode.CONNECTION_NOT_FOUND:
            heading = _("Connection unavailable")
            body = _("The selected connection no longer exists in the daemon.")
        elif code is ErrorCode.UNSUPPORTED_CAPABILITY:
            heading = _("Effective configuration unavailable")
            body = _("This daemon does not provide effective SSH configuration comparison.")
        elif isinstance(error, SshPilotError) and code in {
            ErrorCode.DAEMON_UNAVAILABLE,
            ErrorCode.TRANSPORT_CLOSED,
            ErrorCode.TRANSPORT_TIMEOUT,
        }:
            self._show_daemon_unavailable_dialog(error)
            return
        else:
            heading = _("Effective configuration unavailable")
            body = _("SSH Pilot could not resolve or compare the effective SSH configuration.")
        logger.warning("Effective-config comparison unavailable code=%s", getattr(code, "value", code))
        self._error_dialog(heading, body)

    def _show_terminal_error_dialog(self):
        """Show error dialog when no terminal is found"""
        try:
            dialog = Adw.MessageDialog(
                transient_for=self,
                modal=True,
                heading=_("No Terminal Found"),
                body=_("Could not find a suitable terminal application. Please install a terminal like gnome-terminal, konsole, or xterm.")
            )

            dialog.add_response("ok", _("OK"))
            dialog.set_default_response("ok")
            dialog.set_close_response("ok")
            dialog.present()

        except Exception as e:
            logger.error(f"Failed to show terminal error dialog: {e}")

    def _show_manage_files_error(self, connection_name: str, error_message: str):
        """Show error dialog for manage files failure"""
        try:
            # Determine error type for appropriate messaging
            is_ssh_error = "ssh connection" in error_message.lower() or "connection failed" in error_message.lower()
            is_timeout_error = "timeout" in error_message.lower()

            if is_ssh_error or is_timeout_error:
                heading = _("SSH Connection Failed")
                body = _("Could not establish SSH connection to the server. Please check:")

                suggestions = [
                    _("• Server is running and accessible"),
                    _("• SSH service is enabled on the server"),
                    _("• Firewall allows SSH connections"),
                    _("• Your SSH keys or credentials are correct"),
                    _("• Network connectivity to the server")
                ]
            else:
                heading = _("File Manager Error")
                body = _("Failed to open file manager for remote server.")
                suggestions = [
                    _("• Try again in a moment"),
                    _("• Check if the server is accessible"),
                    _("• Ensure you have proper permissions")
                ]

            # Create suggestions box
            suggestions_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
            suggestions_box.set_margin_top(12)

            for suggestion in suggestions:
                label = Gtk.Label(label=suggestion)
                label.set_halign(Gtk.Align.START)
                label.set_wrap(True)
                suggestions_box.append(label)

            msg = Adw.MessageDialog(
                transient_for=self,
                modal=True,
                heading=heading,
                body=body
            )
            msg.set_extra_child(suggestions_box)

            # Add technical details if available
            if error_message and error_message.strip():
                detail_label = Gtk.Label(label=error_message)
                detail_label.add_css_class("dim-label")
                detail_label.set_wrap(True)
                detail_label.set_margin_top(8)
                suggestions_box.append(detail_label)

            msg.add_response("ok", _("OK"))
            msg.set_default_response("ok")
            msg.set_close_response("ok")
            msg.present()

        except Exception as e:
            logger.error(f"Failed to show manage files error dialog: {e}")

    def _do_quit(self):
        """Actually quit the application - FINAL STEP"""
        try:
            logger.info("Quitting application")

            # Close all file manager windows first
            if hasattr(self, '_internal_file_manager_windows'):
                count = len(self._internal_file_manager_windows)
                logger.info(f"Closing {count} file manager windows")
                if count > 0:
                    for window in list(self._internal_file_manager_windows):
                        try:
                            logger.info(f"Closing file manager window: {window}")
                            # Try to clean up the manager first
                            if hasattr(window, '_manager') and window._manager is not None:
                                logger.info("Closing file manager backend in file manager window")
                                window._manager.close()
                                window._manager = None
                                logger.info("File manager backend closed")
                            # Close the window
                            if hasattr(window, 'close'):
                                window.close()
                            elif hasattr(window, 'destroy'):
                                window.destroy()
                        except Exception as exc:
                            logger.error(f"Error closing file manager window: {exc}", exc_info=True)
                self._internal_file_manager_windows.clear()
                logger.info("All file manager windows closed")

            # Also check all application windows for file manager windows (defensive)
            try:
                app = self.get_application()
                if app:
                    all_windows = list(app.get_windows())
                    for window in all_windows:
                        # Check if it's a file manager window that wasn't tracked
                        if hasattr(window, '_manager') and hasattr(window, '_on_close_request'):
                            if not hasattr(self, '_internal_file_manager_windows') or window not in self._internal_file_manager_windows:
                                logger.info(f"Found untracked file manager window, closing: {window}")
                                try:
                                    if hasattr(window, '_manager') and window._manager is not None:
                                        logger.info("Closing file manager backend in untracked window")
                                        window._manager.close()
                                        window._manager = None
                                except Exception as exc:
                                    logger.error(f"Error closing untracked file manager window: {exc}", exc_info=True)
            except Exception as exc:
                logger.debug(f"Error checking for untracked file manager windows: {exc}")

            # Save window geometry
            self._save_window_state()

            # Get the application and quit
            app = self.get_application()
            if app:
                app.quit()
            else:
                # Fallback: close the window directly
                self.close()

        except Exception as e:
            logger.error(f"Error during final quit: {e}")
            # Force exit as last resort
            import sys
            sys.exit(0)

        return False  # Don't repeat timeout

    def _save_window_state(self):
        """Save window state before quitting"""
        try:
            width, height = self.get_default_size()
            sidebar_width = getattr(self.split_view, 'get_sidebar_width', lambda: 250)()
            self.config.save_window_geometry(width, height, sidebar_width)
            logger.debug(f"Saved window geometry: {width}x{height}, sidebar: {sidebar_width}")
        except Exception as e:
            logger.error(f"Failed to save window state: {e}")

    def _apply_saved_connection_meta(self, nickname, meta):
        """Persist the dialog's WoL/tags metadata after a successful save."""
        if not nickname or not isinstance(meta, dict):
            return
        try:
            values = {}
            if "tags" in meta:
                values["tags"] = list(meta.get("tags") or [])
            values.update(
                {
                    key: meta[key]
                    for key in ("wol_mac", "wol_broadcast_ip", "wol_port")
                    if key in meta
                }
            )
            if values:
                self.client.update_connection_metadata(nickname, values)
        except Exception:
            # The connection itself saved; only the app-side metadata failed.
            # Surface it — a silent miss here means WoL/tags quietly vanish.
            logger.warning("Connection saved, but its WoL/tags metadata could not "
                           "be persisted for '%s'", nickname, exc_info=True)
            try:
                if getattr(self, 'toast_overlay', None):
                    self.toast_overlay.add_toast(Adw.Toast.new(
                        _("Saved, but tags/Wake-on-LAN settings could not be stored.")))
            except Exception:
                pass

    def _on_plugin_connection_saved(self, dialog, connection_data, complete=None,
                                    pending_meta=None):
        """Persist a plugin-protocol connection (JSON store, no ssh_config)."""
        def _done(ok):
            if callable(complete):
                complete(bool(ok))

        if dialog.is_editing and dialog.connection is not None:
            old_connection = dialog.connection
            original_nickname = old_connection.nickname
            if not self.plugin_connection_services.update_connection(
                old_connection, connection_data
            ):
                logger.error("Failed to update plugin connection")
                _done(False)
                return
            new_nickname = connection_data.get('nickname') or original_nickname
            # Meta goes under the new nickname BEFORE the rename migration so
            # the dialog's fields win the merge, and before rows re-read tags.
            self._apply_saved_connection_meta(new_nickname, pending_meta)
            if original_nickname != new_nickname:
                try:
                    self.group_manager.rename_connection(original_nickname, new_nickname)
                except Exception:
                    pass
            try:
                object.__setattr__(
                    old_connection,
                    "tags",
                    list(self.connection_manager.get_metadata(new_nickname).get("tags", [])),
                )
            except Exception:
                pass
            rows = self._rows_for_connection(old_connection)
            if rows:
                for row in rows:
                    row.update_display()
            else:
                self.rebuild_connection_list()
            logger.info(f"Updated plugin connection: {old_connection.nickname}")
            _done(True)
        else:
            if self.plugin_connection_services.add_connection_from_data(
                connection_data
            ):
                self._apply_saved_connection_meta(
                    connection_data.get('nickname'), pending_meta)
                self.rebuild_connection_list()
                logger.info(f"Created new plugin connection: {connection_data['nickname']}")
                _done(True)
            else:
                logger.error("Failed to save plugin connection")
                _done(False)

    def _daemon_ready(self) -> bool:
        """Return whether typed daemon transport is currently usable.

        This is a readiness check, never a backend-selection switch. SSH
        persistence and remote operations remain daemon-owned when it returns
        false; callers must report unavailable/recovery instead of choosing a
        local implementation.
        """
        return self.client is not None and self.client_bridge is not None

    @staticmethod
    def _normalise_daemon_editor_value(value):
        if isinstance(value, (list, tuple)):
            return tuple(
                MainWindow._normalise_daemon_editor_value(item)
                for item in value
            )
        if isinstance(value, dict):
            return tuple(
                sorted(
                    (
                        str(key),
                        MainWindow._normalise_daemon_editor_value(item),
                    )
                    for key, item in value.items()
                )
            )
        return value

    def prepare_connection_save_for_client(self, dialog, connection_data):
        """Reject daemon edits the v1 secret-free write DTO cannot represent."""

        if connection_data.get('protocol', 'ssh') != 'ssh':
            # Plugins remain owned by their local persistence backend even
            # while SSH connections are routed through the daemon.
            return None

        # An unchanged pre-filled password must never enter the mutation
        # request or trigger local secret storage after a daemon write.
        connection_data['password'] = ''

        return None

    def _save_connection_via_client(
        self,
        dialog,
        connection_data,
        complete_save,
        pending_meta=None,
    ) -> None:
        from .api import SshPilotError
        from .api.models.connections import (
            ConnectionMutationResult,
            EDITABLE_CONFIG_FIELDS,
            ForwardingRule,
            forwarding_rule_to_dict,
            UNSET,
        )
        from .api.models import CreateConnectionRequest, UpdateConnectionRequest

        bridge = self.client_bridge
        if bridge is None:
            complete_save(False)
            return

        # A later metadata/secret stage may be retried after the config
        # mutation has committed.  Keep the authoritative mutation result on
        # the dialog so retry never repeats create/update/rename/split.
        resumable = bool(getattr(dialog, '_resumable_save_active', False))
        checkpoint = (getattr(dialog, '_daemon_save_checkpoint', None)
                      if resumable else None)
        config_fingerprint = self._normalise_daemon_editor_value({
            key: value for key, value in connection_data.items()
            if not key.startswith('__')
        })
        metadata_fingerprint = self._normalise_daemon_editor_value(pending_meta or {})
        if checkpoint is not None and getattr(
            dialog, '_daemon_config_fingerprint', None
        ) != config_fingerprint:
            checkpoint = None
            for name in ('_daemon_metadata_saved',
                         '_daemon_metadata_fingerprint'):
                try:
                    delattr(dialog, name)
                except AttributeError:
                    pass

        # Daemon edits may never save against a pending/failed snapshot: the
        # dialog disables Save in that state, but the signal path (accelerator,
        # programmatic) is gated here too.
        is_daemon_editor = getattr(dialog, 'is_daemon_editor', None)
        if (
            callable(is_daemon_editor)
            and is_daemon_editor()
            and not getattr(dialog, '_daemon_editor_loaded', False)
        ):
            complete_save(False)
            return

        delta_available = '__changed_fields' in connection_data
        changed_fields = set(connection_data.pop('__changed_fields', ()) or ())
        # Sticky per save operation: a resumed/retried partial save must keep
        # the origin of the first attempt — the config commit flips
        # dialog.is_editing to True, so a retried create would otherwise look
        # like an edit and surface the reconnect offer for a new connection.
        was_editing = getattr(dialog, '_save_started_as_edit', None)
        if was_editing is None:
            was_editing = bool(getattr(dialog, 'is_editing', False))
            dialog._save_started_as_edit = was_editing

        def _build_config_patch():
            import re
            patch = {}

            for key in EDITABLE_CONFIG_FIELDS:
                if key in ("nickname", "hostname", "username", "port", "protocol"):
                    continue
                if key not in connection_data:
                    continue
                if changed_fields and key not in changed_fields:
                    continue

                if key == "forwarding_rules":
                    raw = connection_data.get(key, ())
                    if raw:
                        patch[key] = [
                            forwarding_rule_to_dict(r) if isinstance(r, ForwardingRule)
                            else r if isinstance(r, dict) else {}
                            for r in raw
                        ]
                    else:
                        patch[key] = []
                elif key in ("identity_files", "certificate_files", "proxy_jump", "aliases"):
                    val = connection_data.get(key, ())
                    if isinstance(val, str):
                        if key in ("proxy_jump", "aliases"):
                            patch[key] = [h.strip() for h in re.split(r'[\s,]+', val) if h.strip()]
                        else:
                            patch[key] = [f.strip() for f in re.split(r'[\r\n,]+', val) if f.strip()]
                    else:
                        patch[key] = [str(item).strip() for item in val if str(item).strip()] if val else []

                elif isinstance(connection_data.get(key), bool):
                    patch[key] = connection_data[key]
                else:
                    val = connection_data.get(key, '')
                    if val is not None:
                        patch[key] = val
            return patch

        try:
            if dialog.is_editing:
                connection_id = getattr(dialog, '_saved_connection_id', None)
                if not connection_id:
                    connection_id = connection_id_for(dialog.connection)
                if delta_available and not changed_fields:
                    # Nothing in the config changed, but a password/passphrase
                    # save still needs an authoritative connection_id — the
                    # secret worker cannot infer one from ``ok=True`` alone.
                    unchanged_result = ConnectionMutationResult(
                        connection_id=str(connection_id),
                        nickname=(connection_data.get('nickname')
                                  or getattr(dialog.connection, 'nickname', '')
                                  or str(connection_id)),
                        generation=int(getattr(dialog, '_daemon_generation', None) or 0),
                        changed=False,
                        changed_fields=(),
                    )
                    complete_save(True, unchanged_result)
                    if was_editing:
                        try:
                            new_connection = self._rebind_terminals_after_save(
                                dialog,
                                connection_id,
                                connection_data.get('nickname'),
                            )
                            self._offer_reconnect_after_edit_save(
                                dialog, new_connection
                            )
                        except Exception:
                            pass
                    return
                config_patch = _build_config_patch()

                # The editor generation is always the daemon snapshot loaded
                # when the dialog opened. There is no local generation source.
                current_generation = getattr(dialog, '_daemon_generation', None)

                # Pop split params — they drive a dedicated RPC, not
                # the normal update_connection path.
                split_from_group = connection_data.pop('__split_from_group', None)
                split_source = connection_data.pop('__split_source', None)
                split_original = connection_data.pop('__split_original_nickname', None)

                if split_from_group:
                    from .api.models.connections import SplitConnectionRequest
                    split_req = SplitConnectionRequest(
                        connection_id=connection_id,
                        original_host_token=split_original or connection_data.get('nickname', ''),
                        source_config_path=split_source or '',
                        nickname=connection_data.get('nickname'),
                        hostname=connection_data.get('hostname'),
                        username=connection_data.get('username'),
                        port=connection_data.get('port'),
                        config_patch=config_patch,
                        expected_generation=current_generation,
                    )
                    operation = lambda: self.client.split_connection(split_req)
                else:
                    request = UpdateConnectionRequest(
                        nickname=(connection_data.get('nickname')
                                  if (not delta_available or 'nickname' in changed_fields)
                                  else UNSET),
                        hostname=(connection_data.get('hostname')
                                  if (not delta_available or 'hostname' in changed_fields)
                                  else UNSET),
                        username=(connection_data.get('username')
                                  if (not delta_available or 'username' in changed_fields)
                                  else UNSET),
                        port=(connection_data.get('port')
                              if (not delta_available or 'port' in changed_fields)
                              else UNSET),
                        display_name=(connection_data.get('display_name')
                                      if (not delta_available or 'display_name' in changed_fields)
                                      else UNSET),
                        config_patch=config_patch,
                        expected_generation=current_generation,
                    )
                    operation = lambda: self.client.update_connection(
                        connection_id,
                        request,
                    )
            else:
                config_patch = _build_config_patch()
                request = CreateConnectionRequest(
                    nickname=connection_data.get('nickname', ''),
                    hostname=connection_data.get('hostname', ''),
                    username=connection_data.get('username', ''),
                    port=connection_data.get('port', 22),
                    protocol='ssh',
                    display_name=connection_data.get('display_name', ''),
                    config_patch=config_patch,
                )
                operation = lambda: self.client.create_connection(request)
        except (ValueError, TypeError) as build_err:
            logger.error(
                "Failed to build connection request: %s",
                build_err,
                exc_info=True,
            )
            complete_save(False)
            return

        def _success(_details):
            if self._is_quitting:
                complete_save(False, None)
                return
            
            try:
                new_conn_id = _details.connection_id
                new_generation = _details.generation
            except AttributeError:
                logger.error("Daemon mutation succeeded but returned invalid details")
                complete_save(False, None)
                return

            # The config mutation is committed. Transition immediately, before
            # metadata/secrets can fail, so every retry addresses the returned
            # identity and generation and can never repeat create/split.
            dialog.is_editing = True
            dialog._saved_connection_id = new_conn_id
            dialog._daemon_generation = int(new_generation)
            dialog._config_stage_complete = True
            dialog._save_mutation_result = _details
            dialog._daemon_save_checkpoint = _details
            dialog._daemon_config_fingerprint = config_fingerprint
            dialog.force_split_from_group = False

            def _finish_save_flow(meta_error=None):
                complete_save(True, _details, meta_error=meta_error)
                try:
                    is_daemon_editor = getattr(dialog, 'is_daemon_editor', None)
                    if callable(is_daemon_editor) and is_daemon_editor():
                        dialog._daemon_generation = int(new_generation)
                except Exception:
                    pass
                if was_editing:
                    try:
                        new_connection = self._rebind_terminals_after_save(
                            dialog,
                            new_conn_id,
                            connection_data.get('nickname'),
                        )
                        self._offer_reconnect_after_edit_save(
                            dialog, new_connection
                        )
                    except Exception:
                        pass
                
                try:
                    self.connection_manager.refresh()
                    self.rebuild_connection_list()
                except Exception as error:
                    logger.warning(
                        "Post-mutation connection refresh failed type=%s",
                        type(error).__name__,
                    )

            pending_meta_local = pending_meta or {}
            metadata_saved = getattr(dialog, '_daemon_metadata_saved', False)
            if metadata_saved and getattr(
                dialog, '_daemon_metadata_fingerprint', None
            ) == metadata_fingerprint:
                _finish_save_flow()
                return
            if pending_meta_local and new_conn_id:
                def _meta_success(result):
                    if result is not True:
                        _meta_failure(RuntimeError(
                            "Metadata persistence returned false"
                        ))
                        return
                    dialog._daemon_metadata_saved = True
                    dialog._daemon_metadata_fingerprint = metadata_fingerprint
                    _finish_save_flow()
                def _meta_failure(error):
                    logger.debug("Metadata update via daemon RPC failed: %s", error)
                    complete_save(
                        False, _details,
                        meta_error=_("Saved, but tags/Wake-on-LAN settings could not be stored."),
                    )
                try:
                    bridge.submit(
                        lambda: self.client.update_connection_metadata(new_conn_id, pending_meta_local),
                        on_success=_meta_success,
                        on_error=_meta_failure,
                    )
                    return
                except RuntimeError:
                    complete_save(False, _details, meta_error=_("Saved, but tags/Wake-on-LAN settings could not be stored."))
                    return
                    
            _finish_save_flow()

        def _failure(error):
            if isinstance(error, SshPilotError):
                logger.warning(
                    "Daemon connection mutation failed code=%s retryable=%s message=%s",
                    error.code.value,
                    error.retryable,
                    error.message,
                )
            else:
                logger.error(
                    "Daemon connection mutation failed type=%s",
                    type(error).__name__,
                    exc_info=error,
                )
            complete_save(False)

        if checkpoint is not None:
            _success(checkpoint)
            return

        def _checkpoint_success(details):
            _success(details)

        try:
            bridge.submit(
                operation,
                on_success=_checkpoint_success,
                on_error=_failure,
            )
        except RuntimeError:
            complete_save(False)

    def _rebind_terminals_after_save(
        self,
        dialog,
        connection_id,
        nickname,
    ) -> Optional["ConnectionSummary"]:
        """Refresh the presentation store and atomically re-key every terminal
        that was opened with the pre-save snapshot to the authoritative
        post-save ConnectionSummary.

        Connection ids are nickname-derived, so after a rename the daemon only
        knows the new alias while ``terminal.connection`` still carries the old
        one.  Re-keying the terminal state (``terminal.connection`` and the
        ``active_terminals`` / ``terminal_to_connection`` /
        ``connection_to_terminals`` maps) at save time keeps every later use of
        the terminal — reconnection, tab focus, sidebar status — on the exact
        identity the daemon holds, instead of stashing one post-save id.

        Returns the authoritative post-save ConnectionSummary, or None when it
        cannot be resolved (in which case terminal state is left untouched and
        the caller offers no reconnection).
        """
        try:
            self.connection_manager.refresh()
        except Exception as error:
            logger.warning(
                "Post-save connection refresh failed type=%s",
                type(error).__name__,
            )
            return None
        saved_id = str(connection_id) if connection_id else ''
        if not saved_id:
            return None
        try:
            new_connection = self.connection_manager.get_connection_by_id(saved_id)
        except Exception:
            new_connection = None
        if new_connection is None:
            logger.warning(
                "Cannot resolve authoritative post-save connection %r", saved_id
            )
            return None

        def _identity_ids(connection):
            return {
                str(value)
                for value in (
                    getattr(connection, 'id', None),
                    getattr(connection, 'connection_id', None),
                    getattr(connection, 'uuid', None),
                )
                if value is not None
            }

        original = getattr(dialog, 'connection', None)
        original_ids = _identity_ids(original) if original is not None else set()
        original_nickname = (
            str(getattr(original, 'nickname', '') or '')
            if original is not None else ''
        )
        if not (original_ids or original_nickname):
            original_ids = {saved_id}
            original_nickname = str(nickname or '')

        def _matches_original(candidate):
            if candidate is None:
                return False
            if original is not None and candidate is original:
                return True
            candidate_ids = _identity_ids(candidate)
            if original_ids and candidate_ids & original_ids:
                return True
            if original_nickname and (
                str(getattr(candidate, 'nickname', '') or '') == original_nickname
            ):
                return True
            return False

        targets = []
        seen = set()
        for old_key, terminals in tuple(self.connection_to_terminals.items()):
            if not _matches_original(old_key):
                continue
            for terminal in terminals:
                if id(terminal) not in seen:
                    targets.append((terminal, old_key))
                    seen.add(id(terminal))
        for old_key, terminal in tuple(self.active_terminals.items()):
            if _matches_original(old_key) and id(terminal) not in seen:
                targets.append((terminal, old_key))
                seen.add(id(terminal))
        for terminal, old_key in tuple(self.terminal_to_connection.items()):
            if _matches_original(old_key) and id(terminal) not in seen:
                targets.append((terminal, old_key))
                seen.add(id(terminal))

        for terminal, _old_key in targets:
            try:
                terminal.connection = new_connection
            except Exception:
                pass
        for terminal, old_key in targets:
            terms = self.connection_to_terminals.get(old_key)
            if terms and terminal in terms:
                terms.remove(terminal)
                if not terms:
                    del self.connection_to_terminals[old_key]
            self.connection_to_terminals.setdefault(new_connection, []).append(terminal)
            self.terminal_to_connection[terminal] = new_connection
            if self.active_terminals.get(old_key) is terminal:
                del self.active_terminals[old_key]
                self.active_terminals[new_connection] = terminal

        return new_connection

    def _offer_reconnect_after_edit_save(self, dialog, connection) -> None:
        """Offer to reconnect an active terminal after a successful edit save.

        ``connection`` is the authoritative post-save ConnectionSummary: the
        save flow resolved it from the refreshed presentation store and
        atomically re-keyed every terminal opened with the pre-save snapshot to
        it, so reconnecting reopens the session under exactly the alias the
        daemon holds.  The daemon save path emits ``connection-updated`` to
        every open terminal (which logs "waiting for user confirmation to
        reconnect") but, unlike the local save path, never surfaces a prompt —
        present the same reconnect confirmation here.

        Idempotent per save operation: a resumed or retried partial save that
        completes the same user Save never offers the prompt a second time.
        """
        if self._is_quitting:
            return
        if getattr(dialog, '_reconnect_prompt_offered', False):
            return
        if connection is None or connection not in self.active_terminals:
            return
        dialog._reconnect_prompt_offered = True
        self.terminal_manager.prompt_reconnect(connection)

    def on_connection_saved(self, dialog, connection_data, pending_meta=None,
                            secret_plan=None, save_completion=None):
        """Handle connection saved from dialog"""
        claim = getattr(save_completion, 'claim', None)
        if callable(claim):
            claim()
        # Compatibility for third-party/plugin emitters using the pre-v2
        # one-object signal.  Values are removed before any persistence call.
        if save_completion is None:
            save_completion = connection_data.pop('__save_completion', None)
        if pending_meta is None:
            pending_meta = connection_data.pop('__meta', None)
        # The signal contract keeps these objects separate by construction.
        # Assert at the persistence boundary as defence in depth.
        if '__secret_plan' in connection_data:
            logger.error("Rejected connection payload containing a secret plan")
            if callable(save_completion):
                save_completion(False)
            return

        def _complete_save(ok, result=None, meta_error=None):
            if callable(save_completion):
                try:
                    save_completion(bool(ok), result, meta_error)
                except TypeError:
                    # Compatibility only for legacy third-party signal handlers;
                    # ConnectionDialog.SaveRequest has one fixed signature.
                    save_completion(bool(ok), result)
            has_secret_work = bool(
                secret_plan and (
                    secret_plan.get('password_changed')
                    or secret_plan.get('passphrase_operations')
                )
            )
            if ok and not meta_error and not has_secret_work:
                for name in ('_daemon_save_checkpoint', '_daemon_metadata_saved',
                             '_daemon_config_fingerprint',
                             '_daemon_metadata_fingerprint',
                             '_resumable_save_active',
                             '_reconnect_prompt_offered',
                             '_save_started_as_edit'):
                    try:
                        delattr(dialog, name)
                    except AttributeError:
                        pass

        try:
            if connection_data.get('protocol', 'ssh') != 'ssh':
                self._on_plugin_connection_saved(
                    dialog, connection_data, _complete_save, pending_meta)
                return
            dialog._resumable_save_active = True
            self._save_connection_via_client(
                dialog,
                connection_data,
                _complete_save,
                pending_meta=pending_meta,
            )
            return

        except Exception as e:
            logger.error(f"Failed to save connection: {e}")
            _complete_save(False)
            self._error_dialog(_("Failed to save connection"), str(e))

    def _warn_if_effective_config_differs(self, connection, connection_data):
        """After a save, warn if global SSH config overrides/adds values for this
        connection. Informational only — offers a diff view and a shortcut to the
        SSH config editor. Best-effort: any failure is swallowed silently.

        The daemon performs resolution on its serialized backend path; GTK only
        presents the generation-tagged comparison result.
        """
        del connection_data
        host = getattr(connection, "nickname", "") or ""
        if not host or not self._daemon_ready():
            return
        try:
            self.client_bridge.submit(
                lambda: self.client.get_effective_config(connection_id_for(connection)),
                on_success=lambda result: GLib.idle_add(
                    self._present_effective_config_warning,
                    host,
                    {
                        "has_diff": result.has_diff,
                        "changes": list(result.changes),
                        "own": list(result.own),
                        "full": list(result.full),
                    },
                ),
                on_error=lambda error: logger.debug(
                    "daemon effective-config warning unavailable: %s", error
                ),
            )
        except Exception:
            logger.debug("Could not request daemon effective-config comparison", exc_info=True)

    def show_effective_config_for_connection(self, connection) -> None:
        """Open the effective-config viewer from a daemon comparison result."""
        if not self._daemon_ready():
            self._show_daemon_unavailable_dialog()
            return
        host = getattr(connection, "nickname", "") or ""
        try:
            self.client_bridge.submit(
                lambda: self.client.get_effective_config(connection_id_for(connection)),
                on_success=lambda result: self._present_effective_config_dialog(host, result),
                on_error=self._on_effective_config_error,
            )
        except Exception as error:
            self._on_effective_config_error(error)

    def _present_effective_config_dialog(self, host, result) -> None:
        if not result.available:
            logger.info("Effective-config comparison unavailable for %s", host)
            self._error_dialog(
                _("Effective configuration unavailable"),
                _("The daemon could not resolve or compare this connection's effective SSH configuration."),
            )
            return
        try:
            from .effective_config_dialog import EffectiveConfigDialog
            EffectiveConfigDialog.for_result(
                self,
                host,
                {
                    "has_diff": result.has_diff,
                    "changes": list(result.changes),
                    "own": list(result.own),
                    "full": list(result.full),
                },
            )
        except Exception:
            logger.debug("Failed to open effective-config diff", exc_info=True)

    def _present_effective_config_warning(self, host, result):
        """Show the global-config warning dialog on the GTK main thread."""
        try:
            from gettext import gettext as _

            if not result or not result.get('has_diff'):
                return False

            dialog = Adw.MessageDialog(
                transient_for=self,
                modal=True,
                heading=_("Global SSH config may change this connection"),
                body=_(
                    "Some of the values you set may be overridden or added by "
                    "your global SSH configuration (for example a 'Host *' "
                    "block or an included file). SSH will use the effective "
                    "values, not only what you entered here."
                ),
            )
            dialog.add_response('dismiss', _('Dismiss'))
            dialog.add_response('view', _('View differences…'))
            dialog.set_response_appearance('view', Adw.ResponseAppearance.SUGGESTED)
            dialog.set_default_response('view')

            def _on_response(dlg, response):
                if response == 'view':
                    try:
                        from .effective_config_dialog import EffectiveConfigDialog
                        EffectiveConfigDialog.for_result(self, host, result)
                    except Exception:
                        logger.debug("Failed to open effective-config diff", exc_info=True)

            dialog.connect('response', _on_response)
            dialog.present()
        except Exception:
            logger.debug("Failed to present effective-config warning", exc_info=True)
        return False

    def _rebuild_connections_list(self):
        """Rebuild the sidebar connections list from manager state, avoiding duplicates."""
        try:
            self.rebuild_connection_list()
        except Exception:
            pass
