# -----------------------------------------------------------------------------
# Copyright (C) 2026 Iliyas Jorio.
# This file is part of GitFourchette, distributed under the GNU GPL v3.
# For full terms, see the included LICENSE file.
# -----------------------------------------------------------------------------

import copy
import gc
import logging
import os
import re
from collections.abc import Sequence, Callable
from contextlib import suppress
from pathlib import Path
from typing import Literal

from gitfourchette import settings
from gitfourchette import tasks
from gitfourchette.application import GFApplication
from gitfourchette.codeview.codeview import CodeView
from gitfourchette.dropzone import DropAction, DropZone
from gitfourchette.exttools.toolprocess import ToolProcess
from gitfourchette.exttools.usercommand import UserCommand
from gitfourchette.forms.aboutdialog import AboutDialog
from gitfourchette.forms.clonedialog import CloneDialog
from gitfourchette.forms.maintoolbar import MainToolBar
from gitfourchette.forms.repostub import RepoStub
from gitfourchette.forms.searchbar import SearchBar
from gitfourchette.forms.textinputdialog import TextInputDialog
from gitfourchette.forms.welcomewidget import WelcomeWidget
from gitfourchette.globalshortcuts import GlobalShortcuts
from gitfourchette.localization import *
from gitfourchette.nav import NavLocator, NavContext, NavFlags
from gitfourchette.porcelain import *
from gitfourchette.qt import *
from gitfourchette.repowidget import RepoWidget
from gitfourchette.settings import PrefEffects, TabBarClick
from gitfourchette.syntax import LexJobCache
from gitfourchette.tasks import TaskBook, RepoTaskRunner
from gitfourchette.tasks.newrepotasks import NewRepo
from gitfourchette.toolbox import *
from gitfourchette.trash import Trash

logger = logging.getLogger(__name__)

USERS_GUIDE_URL = "https://gitfourchette.org/guide"


class NoRepoWidgetError(Exception):
    pass


