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

from __future__ import annotations

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

if TYPE_CHECKING:
    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_manager import ConnectionManager, Connection, ConnectionState
from .config import Config
from .key_manager import KeyManager
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_dialogs import (
    WindowConfigDialogsMixin,
    resolve_topmost_prompt_parent,
    resolve_app_modal_parent,  # noqa: F401  re-exported for tests / other modules
    present_for_modal_dialog,
    show_ssh_password_dialog,
    _show_password_passphrase_dialog,
)
from . import shutdown
from .search_utils import connection_matches
from .shortcut_utils import get_primary_modifier_label
from .platform_utils import (
    get_config_dir,
    get_default_terminal_command,
    get_user_preferred_terminal,
    find_any_terminal,
    open_system_terminal,
)
from .plugins.api import Capability
from .plugins.registry import capabilities_for
logger = logging.getLogger(__name__)


_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


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


@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._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
        effective_isolated = isolated or bool(self.config.get_setting('ssh.use_isolated_config', False))
        key_dir = Path(get_config_dir()) if effective_isolated else None
        self.connection_manager = ConnectionManager(self.config, isolated_mode=effective_isolated)

        # 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.connection_manager)
                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.connection_manager,
                    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.key_manager = KeyManager(key_dir)
        self.group_manager = GroupManager(self.config)
        self.session_manager = SessionManager(self.config)
        
        # 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._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()
        self.setup_connections()
        self.setup_signals()

        # 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()

        logger.info("Main window initialized")

    @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:
                    page = self.tab_view.get_selected_page() if hasattr(self, 'tab_view') else None
                    if page is None:
                        return
                    terminal_widget = page.get_child()
                    if terminal_widget is None:
                        return
                    if hasattr(terminal_widget, 'vte') and hasattr(terminal_widget.vte, 'grab_focus'):
                        terminal_widget.vte.grab_focus()
                    elif hasattr(terminal_widget, 'grab_focus'):
                        terminal_widget.grab_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 _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_export_diagnostics_done(self, ok, error, path):
        if ok:
            dialog = Adw.MessageDialog(
                transient_for=self, modal=True,
                heading=_("Diagnostics Exported"),
                body=_("Saved to:\n{}\n\nSecrets are redacted. Your saved connections "
                       "and SSH config are not included; a maintainer can request them "
                       "separately if needed.").format(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{}").format(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()
        
        # 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}")

        # One-time operation mode chooser
        try:
            if self._should_prompt_operation_mode():
                GLib.idle_add(self._show_operation_mode_dialog)
        except Exception as e:
            logger.debug(f"Failed to schedule operation mode prompt: {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 _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.
        self.header_bar.set_title_widget(Gtk.Box())

        # 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']

        def _accel_label(accel):
            try:
                ok, keyval, mods = Gtk.accelerator_parse(accel)
                if ok and keyval:
                    return Gtk.accelerator_get_label(keyval, mods)
            except Exception:
                pass
            return accel

        accel_labels = ', '.join(_accel_label(a) for a in sidebar_accels)
        self.sidebar_toggle_button.set_tooltip_text(f'Hide Sidebar ({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(
            f'Open Local Terminal ({get_primary_modifier_label()}+Shift+T)'
        )
        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()

        # Add split view to main container
        main_box.append(self.split_view)

        # 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 _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 = (
            ('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
        
        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()

    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}")


    
    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]) -> Optional[Connection]:
        """Duplicate an existing connection, persist it, and select the new entry."""
        if connection is None:
            return None
        try:
            duplicated = self.connection_manager.duplicate_connection(connection, self.group_manager)
        except Exception as error:
            self._show_duplicate_connection_error(connection, error)
            logger.error(f"Failed to duplicate connection: {error}", exc_info=True)
            return None

        self.rebuild_connection_list()
        row = self._primary_row_for_connection(duplicated)
        if row is not None:
            self._select_only_row(row)
            try:
                self.connection_list.scroll_to_row(row)
            except Exception:
                pass
            try:
                self.connection_list.grab_focus()
            except Exception:
                pass
        return duplicated

    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(self.config.is_pinned(c.nickname) for c in conns)
            for c in conns:
                if all_pinned:
                    self.config.unpin_connection(c.nickname)
                else:
                    self.config.pin_connection(c.nickname)
            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:
                self.toast_overlay.add_toast(Adw.Toast.new(msg))
            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)

        # Disable Adw.TabView's built-in Alt+1..9 / Alt+0 tab-selection shortcuts.
        # They otherwise shadow split-view "Alt+N focus pane" whenever more than
        # one tab is open (the tab_view is an ancestor of the split-view tab, so
        # its handler runs first). Tab switching is via Ctrl+PageUp/Down.
        try:
            self.tab_view.set_shortcuts(
                self.tab_view.get_shortcuts()
                & ~Adw.TabViewShortcuts.ALT_DIGITS
                & ~Adw.TabViewShortcuts.ALT_ZERO
            )
        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)

            def _on_tab_step(step: int):
                def _handler(widget, *args):
                    try:
                        self._select_tab_relative(step)
                    except Exception:
                        pass
                    return True

                return _handler

            tab_nav.add_shortcut(Gtk.Shortcut.new(
                Gtk.ShortcutTrigger.parse_string('<Alt>Right'),
                Gtk.CallbackAction.new(_on_tab_step(1))
            ))
            tab_nav.add_shortcut(Gtk.Shortcut.new(
                Gtk.ShortcutTrigger.parse_string('<Alt>Left'),
                Gtk.CallbackAction.new(_on_tab_step(-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)
        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_reveal_child(False)
        self.broadcast_banner.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
        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)

        if HAS_OVERLAY_SPLIT:
            content_box = Adw.ToolbarView()
            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()
            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 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()
        
        # 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:
                conn.tags = self.config.get_connection_tags(conn.nickname)
            except Exception:
                conn.tags = []
        connections_dict = {conn.nickname: conn for conn in connections}
        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)
        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._ungrouped_area_row = None
            # Restore scroll position
            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))
            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_nickname)

            matches = [
                c for c in connections
                if connection_matches(c, search_text)
                and c.nickname not in displayed_connections
            ]
            for conn in sorted(matches, key=lambda c: c.nickname.lower()):
                self.add_connection_row(conn)
                displayed_connections.add(conn.nickname)
            self._ungrouped_area_row = None
            # Restore scroll position
            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))
            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.nickname for conn in connections
            if not self.group_manager.get_connection_groups(conn.nickname)
        ]

        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)


        # Store reference to ungrouped area (hidden by default)
        self._ungrouped_area_row = None
        
        # Restore scroll position
        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 _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"""
        row = ConnectionRow(
            connection,
            self.group_manager,
            self.config,
            file_manager_callback=self._open_manage_files_for_connection,
            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_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)
        # 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('connection-status-changed', self.on_connection_status_changed)
        
        # Config signals
        self.config.connect('setting-changed', self.on_setting_changed)


    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()
        GLib.idle_add(self._focus_connection_list_first_row)

        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 _focus_connection_list_first_row(self):
        """Focus the first row of the connection list so arrow-key navigation works immediately."""
        try:
            if not hasattr(self, 'connection_list') or self.connection_list is None:
                return False
            if not self.connection_list.get_parent():
                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):
                try:
                    selected_rows = list(self.connection_list.get_selected_rows())
                except Exception:
                    sel = self.connection_list.get_selected_row()
                    selected_rows = [sel] if sel else []
                if not selected_rows 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 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)

                # Ensure a row is selected before focusing
                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 []
                logger.debug(f"Focus connection list - current selection count: {len(selected_rows)}")
                target_row = selected_rows[0] if selected_rows else None
                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(
                    f"Switched to connection list — ↑/↓ navigate, Enter open, {get_primary_modifier_label()}+Enter new tab"
                )
                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 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

            # If the sidebar is hidden, reveal 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)

            was_visible = True
            if hasattr(self, 'search_container') and self.search_container:
                was_visible = self.search_container.get_visible()
                if not was_visible:
                    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 focus_search_entry(self):
        """Toggle search on/off and show appropriate toast notification."""
        try:
            if hasattr(self, 'search_entry') and self.search_entry:
                # 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)
                
                # Toggle search container visibility
                if hasattr(self, 'search_container') and self.search_container:
                    is_visible = self.search_container.get_visible()
                    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()
                        
                        # 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(
                            f"Search hidden — {get_primary_modifier_label()}+F to search again"
                        )
                        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,
    ):
        """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:
            try:
                self.connection_manager.load_ssh_config()
                refreshed = self.connection_manager.find_connection_by_nickname(connection.nickname)
                if refreshed:
                    connection = refreshed
            except Exception:
                pass

        if connection is not None and not skip_group_warning:
            block_info = None
            try:
                source_path = split_group_source or getattr(connection, 'source', None)
                block_info = self.connection_manager.get_host_block_details(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,
        )
        dialog.connect('connection-saved', self.on_connection_saved)
        dialog.present()


    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):
        try:
            # Single editor for the SSH config: the GtkSourceView-backed editor,
            # which self-degrades to a plain TextView if GtkSourceView is absent.
            from .text_editor import RemoteFileEditorWindow
            from .ssh_config_utils import validate_ssh_config_text

            config_path = getattr(self.connection_manager, 'ssh_config_path', None)
            if not config_path:
                from .platform_utils import get_ssh_dir
                config_path = os.path.join(get_ssh_dir(), 'config')

            config_path = os.path.abspath(os.path.expanduser(config_path))
            config_name = os.path.basename(config_path)

            # Set up file monitoring to detect when the file is saved
            file_modified_time = 0.0
            if os.path.exists(config_path):
                try:
                    file_modified_time = os.path.getmtime(config_path)
                except Exception:
                    pass

            def _reload_ssh_config():
                """Reload SSH config and refresh connection list, preserving group membership"""
                try:
                    # Capture current connections and their group memberships before reload
                    old_connections = {conn.nickname: conn for conn in self.connection_manager.get_connections()}
                    old_group_memberships = {}
                    for nickname in old_connections.keys():
                        group_id = self.group_manager.get_connection_group(nickname)
                        if group_id:
                            old_group_memberships[nickname] = group_id

                    # Reload SSH config (this creates new Connection objects)
                    self.connection_manager.load_ssh_config()
                    new_connections = {conn.nickname: conn for conn in self.connection_manager.get_connections()}

                    # Detect nickname changes by matching connections on hostname/username/port
                    # This handles the case where Host value changes but connection is otherwise the same
                    for old_nickname, old_conn in old_connections.items():
                        if old_nickname in old_group_memberships:
                            # This connection was in a group, try to find its new nickname
                            group_id = old_group_memberships[old_nickname]

                            # Try to find matching connection by hostname/username/port
                            matching_new_nickname = None
                            for new_nickname, new_conn in new_connections.items():
                                if (new_conn.hostname == old_conn.hostname and
                                    new_conn.username == old_conn.username and
                                    new_conn.port == old_conn.port):
                                    matching_new_nickname = new_nickname
                                    break

                            # If we found a match and nickname changed, update group membership
                            if matching_new_nickname and matching_new_nickname != old_nickname:
                                try:
                                    self.group_manager.rename_connection(old_nickname, matching_new_nickname)
                                    logger.info(f"Preserved group membership: '{old_nickname}' -> '{matching_new_nickname}' in group {group_id}")
                                except Exception as e:
                                    logger.error(f"Failed to preserve group membership for renamed connection: {e}")
                            # If old nickname still exists, group membership is already preserved

                    self.rebuild_connection_list()
                    logger.info("SSH config reloaded after file save")
                except Exception as e:
                    logger.error(f"Failed to refresh connections after SSH config save: {e}")

            # Monitor file for changes
            def _on_file_changed(monitor, file, other_file, event_type):
                """Handle file system changes to detect saves"""
                if event_type in (Gio.FileMonitorEvent.CHANGED, Gio.FileMonitorEvent.CHANGES_DONE_HINT):
                    try:
                        if os.path.exists(config_path):
                            new_mtime = os.path.getmtime(config_path)
                            nonlocal file_modified_time
                            if new_mtime > file_modified_time:
                                file_modified_time = new_mtime
                                # Reload after a short delay to ensure file is fully written
                                GLib.timeout_add(100, _reload_ssh_config)
                    except Exception as e:
                        logger.debug(f"Error checking file modification time: {e}")

            # Create file monitor
            try:
                gfile = Gio.File.new_for_path(config_path)
                file_monitor = gfile.monitor_file(Gio.FileMonitorFlags.WATCH_MOVES, None)
                file_monitor.connect("changed", _on_file_changed)
                # Store monitor reference to keep it alive
                if not hasattr(self, '_ssh_config_monitors'):
                    self._ssh_config_monitors = []
                self._ssh_config_monitors.append(file_monitor)
            except Exception as e:
                logger.debug(f"Failed to set up file monitoring for SSH config: {e}")
                file_monitor = None

            editor = RemoteFileEditorWindow(
                parent=self,
                file_path=config_path,
                file_name=config_name,
                is_local=True,
                sftp_manager=None,
                file_manager_window=None,
                pre_save_validator=validate_ssh_config_text,
                language_id="sshconfig",
                show_outline=True,
            )

            # Set custom title for SSH config editor (the path shows as the
            # header subtitle automatically).
            editor.set_title(_("Edit SSH Config"))  # window/taskbar title
            if hasattr(editor, 'set_editor_title'):
                editor.set_editor_title(_("SSH Config"))

            # Also reload when the editor closes (fallback)
            def _on_editor_close_request(window):
                # Clean up file monitor
                if hasattr(self, '_ssh_config_monitors') and file_monitor:
                    try:
                        if file_monitor in self._ssh_config_monitors:
                            self._ssh_config_monitors.remove(file_monitor)
                        file_monitor.cancel()
                    except Exception:
                        pass
                # Final reload check when closing
                try:
                    if os.path.exists(config_path):
                        new_mtime = os.path.getmtime(config_path)
                        if new_mtime > file_modified_time:
                            _reload_ssh_config()
                except Exception:
                    pass
                return False  # Allow window to close

            editor.connect("close-request", _on_editor_close_request)
            editor.present()
        except Exception as e:
            logger.error(f"Failed to open SSH config editor: {e}")

    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
        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 _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_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.
        """
        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:
                    key_path = self.key_manager.ssh_dir / key_name
                    if key_path.exists():
                        entry.add_css_class("error")
                        entry.set_title(_("Key file name (already exists)"))
                    else:
                        entry.remove_css_class("error")
                        entry.set_title(_("Key file name"))
                else:
                    entry.remove_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)

            pass_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
            pass1 = Gtk.PasswordEntry()
            pass1.set_property("placeholder-text", _("Passphrase"))
            pass2 = Gtk.PasswordEntry()
            pass2.set_property("placeholder-text", _("Confirm passphrase"))
            pass_box.append(pass1); pass_box.append(pass2)
            pass_box.set_visible(False)


            def on_pass_toggle(*_):
                pass_box.set_visible(pass_switch.get_active())
            pass_switch.connect("notify::active", on_pass_toggle)

            # 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(pass_box)
            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):
                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 '.'"))

                    # Check if key already exists before attempting generation
                    key_path = self.key_manager.ssh_dir / key_name
                    if key_path.exists():
                        # Suggest alternative names
                        base_name = key_name
                        counter = 1
                        while (self.key_manager.ssh_dir / f"{base_name}_{counter}").exists():
                            counter += 1
                        suggestion = f"{base_name}_{counter}"
                        
                        raise ValueError(_("A key named '{}' already exists. Try '{}' instead.").format(key_name, suggestion))

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

                    passphrase = None
                    if pass_switch.get_active():
                        p1 = pass1.get_text() or ""
                        p2 = pass2.get_text() or ""
                        logger.debug(f"SshCopyIdWindow: Passphrase lengths - p1: {len(p1)}, p2: {len(p2)}")
                        if p1 != p2:
                            logger.debug("SshCopyIdWindow: Passphrases do not match")
                            raise ValueError("Passphrases do not match")
                        passphrase = p1
                        logger.info("SshCopyIdWindow: Passphrase enabled")
                        logger.debug("SshCopyIdWindow: Passphrase validation successful")

                    logger.info(f"SshCopyIdWindow: Calling key_manager.generate_key with name='{key_name}', type='{kt}'")
                    logger.debug(f"SshCopyIdWindow: Key generation parameters - name='{key_name}', type='{kt}', "
                               f"size={3072 if kt == 'rsa' else 0}, passphrase={'<set>' if passphrase else 'None'}")
                    
                    new_key = self._km.generate_key(
                        key_name=key_name,
                        key_type=kt,
                        key_size=3072 if kt == "rsa" else 0,
                        comment=None,
                        passphrase=passphrase,
                    )
                    
                    if not new_key:
                        logger.debug("SshCopyIdWindow: Key generation returned None")
                        raise RuntimeError("Key generation failed. See logs for details.")

                    logger.info(f"SshCopyIdWindow: Key generated successfully: {new_key.private_path}")
                    logger.debug(f"SshCopyIdWindow: Generated key details - private_path='{new_key.private_path}', "
                               f"public_path='{new_key.public_path}'")
                    
                    # Ensure the key files are properly written and accessible
                    import time
                    logger.debug("SshCopyIdWindow: Waiting 0.5s for files to be written")
                    time.sleep(0.5)  # Small delay to ensure files are written
                    
                    # Verify the key files exist and are accessible
                    private_exists = os.path.exists(new_key.private_path)
                    public_exists = os.path.exists(new_key.public_path)
                    logger.debug(f"SshCopyIdWindow: File existence check - private: {private_exists}, public: {public_exists}")
                    
                    if not private_exists:
                        logger.debug(f"SshCopyIdWindow: Private key file missing: {new_key.private_path}")
                        raise RuntimeError(f"Private key file not found: {new_key.private_path}")
                    if not public_exists:
                        logger.debug(f"SshCopyIdWindow: Public key file missing: {new_key.public_path}")
                        raise RuntimeError(f"Public key file not found: {new_key.public_path}")
                    
                    logger.info(f"SshCopyIdWindow: Key files verified, starting ssh-copy-id")
                    logger.debug("SshCopyIdWindow: All key files verified successfully")
                    
                    # Run your terminal ssh-copy-id flow
                    logger.debug("SshCopyIdWindow: Calling _show_ssh_copy_id_terminal_using_main_widget()")
                    self._parent._show_ssh_copy_id_terminal_using_main_widget(self._conn, new_key)
                    logger.debug("SshCopyIdWindow: Terminal window launched, closing dialog")
                    self.close()

                except Exception as e:
                    logger.error(f"SshCopyIdWindow: Generate and copy failed: {e}")
                    logger.debug(f"SshCopyIdWindow: Exception details: {type(e).__name__}: {e!s}")
                    self._error("Generate & Copy failed",
                                "Could not generate a new key and copy it to the server.",
                                str(e))

            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 Capability.KEY_DEPLOYMENT not in capabilities_for(connection):
            logger.debug("ssh-copy-id unavailable: protocol %r has no key deployment",
                         getattr(connection, 'protocol', 'ssh'))
            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=f"Could not open the Copy Key window.\n\n{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 child has no .vte attribute of its own).
            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)"""
        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.connection_toolbar.set_visible(True)
            self.group_toolbar.set_visible(False)

            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 Capability.KEY_DEPLOYMENT in caps
                )
            if hasattr(self, 'scp_button'):
                self.scp_button.set_sensitive(
                    not multiple_connections and Capability.FILE_TRANSFER in caps
                )
            self.manage_files_button.set_sensitive(
                not multiple_connections
                and Capability.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 rides build_native_command(), an SSH-only path.
                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.connection_toolbar.set_visible(False)
            self.group_toolbar.set_visible(True)

            # 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.connection_toolbar.set_visible(False)
            self.group_toolbar.set_visible(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(False)
            self.delete_group_button.set_sensitive(False)

    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(
                    f'Hide Sidebar (F9, {get_primary_modifier_label()}+B)'
                )
            else:
                icon_utils.set_button_icon(button, 'sidebar-show-symbolic')
                button.set_tooltip_text(
                    f'Show Sidebar (F9, {get_primary_modifier_label()}+B)'
                )
            
            # 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 _apply_sidebar_visible(self, visible: bool) -> None:
        """Programmatically show/hide the sidebar and keep the toggle button in
        sync (used by the behavior hooks)."""
        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 _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

            pending = iter(connection for connection in connections if connection)
            self._deleting_connections_batch = True

            def _delete_next_connection():
                try:
                    connection = next(pending)
                except StopIteration:
                    self._deleting_connections_batch = False
                    self.group_manager._save_groups()
                    try:
                        self.connection_manager.load_ssh_config()
                    except Exception:
                        logger.exception(
                            "Failed to reload connections after batch deletion"
                        )
                    return False

                try:
                    if response == 'close_remove':
                        self._disconnect_connection_terminals(connection)
                    self.connection_manager.remove_connection(
                        connection,
                        reload_config=False,
                    )
                except Exception:
                    logger.exception(
                        "Failed to delete connection %s",
                        getattr(connection, 'nickname', ''),
                    )

                # Give GTK a chance to process redraws and window-manager
                # responsiveness checks between potentially blocking keyring
                # and filesystem operations.
                GLib.idle_add(_delete_next_connection)
                return False

            GLib.idle_add(_delete_next_connection)
        except Exception as e:
            self._deleting_connections_batch = False
            logger.error(f"Failed to delete connections: {e}")

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

    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.nickname, None)
        if connection.nickname in self.group_manager.root_connections:
            self.group_manager.root_connections.remove(connection.nickname)
        for group in self.group_manager.groups.values():
            if connection.nickname in group.get('connections', []):
                group['connections'] = [
                    n for n in group['connections'] if n != connection.nickname
                ]
        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()
        }
        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:
            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 all local terminals are idle, safe to close
        return False  # Allow close

    def _askpass_dialog_parent(self):
        """Topmost app window to parent a routed askpass prompt on.

        A routed prompt is an ``Adw.Dialog`` sheet; presenting it on the main
        window hides it behind a modal secondary window (e.g. the SCP browse
        ``Adw.Window``), which is what pops these prompts in the first place.
        A modal transient does not reliably become the active window on
        Wayland, so target the modal window explicitly rather than trusting
        focus. See :func:`resolve_topmost_prompt_parent`.
        """
        try:
            app = self.get_application() or Gtk.Application.get_default()
            if app is None:
                return self
            active = None
            try:
                active = app.get_active_window()
            except Exception:
                active = None
            return resolve_topmost_prompt_parent(
                list(app.get_windows()), active, self
            )
        except Exception:
            return self

    def prompt_ssh_passphrase(self, key_path: str, prompt: str = "") -> "str | None":
        """Show the SSH key passphrase prompt as a modal child of the main window.

        Invoked (on the GTK main thread) by the askpass IPC server so the prompt
        renders above the main window instead of as a stray top-level helper
        window that can hide behind it on Wayland. Returns the passphrase, or
        None if the user cancelled. Blocks until the dialog is dismissed.
        """
        parent = self._askpass_dialog_parent()
        present_for_modal_dialog(parent)
        return _show_password_passphrase_dialog(
            parent,
            prompt_type="passphrase",
            key_path=key_path or None,
        )

    def prompt_ssh_challenge(self, prompt: str = "") -> "str | None":
        """Show an MFA / verification-code prompt as a modal child of the main window.

        Used when askpass receives an interactive keyboard-interactive prompt
        (OTP, PIN, etc.). OpenSSH with ``SSH_ASKPASS_REQUIRE=prefer`` does not
        fall back to the TTY when askpass declines, so the user must answer
        here. Never stores the response.
        """
        parent = self._askpass_dialog_parent()
        present_for_modal_dialog(parent)
        body = (prompt or "").strip() or _("Please enter the verification code:")
        return _show_password_passphrase_dialog(
            parent,
            prompt_type="challenge",
            display_name=body,
            heading=_("Authentication Required"),
            body=body,
        )

    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 — the askpass server uses it to dismiss
        the reminder as soon as the touch completes (helper socket EOF).
        """
        parent = self._askpass_dialog_parent()
        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._askpass_dialog_parent()
        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._askpass_dialog_parent(),
            display_name=display_name,
            host=host,
            username=username,
            connection_manager=getattr(self, "connection_manager", None),
            heading=heading,
            body=body,
        )

    def show_quit_confirmation_dialog(self):
        """Show confirmation dialog when quitting with active connections"""
        # Best-effort raise of the main window. On X11 / for a minimized window
        # this brings it forward; on Wayland a background app can't force a raise
        # without an activation token, so this only flags attention there. The
        # confirmation itself is a real top-level Gtk.AlertDialog (below) so it is
        # surfaced by the compositor regardless.
        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}")

        # Categorize connected terminals
        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))
                    # Categorize terminals
                    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)
        
        # Determine dialog content based on terminal types
        if ssh_terminals:
            # SSH terminals present - use original messaging
            if active_count == 1:
                message = f"You have 1 open terminal tab."
                detail = "Closing the application will disconnect this connection."
            else:
                message = f"You have {active_count} open terminal tabs."
                detail = f"Closing the application will disconnect all connections."
        else:
            # Only local terminals with active jobs
            if active_count == 1:
                message = f"You have 1 local terminal with an active job."
                detail = "Closing the application will terminate the running process."
            else:
                message = f"You have {active_count} local terminals with active jobs."
                detail = f"Closing the application will terminate all running processes."

        # Use Gtk.AlertDialog: it builds its own top-level window, so the
        # compositor maps it even when the main window is in the background
        # (an in-window Adw.AlertDialog is drawn inside the background surface
        # and stays unreachable on Wayland).
        dialog = Gtk.AlertDialog()
        dialog.set_modal(True)
        dialog.set_message("Quit SSH Pilot?")
        dialog.set_detail(f"{message}\n\n{detail}")
        dialog.set_buttons(['Cancel', 'Quit Anyway'])
        dialog.set_cancel_button(0)   # Escape / dismiss -> Cancel
        dialog.set_default_button(1)  # Enter -> Quit Anyway

        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"
                shutdown.cleanup_and_quit(self)
            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 structured editor on the local user's ~/.ssh/authorized_keys."""
        try:
            from .authorized_keys_window import AuthorizedKeysWindow
        except Exception as exc:
            logger.error("authorized_keys editor unavailable: %s", exc)
            return
        try:
            window = AuthorizedKeysWindow(
                parent=self,
                local_path="~/.ssh/authorized_keys",
                connection_manager=self.connection_manager,
                key_manager=getattr(self, 'key_manager', None),
            )
            window.present()
        except Exception as exc:
            logger.error("Failed to open local authorized_keys editor: %s", exc)

    def on_manage_authorized_keys_action(self, action, param=None):
        """Open the structured authorized_keys editor for the right-clicked connection."""
        if not (hasattr(self, '_context_menu_connection') and self._context_menu_connection):
            return
        connection = self._context_menu_connection
        if Capability.KEY_DEPLOYMENT not in capabilities_for(connection):
            logger.debug("authorized_keys editor unavailable: protocol %r has no key deployment",
                         getattr(connection, 'protocol', 'ssh'))
            return
        try:
            from .authorized_keys_window import AuthorizedKeysWindow
            from .file_manager import create_file_manager_backend
        except Exception as exc:
            logger.error("authorized_keys editor unavailable: %s", exc)
            return

        host_value = _get_connection_host(connection) or _get_connection_alias(connection)
        username = getattr(connection, 'username', '') or ''
        port_value = getattr(connection, 'port', 22) or 22

        ssh_config = None
        if hasattr(self, 'config') and self.config is not None:
            try:
                ssh_config = self.config.get_ssh_config()
            except Exception as exc:
                logger.debug("Failed to read SSH configuration for authorized_keys editor: %s", exc)
                ssh_config = None

        initial_password = getattr(connection, 'password', None) or None
        if not initial_password and self.connection_manager is not None:
            try:
                initial_password = self.connection_manager.get_password(host_value, username)
            except Exception as exc:
                logger.debug("Password lookup failed for authorized_keys editor: %s", exc)
                initial_password = None

        try:
            manager = create_file_manager_backend(
                str(host_value or ''),
                str(username or ''),
                int(port_value),
                password=initial_password,
                connection=connection,
                connection_manager=self.connection_manager,
                ssh_config=ssh_config,
            )
        except Exception as exc:
            logger.error("Failed to create SFTP manager for authorized_keys: %s", exc)
            return

        try:
            window = AuthorizedKeysWindow(
                parent=self,
                connection=connection,
                sftp_manager=manager,
                connection_manager=self.connection_manager,
                key_manager=getattr(self, 'key_manager', None),
            )
            window.present()
        except Exception as exc:
            logger.error("Failed to open authorized_keys editor: %s", exc)
            try:
                manager.close()
            except Exception:
                pass

    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_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 Capability.REMOTE_COMMAND in capabilities_for(c)
        ]
        if connection is not None and Capability.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

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

            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
                ):
                    # Only detach from the group the row is displayed under
                    self.group_manager.remove_connection_from_group(nickname, context_group_id)
                else:
                    self.group_manager.move_connection(nickname, None)
            self.rebuild_connection_list()

        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:
            self.group_manager.move_connection(connection_nickname, target_group_id)
            self.rebuild_connection_list()
        except Exception as e:
            logger.error(f"Failed to move connection {connection_nickname} to group: {e}")
    
    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 the connection in the system's default terminal using ssh_connection_builder"""
        if getattr(connection, 'protocol', 'ssh') != 'ssh':
            # build_native_command() is an SSH-only path.
            logger.debug("System terminal unavailable for protocol %r",
                         getattr(connection, 'protocol', 'ssh'))
            return
        try:
            from .ssh_connection_builder import build_native_command

            # Build a plain native command (ssh -F <config> host). The external
            # terminal provides its own TTY/agent, so we deliberately do NOT apply
            # sshPilot's in-app askpass or agent bypass here.
            ssh_cmd_parts = build_native_command(
                connection,
                self.config if hasattr(self, 'config') else None,
            )
            # The terminal emulators take a shell command line, so the argv has
            # to be flattened. shlex.join quotes every argument correctly --
            # never hand-roll this: a Host alias from ~/.ssh/config containing a
            # quote or backtick would otherwise break out of the command.
            ssh_command = shlex.join(ssh_cmd_parts)

            use_external = self.config.get_setting('use-external-terminal', False)
            if use_external:
                terminal_command = get_user_preferred_terminal(self.config)
            else:
                terminal_command = get_default_terminal_command()
            if not terminal_command:
                terminal_command = find_any_terminal()

            if not terminal_command:
                self._show_terminal_error_dialog()
                return

            if not open_system_terminal(terminal_command, ssh_command):
                self._show_terminal_error_dialog()

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

    def _open_connection_in_external_terminal(self, connection):
        """Open the connection in the user's preferred external terminal"""
        try:
            # Native-only: connect by the ~/.ssh/config host identifier and let
            # the external terminal's ssh read per-host settings from the config.
            host_value = ''
            if hasattr(connection, 'resolve_host_identifier'):
                try:
                    host_value = connection.resolve_host_identifier()
                except Exception:
                    host_value = ''
            if not host_value:
                host_value = _get_connection_host(connection) or _get_connection_alias(connection)

            # Quoted, not interpolated: host_value comes from ~/.ssh/config.
            ssh_command = shlex.join(['ssh', host_value]) if host_value else 'ssh'

            terminal = get_user_preferred_terminal(self.config)
            if not terminal:
                terminal = get_default_terminal_command()

            if not terminal:
                self._show_terminal_error_dialog()
                return

            if not open_system_terminal(terminal, ssh_command):
                self._show_terminal_error_dialog()

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

    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 _on_plugin_connection_saved(self, dialog, connection_data):
        """Persist a plugin-protocol connection (JSON store, no ssh_config)."""
        if dialog.is_editing and dialog.connection is not None:
            old_connection = dialog.connection
            original_nickname = old_connection.nickname
            if not self.connection_manager.update_connection(old_connection, connection_data):
                logger.error("Failed to update plugin connection")
                return
            new_nickname = connection_data.get('nickname') or original_nickname
            if original_nickname != new_nickname:
                try:
                    self.group_manager.rename_connection(original_nickname, new_nickname)
                except Exception:
                    pass
                try:
                    old_meta = self.config.get_connection_meta(original_nickname)
                    if old_meta:
                        new_meta = self.config.get_connection_meta(new_nickname)
                        merged = {**old_meta, **new_meta}
                        meta_all = self.config.get_setting('connections_meta', {}) or {}
                        meta_all.pop(original_nickname, None)
                        self.config.set_setting('connections_meta', meta_all)
                        self.config.set_connection_meta(new_nickname, merged)
                except Exception:
                    logger.debug("Failed to migrate connection meta on rename", exc_info=True)
            try:
                old_connection.tags = self.config.get_connection_tags(old_connection.nickname)
            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}")
        else:
            connection = Connection(connection_data)
            if self.connection_manager.update_connection(connection, connection_data):
                self.rebuild_connection_list()
                logger.info(f"Created new plugin connection: {connection_data['nickname']}")
            else:
                logger.error("Failed to save plugin connection")

    def on_connection_saved(self, dialog, connection_data):
        """Handle connection saved from dialog"""
        save_completion = connection_data.pop('__save_completion', None)

        def _complete_save(ok):
            if callable(save_completion):
                save_completion(bool(ok))

        try:
            if connection_data.get('protocol', 'ssh') != 'ssh':
                self._on_plugin_connection_saved(dialog, connection_data)
                return
            if dialog.is_editing:
                # Update existing connection
                old_connection = dialog.connection
                is_connected = old_connection in self.active_terminals

                # Store the current terminal instance if connected
                terminal = self.active_terminals.get(old_connection) if is_connected else None
                original_nickname = old_connection.nickname

                try:
                    logger.info(
                        "Window.on_connection_saved(edit): saving '%s' with %d forwarding rules",
                        original_nickname, len(connection_data.get('forwarding_rules', []) or [])
                    )
                except Exception:
                    pass

                # Persist + sync the live instance (connection_manager owns this)
                if not self.connection_manager.apply_connection_update(old_connection, connection_data):
                    logger.error("Failed to update connection in SSH config")
                    _complete_save(False)
                    return

                # Preserve group assignment if nickname changed
                new_nickname = connection_data['nickname']
                if original_nickname != new_nickname:
                    try:
                        self.group_manager.rename_connection(original_nickname, new_nickname)
                    except Exception:
                        pass
                    # Migrate per-connection metadata (pinned, WoL, tags) to the
                    # new nickname. The dialog already wrote its own fields under
                    # the new key, so those win over the old entry's values.
                    try:
                        old_meta = self.config.get_connection_meta(original_nickname)
                        if old_meta:
                            new_meta = self.config.get_connection_meta(new_nickname)
                            merged = {**old_meta, **new_meta}
                            meta_all = self.config.get_setting('connections_meta', {}) or {}
                            meta_all.pop(original_nickname, None)
                            self.config.set_setting('connections_meta', meta_all)
                            self.config.set_connection_meta(new_nickname, merged)
                    except Exception:
                        logger.debug("Failed to migrate connection meta on rename", exc_info=True)

                # Update UI. The tag-filtered list is derived during rebuilds,
                # so a tag change needs a full rebuild while a tag filter is
                # active — update_display() alone leaves it stale.
                tags_changed = False
                try:
                    fresh_tags = self.config.get_connection_tags(old_connection.nickname)
                    if list(getattr(old_connection, 'tags', None) or []) != fresh_tags:
                        tags_changed = True
                    old_connection.tags = fresh_tags
                except Exception:
                    pass
                rows = self._rows_for_connection(old_connection)
                if tags_changed and getattr(self, '_tag_filter', None):
                    self.rebuild_connection_list()
                elif rows:
                    # Update the display for every row representing this connection
                    for row in rows:
                        row.update_display()
                else:
                    # If the connection is not in the rows, rebuild the list
                    self.rebuild_connection_list()

                logger.info(f"Updated connection: {old_connection.nickname}")

                # If the connection is active, ask if user wants to reconnect
                if is_connected and terminal is not None:
                    # Store the terminal in the connection for later use
                    old_connection._terminal_instance = terminal
                    self.terminal_manager.prompt_reconnect(old_connection)
                _complete_save(True)

            else:
                # Create new connection (connection_manager owns persistence)
                connection = self.connection_manager.create_connection(connection_data)
                if connection is not None:
                    self.rebuild_connection_list()
                    logger.info(f"Created new connection: {connection_data['nickname']}")
                    _complete_save(True)
                else:
                    logger.error("Failed to save connection to SSH config")
                    _complete_save(False)

        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 _rebuild_connections_list(self):
        """Rebuild the sidebar connections list from manager state, avoiding duplicates."""
        try:
            self.rebuild_connection_list()
        except Exception:
            pass