class MainWindow(QMainWindow):
    welcomeStack: QStackedWidget
    welcomeWidget: WelcomeWidget
    tabs: QTabWidget2

    recentMenu: QMenu
    showStatusBarAction: QAction
    showMenuBarAction: QAction

    sharedSplitterSizes: dict[str, list[int]]

    def __init__(self):
        super().__init__()

        self.welcomeStack = QStackedWidget(self)
        self.setCentralWidget(self.welcomeStack)

        self.setObjectName("GFMainWindow")

        self.sharedSplitterSizes = {}

        self.setWindowTitle(qAppName())

        initialSize = .75 * QApplication.primaryScreen().availableSize()
        self.resize(initialSize)

        self.tabs = QTabWidget2(self)
        self.tabs.currentWidgetChanged.connect(self.onTabCurrentWidgetChanged)
        self.tabs.tabCloseRequested.connect(self.closeTab)
        self.tabs.tabContextMenuRequested.connect(self.onTabContextMenu)
        self.tabs.tabMiddleClicked.connect(self.onTabMiddleClicked)
        self.tabs.tabDoubleClicked.connect(self.onTabDoubleClicked)

        self.welcomeWidget = WelcomeWidget(self)
        self.welcomeWidget.newRepo.connect(self.newRepo)
        self.welcomeWidget.openRepo.connect(self.openDialog)
        self.welcomeWidget.cloneRepo.connect(self.cloneDialog)

        self.welcomeStack.addWidget(self.welcomeWidget)
        self.welcomeStack.addWidget(self.tabs)
        self.welcomeStack.setCurrentWidget(self.welcomeWidget)

        self.globalMenuBar = QMenuBar(self)
        self.globalMenuBar.setObjectName("GFMainMenuBar")
        self.setMenuBar(self.globalMenuBar)
        self.autoHideMenuBar = AutoHideMenuBar(self.globalMenuBar)

        self.statusBar2 = QStatusBar2(self)
        self.setStatusBar(self.statusBar2)

        self.mainToolBar = MainToolBar(self)
        self.addToolBar(self.mainToolBar)
        self.mainToolBar.openDialog.connect(self.openDialog)
        self.mainToolBar.openPrefs.connect(GFApplication.instance().openPrefsDialog)
        self.mainToolBar.reveal.connect(lambda: self.currentRepoWidget().openRepoFolder())
        self.mainToolBar.openTerminal.connect(lambda: self.currentRepoWidget().openTerminal())
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.PreventContextMenu)

        self.recentMenu = QMenu(self)
        self.recentMenu.setObjectName("RecentMenu")
        self.recentMenu.setToolTipsVisible(True)
        self.fillRecentMenu()

        self.welcomeWidget.ui.recentReposButton.setMenu(self.recentMenu)
        self.mainToolBar.recentAction.setMenu(self.recentMenu)

        self.fillGlobalMenuBar()

        self.setAcceptDrops(True)

        GFApplication.instance().prefsChanged.connect(self.refreshPrefs)
        self.refreshPrefs()

        self.dropZone = DropZone(self)
        self.dropZone.setVisible(False)

    # -------------------------------------------------------------------------
    # RepoTask

    @property
    def taskRunner(self) -> RepoTaskRunner:
        """
        Return the foreground RepoWidget's RepoTaskRunner.
        This provides compatibility for TaskInvocation's RepoTaskRunner lookup.
        May raise NoRepoWidgetError, aborting the lookup.
        """
        return self.currentRepoWidget().taskRunner

    # -------------------------------------------------------------------------
    # Event handlers

    def onMouseSideButtonPressed(self, forward: bool):
        if not self.isActiveWindow():
            return
        with suppress(NoRepoWidgetError):
            repoWidget = self.currentRepoWidget()
            if forward:
                repoWidget.navigateForward()
            else:
                repoWidget.navigateBack()

    def onFileDraggedToDockIcon(self, path: str):
        outcome = self.getDropOutcomeFromLocalFilePath(path)
        self.handleDrop(*outcome)

    def keyReleaseEvent(self, event: QKeyEvent):
        if event.key() == Qt.Key.Key_Alt and self.autoHideMenuBar.enabled:
            self.autoHideMenuBar.toggle()
        else:
            super().keyReleaseEvent(event)

    def dragEnterEvent(self, event: QDragEnterEvent):
        try:
            action, data = self.getDropOutcomeFromMimeData(event.mimeData())
            if action == DropAction.Deny and not data:
                event.setAccepted(False)
                return
            self.dropZone.install(action, data)
            if action == DropAction.Deny:
                event.setDropAction(Qt.DropAction.IgnoreAction)  # 'nope' cursor on KDE
            else:
                event.acceptProposedAction()
        except Exception:  # pragma: no cover - Don't let this crash the application
            logger.exception("dragEnterEvent failed")
            event.setAccepted(False)

    def dragLeaveEvent(self, event: QDragLeaveEvent):
        self.dropZone.setVisible(False)

    def dropEvent(self, event: QDropEvent):
        self.dropZone.setVisible(False)
        action, data = self.getDropOutcomeFromMimeData(event.mimeData())
        event.setAccepted(True)  # keep dragged item from coming back to cursor on macOS
        self.handleDrop(action, data)

    # -------------------------------------------------------------------------
    # Menu bar

    def fillGlobalMenuBar(self):
        menubar = self.globalMenuBar
        menuObjectNamePrefix = "MWMainMenu"

        # Delete old menu objects
        for m in menubar.findChildren(QMenu, options=Qt.FindChildOption.FindDirectChildrenOnly):
            if m.objectName().startswith(menuObjectNamePrefix):
                m.deleteLater()

        menubar.clear()

        # -------------------------------------------------------------
        # Set up root menus

        menuNames = {
            "File": _("&File"),
            "Edit": _("&Edit"),
            "View": _("&View"),
            "Repo": _("&Repo"),
            "Commands": _("&Commands"),
            "Mount": _("&Mount"),
            "Help": _("&Help"),
        }

        rootMenus: dict[str, QMenu] = {}
        for key, name in menuNames.items():
            menu = menubar.addMenu(name)
            menu.setObjectName(f"{menuObjectNamePrefix}{key}")
            menu.setToolTipsVisible(True)
            rootMenus[key] = menu

        fileMenu, editMenu, viewMenu, repoMenu, commandsMenu, mountMenu, helpMenu = iter(rootMenus.values())

        self.autoHideMenuBar.reconnectToMenus()

        # -------------------------------------------------------------

        ActionDef.addToQMenu(
            fileMenu,

            ActionDef(_("&New Repository…"), self.newRepo,
                      shortcuts=QKeySequence.StandardKey.New, icon="folder-new",
                      tip=_("Create an empty Git repo")),

            ActionDef(_("C&lone Repository…"), self.cloneDialog,
                      shortcuts="Ctrl+Shift+N", icon="folder-download",
                      tip=_("Download a Git repo and open it")),

            ActionDef.SEPARATOR,

            ActionDef(_("&Open Repository…"), self.openDialog,
                      shortcuts=QKeySequence.StandardKey.Open, icon="folder-open",
                      tip=_("Open a Git repo on your machine")),

            ActionDef(_("Open &Recent"),
                      icon="folder-open-recent",
                      tip=_("List of recently opened Git repos"),
                      submenu=self.recentMenu),

            ActionDef.SEPARATOR,

            TaskBook.action(self, tasks.ApplyPatchFile),
            TaskBook.action(self, tasks.ApplyPatchFileReverse),

            ActionDef.SEPARATOR,

            ActionDef(_("&Settings…"), GFApplication.instance().openPrefsDialog,
                      shortcuts=QKeySequence.StandardKey.Preferences, icon="configure",
                      menuRole=QAction.MenuRole.PreferencesRole,
                      tip=_("Configure {app}", app=qAppName())),

            TaskBook.action(self, tasks.SetUpGitIdentity, taskArgs=('', False)
                            ).replace(menuRole=QAction.MenuRole.ApplicationSpecificRole),

            ActionDef.SEPARATOR,

            ActionDef(_("&Close Tab"), self.dispatchCloseCommand,
                      shortcuts=QKeySequence.StandardKey.Close, icon="document-close",
                      tip=_("Close current repository tab")),

            ActionDef(_("&Quit"), self.close,
                      shortcuts=QKeySequence.StandardKey.Quit, icon="application-exit",
                      tip=_("Quit {app}", app=qAppName()),
                      menuRole=QAction.MenuRole.QuitRole),
        )

        # -------------------------------------------------------------

        ActionDef.addToQMenu(
            editMenu,

            ActionDef(_("&Find…"), lambda: self.dispatchSearchCommand(),
                      shortcuts=GlobalShortcuts.find, icon="edit-find",
                      tip=_("Search for a piece of text in commit messages, the current diff, or the name of a file")),

            ActionDef(_("Find Next"), lambda: self.dispatchSearchCommand(SearchBar.Op.Next),
                      shortcuts=GlobalShortcuts.findNext,
                      tip=_("Find next occurrence")),

            ActionDef(_("Find Previous"), lambda: self.dispatchSearchCommand(SearchBar.Op.Previous),
                      shortcuts=GlobalShortcuts.findPrevious,
                      tip=_("Find previous occurrence")),
        )

        # -------------------------------------------------------------

        ActionDef.addToQMenu(
            repoMenu,
            *RepoWidget.contextMenuItemsByProxy(self, self.currentRepoWidget),
        )

        # -------------------------------------------------------------

        ActionDef.addToQMenu(
            viewMenu,
            self.mainToolBar.toggleViewAction(),
            ActionDef(englishTitleCase(_("Show status bar")), self.toggleStatusBar, checkState=-1, objectName="ShowStatusBarAction"),
            ActionDef(englishTitleCase(_("Show menu bar")), self.toggleMenuBar, checkState=-1, objectName="ShowMenuBarAction"),
            ActionDef.SEPARATOR,
            TaskBook.action(self, tasks.JumpToUncommittedChanges, accel="U"),
            TaskBook.action(self, tasks.JumpToHEAD, accel="H"),
            ActionDef.SEPARATOR,
            ActionDef(_("Focus on Sidebar"), self.focusSidebar, shortcuts="Alt+1"),
            ActionDef(_("Focus on Commit Log"), self.focusGraph, shortcuts="Alt+2"),
            ActionDef(_("Focus on File List"), self.focusFiles, shortcuts="Alt+3"),
            ActionDef(_("Focus on Code View"), self.focusDiff, shortcuts="Alt+4"),
            ActionDef.SEPARATOR,
            ActionDef(_("Blame File…"), self.blameFile, icon=TaskBook.icons[tasks.OpenBlame], shortcuts=TaskBook.shortcuts[tasks.OpenBlame]),
            ActionDef(_("Next File"), self.nextFile, shortcuts="Ctrl+]"),
            ActionDef(_("Previous File"), self.previousFile, shortcuts="Ctrl+["),
            ActionDef.SEPARATOR,
            ActionDef(_("&Next Tab"), self.nextTab, shortcuts="Ctrl+Shift+]" if MACOS else "Ctrl+Tab"),
            ActionDef(_("&Previous Tab"), self.previousTab, shortcuts="Ctrl+Shift+[" if MACOS else "Ctrl+Shift+Tab"),
            ActionDef.SEPARATOR,
            TaskBook.action(self, tasks.JumpBack),
            TaskBook.action(self, tasks.JumpForward),
            ActionDef("Dump Nav Log", lambda: logger.info(self.currentRepoWidget().navHistory.getTextLog()), objectName="DumpNavLogAction"),
            ActionDef.SEPARATOR,
            ActionDef(
                _("&Refresh"),
                lambda: self.currentRepoWidget().refreshRepo(),
                shortcuts=GlobalShortcuts.refresh,
                icon="SP_BrowserReload",
                tip=_("Check for changes in the repo (on the local filesystem only – will not fetch remotes)"),
            ),
            ActionDef(
                _("Reloa&d"),
                lambda: self.currentRepoWidget().replaceWithStub(),
                shortcuts="Ctrl+F5",
                tip=_("Reopen the repo from scratch"),
            ),
        )

        self.showStatusBarAction = viewMenu.findChild(QAction, "ShowStatusBarAction")
        self.showMenuBarAction = viewMenu.findChild(QAction, "ShowMenuBarAction")
        self.showMenuBarAction.setVisible(not MACOS)
        viewMenu.findChild(QAction, "DumpNavLogAction").setVisible(APP_DEBUG)

        # -------------------------------------------------------------

        self.parseUserCommands()
        if self.userCommands:
            commandActions = [
                ActionDef.SEPARATOR if command.isSeparator
                else ActionDef(
                    command.menuTitle(),
                    lambda c=command: self.currentRepoWidget().executeUserCommand(c),
                    tip=command.menuToolTip(),
                    shortcuts=command.shortcut
                )
                for command in self.userCommands
            ]

            ActionDef.addToQMenu(
                commandsMenu,
                *commandActions,
                ActionDef.SEPARATOR,
                ActionDef(_("Edit Commands…"), icon="document-edit",
                          callback=lambda: GFApplication.instance().openPrefsDialog("commands")),
            )

            # Don't share commandsMenu with the terminal button: commandsMenu.aboutToShow
            # would fire via the terminal button's popup routine, causing AutoHideMenuBar to
            # show the entire menu bar.
            # Do share the actions themselves so that the keyboard shortcuts work.
            self.mainToolBar.setTerminalActions(commandsMenu.actions())
        else:
            commandsMenu.deleteLater()
            self.mainToolBar.setTerminalActions([])

        # -------------------------------------------------------------

        mountItems = GFApplication.instance().mountManager.makeMenu(self)
        if mountItems:
            ActionDef.addToQMenu(mountMenu, *mountItems)
        else:
            mountMenu.deleteLater()

        # -------------------------------------------------------------

        ActionDef.addToQMenu(
            helpMenu,

            ActionDef(
                _("&About {0}", qAppName()),
                lambda: AboutDialog.popUp(self),
                icon="gitfourchette",
                menuRole=QAction.MenuRole.AboutRole,),

            ActionDef(
                _("{0} User’s Guide", qAppName()),
                lambda: QDesktopServices.openUrl(QUrl(USERS_GUIDE_URL)),
                icon="help-contents"),

            ActionDef.SEPARATOR,

            ActionDef(
                _("Open Trash…"),
                self.openRescueFolder,
                icon="SP_TrashIcon",
                tip=_("Explore changes that you may have discarded by mistake")),

            ActionDef(
                _("Empty Trash…"),
                self.clearRescueFolder,
                tip=_("Delete all discarded changes from the trash folder")),
        )

    def fillRecentMenu(self):
        actions = []
        for path in settings.history.getRecentRepoPaths(settings.prefs.maxRecentRepos):
            caption = compactPath(path)
            nickname = settings.history.getRepoNickname(path, strict=True)
            if nickname:
                caption += f" ({tquo(nickname)})"

            caption = elide(caption, ems=60)  # don't let menus get absurdly wide

            openAction = ActionDef(
                escamp(caption),
                lambda p=path: self.openRepo(p, exactMatch=True),
                tip=path)
            actions.append(openAction)

        self.recentMenu.clear()
        ActionDef.addToQMenu(
            self.recentMenu,
            *actions,
            ActionDef.SEPARATOR,
            ActionDef(
                _("Clear List"), self.onClearRecentMenu, "edit-clear-history",
                tip=_("Clear the list of recently opened repositories"),
            ))

    def onClearRecentMenu(self):
        settings.history.clearRepoHistory()
        settings.history.write()
        self.fillRecentMenu()

    def showMenuBarHiddenWarning(self):
        return showInformation(
            self, _("Menu bar hidden"),
            _("The menu bar is now hidden. Press the Alt key to toggle it."))

    # -------------------------------------------------------------------------
    # Tabs

    def currentRepoWidget(self) -> RepoWidget:
        rw = self.tabs.currentWidget()
        if not isinstance(rw, RepoWidget):  # it might be a RepoStub
            raise NoRepoWidgetError()
        return rw

    def tabWidgetForWorkdirPath(self, workdir: str) -> RepoWidget | RepoStub | None:
        widget: RepoWidget | RepoStub
        for widget in self.tabs.widgets():
            assert isinstance(widget, RepoWidget | RepoStub)
            with suppress(FileNotFoundError):  # may be raised if workdir cannot be accessed
                if os.path.samefile(workdir, widget.workdir):
                    return widget
        return None

    def setWindowTitle(self, title: str):
        if APP_DEBUG:
            chain = ["DEBUG", str(os.getpid()), QT_BINDING]
            if APP_TESTMODE:
                chain.append("TESTMODE")
            if APP_NOTHREADS:
                chain.append("NOTHREADS")
            title = f"{title} ({' '.join(chain)})"

        super().setWindowTitle(title)

    def onTabCurrentWidgetChanged(self):
        self.mainToolBar.updateNavButtons()  # Kill back/forward arrows
        self.statusBar2.clearMessage()

        widget = self.tabs.currentWidget()

        # Switch to welcome widget if zero tabs
        if not widget:
            self.welcomeStack.setCurrentWidget(self.welcomeWidget)
            self.setWindowTitle(APP_DISPLAY_NAME)
            return

        self.welcomeStack.setCurrentWidget(self.tabs)  # Exit welcome widget
        self.setWindowTitle(widget.windowTitle())

        # If it's a RepoStub, load it if needed
        if not isinstance(widget, RepoWidget):
            assert isinstance(widget, RepoStub)
            if not widget.isPriming() and widget.willAutoLoad():
                widget.loadNow()
            return

        # We know it's a RepoWidget beyond this point
        assert isinstance(widget, RepoWidget)

        # Update back/forward buttons
        self.onRepoHistoryChanged(widget)

        widget.restoreSplitterStates()

        # Refresh the repo
        widget.refreshRepo()

    def generateTabContextMenu(self, i: int):
        if i < 0:  # Right mouse button released outside tabs
            return None

        widget: RepoWidget | RepoStub = self.tabs.widget(i)
        menu = QMenu(self)
        menu.setObjectName("MWRepoTabContextMenu")

        anyOtherLoadedTabs = any(tab is not widget and isinstance(tab, RepoWidget)
                                 for tab in self.tabs.widgets())

        ActionDef.addToQMenu(
            menu,
            ActionDef(_("Close Tab"), lambda: self.closeTab(i), shortcuts=QKeySequence.StandardKey.Close),
            ActionDef(_("Close Other Tabs"), lambda: self.closeOtherTabs(i), enabled=self.tabs.count() > 1),
            ActionDef(_("Unload Other Tabs"), lambda: self.unloadOtherTabs(i), enabled=self.tabs.count() > 1 and anyOtherLoadedTabs),
            ActionDef.SEPARATOR,
            *self.repolessActions(widget.workdir),
            ActionDef.SEPARATOR,
            ActionDef(_("Configure Tabs…"), lambda: GFApplication.instance().openPrefsDialog("tabCloseButton")),
        )

        return menu

    def onTabContextMenu(self, globalPoint: QPoint, i: int):
        if i < 0:  # Right mouse button released outside tabs
            return

        menu = self.generateTabContextMenu(i)
        menu.aboutToHide.connect(menu.deleteLater)
        menu.popup(globalPoint)

    def onTabMiddleClicked(self, i: int):
        self.onTabSpecialClick(i, "middle")

    def onTabDoubleClicked(self, i: int):
        self.onTabSpecialClick(i, "double")

    def onTabSpecialClick(self, i: int, click: Literal["middle", "double"]):
        if i < 0:
            return

        if click == "double":
            action = settings.prefs.doubleClickTabBar
        elif click == "middle":
            action = settings.prefs.middleClickTabBar
        else:
            raise NotImplementedError(f"unknown special click kind '{click}'")

        widget: RepoWidget | RepoStub = self.tabs.widget(i)

        if action == TabBarClick.Nothing:
            pass
        elif action == TabBarClick.Folder:
            openFolder(widget.workdir)
        elif action == TabBarClick.Close:
            self.closeTab(i)
        elif action == TabBarClick.Terminal:
            ToolProcess.startTerminal(self, widget.workdir)
        else:
            raise NotImplementedError(f"unknown special click action '{action}'")

    # -------------------------------------------------------------------------
    # Repo loading

    def openRepo(self, path: str, exactMatch=True) -> RepoWidget | RepoStub | None:
        try:
            rw = self._openRepo(path, exactMatch=exactMatch)
        except BaseException as exc:
            excMessageBox(
                exc,
                _("Open repository"),
                _("Couldn’t open the repository at {0}.", bquo(path)),
                parent=self,
                icon='warning')
            return None

        self.saveSession()

        # Return a concrete RepoWidget instead of a RepoStub if loading is already completed
        # (mostly for single-threaded unit tests that expect a deterministic chain of events).
        if RepoTaskRunner.ForceSerial:
            rw2 = self.tabWidgetForWorkdirPath(rw.workdir)
            if isinstance(rw2, RepoWidget):
                rw = rw2
            else:
                assert not APP_TESTMODE, "the RepoWidget isn't ready yet"

        return rw

    def _resolveWorkdir(self, path: str, exactMatch) -> tuple[str, RepoWidget | RepoStub | None]:
        # Make sure the path exists
        if not os.path.exists(path):
            raise FileNotFoundError(_("There’s nothing at this path."))

        # Resolve the workdir
        if not exactMatch:
            with RepoContext(path) as repo:
                if repo.is_bare:
                    raise NotImplementedError(_("Sorry, {app} doesn’t support bare repositories.", app=qAppName()))
                path = repo.workdir

        # Scan for an existing tab for this repo
        existingWidget = self.tabWidgetForWorkdirPath(path)
        if existingWidget is not None:
            return existingWidget.workdir, existingWidget

        # There's no widget for this workdir but it's a valid repo
        return path, None

    def _openRepo(self, path: str, foreground=True, tabIndex=-1, exactMatch=True, locator=NavLocator.Empty
                  ) -> RepoWidget | RepoStub:
        path, existingWidget = self._resolveWorkdir(path, exactMatch)

        # First check that we don't have a tab for this repo already
        if existingWidget is not None:
            existingWidget.overridePendingLocator(locator)
            self.tabs.setCurrentWidget(existingWidget)
            return existingWidget

        # Create a RepoStub
        stub = RepoStub(parent=self, workdir=path, locator=locator)

        # Create a tab
        with QSignalBlockerContext(self.tabs):
            title = escamp(stub.getTitle())
            tabIndex = self.tabs.insertTab(tabIndex, stub, title)
            self.tabs.setTabTooltip(tabIndex, compactPath(path))
            if foreground:
                self.tabs.setCurrentIndex(tabIndex)
        self.refreshAllTabTexts()

        # We've got at least one tab now, so switch away from WelcomeWidget
        assert self.tabs.count() > 0
        self.welcomeStack.setCurrentWidget(self.tabs)

        # Load repo now
        if foreground:
            self.setWindowTitle(stub.windowTitle())
            stub.loadNow()

        return stub

    def installRepoWidget(self, rw: RepoWidget, tabIndex: int):
        repoStub = self.tabs.widget(tabIndex)
        assert tabIndex >= 0, "stub to replace isn't in tabs"
        assert isinstance(repoStub, RepoStub), "yanked widget isn't RepoStub"

        rw.nameChange.connect(self.onRepoNameChanged)
        rw.requestAttention.connect(lambda: self.onRepoRequestsAttention(rw))
        rw.openRepo.connect(lambda path, locator: self.openRepoNextTo(rw, path, locator))
        rw.openPrefs.connect(GFApplication.instance().openPrefsDialog)
        rw.mustReplaceWithStub.connect(lambda stub: self.replaceRepoWidgetWithStub(rw, stub))

        rw.statusMessage.connect(self.statusBar2.showMessage)
        rw.busyMessage.connect(self.statusBar2.showBusyMessage)
        rw.clearStatus.connect(self.statusBar2.clearMessage)

        rw.historyChanged.connect(lambda: self.onRepoHistoryChanged(rw))
        rw.windowTitleChanged.connect(lambda: self.onRepoWindowTitleChanged(rw))

        self.tabs.swapWidget(tabIndex, rw)
        self.refreshAllTabTexts()

        repoStub.setParent(None)  # tabs don't deparent the widget
        repoStub.deleteLater()

    def replaceRepoWidgetWithStub(self, oldWidget: RepoWidget, stub: RepoStub):
        tabIndex = self.tabs.indexOf(oldWidget)
        assert tabIndex >= 0, "RepoWidget to replace isn't in tabs"
        self.tabs.swapWidget(tabIndex, stub)
        self.refreshAllTabTexts()

        oldWidget.setParent(None)  # tabs don't deparent the widget
        oldWidget.close()  # will call cleanup
        oldWidget.deleteLater()

    # -------------------------------------------------------------------------

    def onRegainForeground(self):
        if QGuiApplication.applicationState() != Qt.ApplicationState.ApplicationActive:
            return
        if not settings.prefs.autoRefresh:
            return
        with suppress(NoRepoWidgetError):
            self.currentRepoWidget().refreshRepo()

    def onRepoNameChanged(self):
        self.refreshAllTabTexts()
        self.fillRecentMenu()

    def onRepoWindowTitleChanged(self, rw: RepoWidget):
        if rw.isVisible():
            self.setWindowTitle(rw.windowTitle())

    def onRepoHistoryChanged(self, rw: RepoWidget):
        if rw.isVisible():
            self.mainToolBar.updateNavButtons(rw.navHistory.canGoBack(), rw.navHistory.canGoForward())

    def onRepoRequestsAttention(self, rw: RepoWidget):
        i = self.tabs.indexOf(rw)
        self.tabs.requestAttention(i)

    # -------------------------------------------------------------------------
    # View menu

    def toggleStatusBar(self):
        GFApplication.applyPrefs(showStatusBar=not settings.prefs.showStatusBar)

    def toggleMenuBar(self):
        GFApplication.applyPrefs(showMenuBar=not settings.prefs.showMenuBar)

    def selectUncommittedChanges(self):
        self.currentRepoWidget().jump(NavLocator.inWorkdir())

    def selectHead(self):
        self.currentRepoWidget().jump(NavLocator.inRef("HEAD"))

    def focusSidebar(self):
        self.currentRepoWidget().sidebar.setFocus()

    def focusGraph(self):
        self.currentRepoWidget().graphView.setFocus()

    def focusFiles(self):
        rw = self.currentRepoWidget()
        context = rw.navLocator.context
        if context == NavContext.COMMITTED:
            rw.committedFiles.setFocus()
        else:
            target = rw.stagedFiles if context == NavContext.STAGED else rw.dirtyFiles
            fallback = rw.dirtyFiles if context == NavContext.STAGED else rw.stagedFiles
            if not target.isEmpty() or fallback.isEmpty():
                target.setFocus()
            else:
                fallback.setFocus()

    def focusDiff(self):
        rw = self.currentRepoWidget()
        if rw.specialDiffView.isVisibleTo(rw):
            rw.specialDiffView.setFocus()
        elif rw.conflictView.isVisibleTo(rw):
            rw.conflictView.setFocus()
        else:
            rw.diffView.setFocus()

    def nextFile(self):
        self.currentRepoWidget().diffArea.selectNextFile(True)

    def previousFile(self):
        self.currentRepoWidget().diffArea.selectNextFile(False)

    def blameFile(self):
        self.currentRepoWidget().blameFile()

    # -------------------------------------------------------------------------
    # Help menu

    def openRescueFolder(self):
        trash = Trash.instance()
        if trash.exists():
            openFolder(str(trash.trashDir))
        else:
            showInformation(
                self,
                _("Open trash folder"),
                _("There’s no trash folder. Perhaps you haven’t discarded a change with {0} yet.", qAppName()))

    def clearRescueFolder(self):
        trash = Trash.instance()
        sizeOnDisk, patchCount = trash.size()

        if patchCount <= 0:
            showInformation(
                self,
                _("Clear trash folder"),
                _("There are no discarded changes to delete."))
            return

        humanSize = self.locale().formattedDataSize(sizeOnDisk)

        askPrompt = paragraphs(
            _n("Do you want to permanently delete <b>{n}</b> discarded patch?",
               "Do you want to permanently delete <b>{n}</b> discarded patches?", patchCount),
            _("This will free up {0} on disk.", escape(humanSize)),
            _("This cannot be undone!")
        )

        askConfirmation(
            parent=self,
            title=_("Clear trash folder"),
            text=askPrompt,
            callback=lambda: trash.clear(),
            okButtonText=_("Delete permanently"),
            okButtonIcon=stockIcon("SP_DialogDiscardButton"))

    # -------------------------------------------------------------------------
    # File menu callbacks

    def newRepo(self):
        runner = RepoTaskRunner(self)
        runner.ready.connect(runner.deleteLater)
        NewRepo.invoke(runner, self.openRepo)

    def cloneDialog(self, initialUrl: str = ""):
        dlg = CloneDialog(initialUrl, self)
        dlg.cloneSuccessful.connect(lambda path: self.openRepo(path, exactMatch=True))
        dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
        dlg.setWindowModality(Qt.WindowModality.WindowModal)
        dlg.show()

    def openDialog(self):
        qfd = PersistentFileDialog.openDirectory(self, "NewRepo", _("Open repository"))
        qfd.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)  # don't leak dialog
        qfd.fileSelected.connect(lambda path: self.openRepo(path, exactMatch=False))
        qfd.show()

    # -------------------------------------------------------------------------
    # Tab management

    def closeCurrentTab(self):
        if self.tabs.count() == 0:  # don't attempt to close if no tabs are open
            QApplication.beep()
            return

        self.closeTab(self.tabs.currentIndex())

    def closeTab(self, index: int, finalTab: bool = True):
        widget = self.tabs.widget(index)

        # Remove the tab BEFORE cleaning up the widget
        # to prevent any interaction with it while it's wrapping up.
        self.tabs.removeTab(index)
        self.refreshAllTabTexts()

        # Clean up the widget
        widget.close()  # will call RepoWidget.cleanup()
        widget.deleteLater()  # help out GC (for PySide6)
        del widget

        if finalTab:
            self.saveSession()
            gc.collect()

    def closeOtherTabs(self, index: int):
        # First, set this tab as active so an active tab that gets closed doesn't trigger other tabs to load.
        self.tabs.setCurrentIndex(index)

        # Now close all tabs in reverse order but skip the index we want to keep.
        start = self.tabs.count()-1
        final = 1 if index == 0 else 0
        for i in range(start, -1, -1):
            if i != index:
                self.closeTab(i, i == final)

    def unloadOtherTabs(self, index: int = -1):
        if index < 0:
            index = self.tabs.currentIndex()

        # First, set this tab as active so an active tab that gets closed doesn't trigger other tabs to load.
        self.tabs.setCurrentIndex(index)

        # Now unload all tabs but skip the index we want to keep.
        numUnloaded = 0
        for i in range(self.tabs.count()):
            rw = self.tabs.widget(i)
            if i == index or not isinstance(rw, RepoWidget):
                continue
            with QSignalBlockerContext(rw):
                stub = rw.replaceWithStub()
            stub.disableAutoLoad()
            self.replaceRepoWidgetWithStub(rw, stub)
            del rw
            numUnloaded += 1

        self.statusBar2.showMessage(_n("{n} background tab unloaded.", "{n} background tabs unloaded.", numUnloaded))
        gc.collect()

    def closeAllTabs(self):
        start = self.tabs.count() - 1
        with QSignalBlockerContext(self.tabs):  # Don't let awaken unloaded tabs
            for i in range(start, -1, -1):  # Close tabs in reverse order
                self.closeTab(i, i == 0)

        self.onTabCurrentWidgetChanged()

    def reloadAllTabs(self):
        self.unloadOtherTabs()
        rw = self.currentRepoWidget()
        rw.replaceWithStub()

    def refreshAllTabTexts(self):
        widgets = list(self.tabs.widgets())
        baseTitles = [widget.getTitle() for widget in widgets]
        newTitles = baseTitles[:]

        groupsByTitle: dict[str, list[int]] = {}
        for i, title in enumerate(baseTitles):
            groupsByTitle.setdefault(title, []).append(i)

        for group in groupsByTitle.values():
            if len(group) <= 1:
                continue
            # Only disambiguate default (basename) titles; custom nicknames are kept as-is.
            defaultIndices = [
                i for i in group
                if not settings.history.getRepoNickname(widgets[i].workdir, strict=True)]
            if len(defaultIndices) <= 1:
                continue
            workdirs = [widgets[i].workdir for i in defaultIndices]
            disambiguatedTitles = disambiguateTabTitlesByPath(workdirs)
            for idx, title in zip(defaultIndices, disambiguatedTitles, strict=True):
                newTitles[idx] = title

        for i, title in enumerate(newTitles):
            self.tabs.setTabText(i, escamp(title))

    def openRepoNextTo(self, rw, path: str, locator: NavLocator = NavLocator.Empty):
        index = self.tabs.indexOf(rw)
        if index >= 0:
            index += 1
        return self._openRepo(path, tabIndex=index, exactMatch=True, locator=locator)

    def nextTab(self):
        if self.tabs.count() == 0:
            QApplication.beep()
            return
        index = self.tabs.currentIndex()
        index += 1
        index %= self.tabs.count()
        self.tabs.setCurrentIndex(index)

    def previousTab(self):
        if self.tabs.count() == 0:
            QApplication.beep()
            return
        index = self.tabs.currentIndex()
        index += self.tabs.count() - 1
        index %= self.tabs.count()
        self.tabs.setCurrentIndex(index)

    # -------------------------------------------------------------------------
    # Session management

    def restoreSession(self, session: settings.Session, sloppyPaths: list[str] | None = None):
        # Note: window geometry, despite being part of the session file, is
        # restored in application.py to avoid flashing a window with incorrect
        # dimensions on boot

        self.sharedSplitterSizes = copy.deepcopy(session.splitterSizes)

        # Stop here if there are no tabs to load
        if not session.tabs:
            return

        errors = []

        # We might not be able to load all tabs, so we may have to adjust the active tab index.
        activeTab = -1
        successfulRepos = []

        # Lazy-loading: prepare all tabs, but don't load the repos (foreground=False).
        for i, path in enumerate(session.tabs):
            sloppy = sloppyPaths is not None and path in sloppyPaths

            try:
                newRepoWidget = self._openRepo(path, exactMatch=not sloppy, foreground=False)
            except (GitError, OSError, NotImplementedError) as exc:
                # GitError: most errors thrown by pygit2
                # OSError: e.g. permission denied
                # NotImplementedError: e.g. shallow/bare repos
                errors.append((path, exc))
                continue

            assert isinstance(newRepoWidget, RepoWidget | RepoStub)

            # If we were passed a "sloppy" path from the command line, remember the root path.
            if sloppy:
                path = newRepoWidget.workdir

            successfulRepos.append(path)

            if i == session.activeTabIndex:
                # Heads up: MainWindow._openRepo may return an existing RepoWidget that matches the
                # given path. So, we're not necessarily the last tab, e.g. if the user passes
                # duplicate paths on the CLI.
                activeTab = self.tabs.indexOf(newRepoWidget)

        # If we failed to load anything, tell the user about it
        if errors:
            self._reportSessionErrors(errors)

        # Update history (don't write it yet - onTabCurrentWidgetChanged will do it below)
        for path in reversed(successfulRepos):
            settings.history.addRepo(path)
        self.fillRecentMenu()

        # Fall back to tab #0 if desired tab couldn't be restored (otherwise welcome page will stick around)
        if activeTab < 0 and len(successfulRepos) >= 1:
            activeTab = 0

        # Set current tab and load its repo.
        if activeTab >= 0:
            self.tabs.setCurrentIndex(activeTab)
            self.onTabCurrentWidgetChanged()  # needed to trigger loading on tab #0

    def _reportSessionErrors(self, errors: Sequence[tuple[str, BaseException]]):
        numErrors = len(errors)
        text = _n("The session couldn’t be restored fully because a repository failed to load:",
                  "The session couldn’t be restored fully because {n} repositories failed to load:", numErrors)
        qmb = asyncMessageBox(self, 'warning', _("Restore session"), text)
        addULToMessageBox(qmb, [f"<b>{compactPath(path)}</b><br>{exc}" for path, exc in errors])
        qmb.show()

    def saveSession(self, writeNow=False):
        session = settings.Session()
        session.windowGeometry = self.saveGeometry().data()
        session.splitterSizes = self.sharedSplitterSizes.copy()
        session.tabs = [widget.workdir for widget in self.tabs.widgets()]
        session.activeTabIndex = self.tabs.currentIndex()
        session.setDirty()
        if writeNow:
            session.write()

    def closeEvent(self, event: QCloseEvent):
        if not GFApplication.instance().mountManager.checkOnClose(self, self.close):
            event.setAccepted(False)
            return

        # Save session before closing all tabs.
        self.saveSession(writeNow=True)

        # Close all tabs so RepoWidgets release all their resources.
        # Important so unit tests wind down properly!
        self.closeAllTabs()

        super().closeEvent(event)

    # -------------------------------------------------------------------------
    # Drag and drop

    def getDropOutcomeFromLocalFilePath(self, path: str) -> tuple[DropAction, str]:
        if path.endswith(".patch"):
            return DropAction.Patch, path

        try:
            workdir, existingWidget = self._resolveWorkdir(path, exactMatch=False)
        except Exception as exc:
            if isinstance(exc, GitError) and str(exc).startswith("Repository not found"):
                return DropAction.Deny, _("{0} isn’t in a Git repo", tquoe(Path(path).name))
            else:  # pragma: no cover
                logger.exception("in drop outcome: _resolveWorkdir failed")
                return DropAction.Deny, str(exc)

        if existingWidget is not None and existingWidget.isVisible() and Path(path).is_file():
            return DropAction.Blame, path

        return DropAction.Open, workdir

    def getDropOutcomeFromMimeData(self, mime: QMimeData) -> tuple[DropAction, str]:
        if mime.hasUrls():
            try:
                url: QUrl = mime.urls()[0]
            except IndexError:
                return DropAction.Deny, ""

            if url.isLocalFile():
                path = url.toLocalFile()
                return self.getDropOutcomeFromLocalFilePath(path)
            else:
                return DropAction.Clone, url.toString()

        elif mime.hasText():
            text = mime.text()
            text = text.strip()
            if os.path.isabs(text) and os.path.exists(text):
                return self.getDropOutcomeFromLocalFilePath(text)
            elif text.startswith(("ssh://", "git+ssh://", "https://", "http://")):
                return DropAction.Clone, text
            elif re.match(r"^[a-zA-Z0-9-_.]+@.+:.+", text):
                return DropAction.Clone, text
            else:
                return DropAction.Deny, ""

        return DropAction.Deny, ""

    def handleDrop(self, action: DropAction, data: str):
        if action == DropAction.Deny:
            pass
        elif action == DropAction.Clone:
            self.cloneDialog(data)
        elif action == DropAction.Blame:
            rw = self.currentRepoWidget()
            path = Path(data)
            path = path.relative_to(rw.workdir)  # May raise ValueError('X is not in the subpath of Y')
            pathStr = path.as_posix()  # WINDOWS: Convert to forward slashes to match internal git representation
            rw.blameFile(pathStr)
        elif action == DropAction.Open:
            self.openRepo(data, exactMatch=True)
        elif action == DropAction.Patch:
            tasks.ApplyPatchFile.invoke(self, False, data)
        else:
            warnings.warn(f"Unsupported drag-and-drop outcome {action}")

    # -------------------------------------------------------------------------
    # Prefs

    def refreshPrefs(self):
        self.statusBar2.setVisible(settings.prefs.showStatusBar)
        self.statusBar2.enableMemoryIndicator(APP_DEBUG)
        self.mainToolBar.setVisible(settings.prefs.showToolBar)
        self.showStatusBarAction.setChecked(settings.prefs.showStatusBar)
        self.showMenuBarAction.setChecked(settings.prefs.showMenuBar)

    def onApplyPrefs(self, changedKeys: set[str]):
        if "showMenuBar" in changedKeys and not settings.prefs.showMenuBar:
            self.showMenuBarHiddenWarning()

        if PrefEffects.RebuildMenu & changedKeys:
            self.fillGlobalMenuBar()

        if PrefEffects.RestartApp & changedKeys:
            showInformation(
                self,
                _("Apply Settings"),
                _("You may need to restart {app} for the new settings to take effect fully.", app=qAppName()))
        elif PrefEffects.ReloadRepo & changedKeys and self.tabs.count() != 0:
            qmb = asyncMessageBox(
                self,
                "question",
                _("Apply Settings"),
                _("The new settings won’t take effect fully until you reload the current repositories."),
                buttons=QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
            qmb.button(QMessageBox.StandardButton.Ok).setText(_("&Reload"))
            qmb.button(QMessageBox.StandardButton.Cancel).setText(_("&Not Now"))
            qmb.accepted.connect(self.reloadAllTabs)
            qmb.show()

        # If any changed setting matches autoReload, schedule a forced refresh
        # of the current diff in all loaded RepoWidgets.
        if PrefEffects.ReloadDiff & changedKeys:
            # Nuke cached syntax highlighting
            LexJobCache.clear()

            for rw in self.tabs.widgets():
                if not isinstance(rw, RepoWidget):
                    continue
                locator = rw.taskRunner.pendingEpilog.jumpTo or rw.navLocator
                locator = locator.withExtraFlags(NavFlags.ForceDiff | NavFlags.ForceRecreateDocument)
                rw.taskRunner.pendingEpilog.jumpTo = locator
                rw.refreshRepo()

    # -------------------------------------------------------------------------
    # Dispatch commands to detached windows

    def dispatchCloseCommand(self):
        if self.isActiveWindow():
            self.closeCurrentTab()
            return

        # This is for macOS. Systems without a global main menu (i.e. anything but macOS)
        # take a different path to intercept keyboard shortcuts.
        try:
            CodeView.currentDetachedCodeView().window().close()
        except KeyError:
            QApplication.beep()

    def dispatchSearchCommand(self, op: SearchBar.Op = SearchBar.Op.Start):
        if self.isActiveWindow() and self.currentRepoWidget():
            self.currentRepoWidget().dispatchSearchCommand(op)
            return

        # This is for macOS. Systems without a global main menu (i.e. anything but macOS)
        # take a different path to intercept keyboard shortcuts.
        try:
            CodeView.currentDetachedCodeView().searchBar.popUp(op)
        except KeyError:
            QApplication.beep()

    # -------------------------------------------------------------------------
    # User commands

    def parseUserCommands(self):
        self.userCommands = list(UserCommand.parseCommandBlock(settings.prefs.commands))

    def contextualUserCommands(self, *placeholderTokens: UserCommand.Token):
        tokenSet = set(placeholderTokens)
        actions = []
        for command in self.userCommands:
            if not command.matchesContext(tokenSet):
                continue
            if not actions:
                actions.append(ActionDef.SEPARATOR)
            actions.append(ActionDef(
                _("(Command) {0}", command.menuTitle()),
                lambda c=command: self.currentRepoWidget().executeUserCommand(c),
                "prefs-usercommands",
                tip=command.menuToolTip(),
                shortcuts=command.shortcut,
            ))
        return actions

    # -------------------------------------------------------------------------
    # Repository-less actions (actions that only need a path to the workdir;
    # full-blown RepoWidget not required)

    def repolessActions(self, workdir: str | Callable[[], str]):
        superprojectLabel = _("Open Superproject")
        superprojectEnabled = True
        superproject = None

        if isinstance(workdir, str):
            def workdirProxy():
                return workdir
            superproject = settings.history.getRepoSuperproject(workdir)
            superprojectEnabled = bool(superproject)
            if superprojectEnabled:
                superprojectName = settings.history.getRepoTabName(superproject)
                superprojectLabel = _("Open Superproject {0}", lquo(superprojectName))
        else:
            workdirProxy = workdir
        assert callable(workdirProxy)

        return [
            ActionDef(
                _("&Open Repo Folder"),
                lambda: openFolder(workdirProxy()),
                icon="reveal",
                shortcuts=GlobalShortcuts.openRepoFolder,
                tip=_("Open this repo’s working directory in the system’s file manager"),
            ),

            ActionDef(
                _("Open &Terminal"),
                lambda: ToolProcess.startTerminal(self, workdirProxy()),
                icon="terminal",
                shortcuts=GlobalShortcuts.openTerminal,
                tip=_("Open a terminal in the repo’s working directory"),
            ),

            ActionDef(
                _("Cop&y Repo Path"),
                lambda: self.repolessCopyPath(workdirProxy()),
                tip=_("Copy the absolute path to this repo’s working directory to the clipboard"),
            ),

            ActionDef(
                superprojectLabel,
                lambda: self.repolessOpenSuperproject(workdirProxy(), superproject),
                enabled=superprojectEnabled,
            ),

            ActionDef(
                _("Re&name\u2026"),
                lambda: self.repolessSetNickname(workdirProxy()),
                icon="rename",
            ),
        ]

    def repolessSetNickname(self, workdir: str):
        defaultName = Path(workdir).name
        oldNickname = settings.history.getRepoNickname(workdir, strict=True)

        title = _("Nickname for {0}", tquoe(defaultName))
        label = _("Enter or clear nickname for this repository:")
        leaveBlank = _("Leave blank to refer to this repo as {0}", tquoe(defaultName))
        hint = "<p>" + _p(  # Sync with ui_reposettingsdialog.py to keep existing translation
            "RepoSettingsDialog",
            "This nickname will appear within {app} in tab names, menus, etc. "
            "It does not change the actual name of the repo’s directory. "
            "Leave blank to clear the nickname.",
            app=qAppName())

        tid = TextInputDialog(self, title, label, subtitle=escamp(compactPath(workdir)), hint=hint)
        tid.lineEdit.setClearButtonEnabled(True)
        tid.lineEdit.setPlaceholderText(leaveBlank)
        tid.lineEdit.setText(oldNickname)
        tid.lineEdit.selectAll()
        tid.textAccepted.connect(lambda nick: self._repolessSetNicknameImpl(workdir, nick))
        tid.show()

    def _repolessSetNicknameImpl(self, workdir: str, nick: str):
        settings.history.setRepoNickname(workdir, nick)
        settings.history.setDirty()
        tabWidget = self.tabWidgetForWorkdirPath(workdir)
        if isinstance(tabWidget, RepoWidget):
            # Percolate to sidebar, etc.
            tabWidget.nameChange.emit()
        else:
            # RepoStub
            self.onRepoNameChanged()

    def repolessCopyPath(self, workdir: str):
        QApplication.clipboard().setText(workdir)
        self.statusBar2.showMessage(clipboardStatusMessage(workdir))

    def repolessOpenSuperproject(self, workdir: str, superproject: str | None = None):
        if superproject is None:
            superproject = settings.history.getRepoSuperproject(workdir)

        if not superproject:
            showInformation(self, _("Open Superproject"), _("This repository does not have a superproject."))
            return

        submoduleTab = self.tabWidgetForWorkdirPath(workdir)  # may be None
        self.openRepoNextTo(submoduleTab, superproject)
