#include "TerminalPageController.h" #include #include #include #include #include "AppModel.h" #include "Commands.h" #include "EditorController.h" #include "PanePool.h" #include "TerminalQuickItem.h" #include "WorkspaceController.h" namespace { // Accepts the splitFocused spellings ("splitRight") and the spatial // spellings ("right"); anything unknown falls back to Down. SplitDirection directionFromString(const QString& direction) { if (direction == QLatin1String("splitLeft") || direction == QLatin1String("left")) { return SplitDirection::Left; } if (direction == QLatin1String("splitRight") || direction == QLatin1String("right")) { return SplitDirection::Right; } if (direction == QLatin1String("splitUp") || direction == QLatin1String("up")) { return SplitDirection::Up; } return SplitDirection::Down; } // The Commands singleton is created lazily by the QML engine, so the runCommand // connection can need a retry. Poll at 20 ms and give up after 5 s: the // singleton appears while the QML tree loads, well inside that window. constexpr int kCommandsRetryIntervalMs = 20; constexpr int kCommandsRetryLimit = 250; } // namespace // --------------------------------------------------------------------------- // Construction / DI // --------------------------------------------------------------------------- TerminalPageController::TerminalPageController(QObject* parent) : QObject(parent) { m_commandsRetry.setInterval(kCommandsRetryIntervalMs); connect(&m_commandsRetry, &QTimer::timeout, this, [this]() { ensureCommandsConnected(); }); ensureModelConnected(); ensureCommandsConnected(); } TerminalPageController::~TerminalPageController() = default; // Mirror of ensureModelConnected() for the command stream. Commands is a lazy // QML singleton, and main.qml reaches it only after the page's controller is // constructed, so the constructor's attempt finds nothing. onRunCommand cannot // self-heal that: it needs the very connection it would repair. Poll the event // loop instead, and reconnect when the singleton is superseded (the QPointer // reads as null once the old instance dies). void TerminalPageController::ensureCommandsConnected() { Commands* commands = Commands::instance(); if (commands != nullptr && m_commands == commands) { return; } if (commands == nullptr) { if (m_commandsRetryTicks++ < kCommandsRetryLimit) { if (!m_commandsRetry.isActive()) { m_commandsRetry.start(); } } else { m_commandsRetry.stop(); } return; } connect(commands, &Commands::runCommand, this, &TerminalPageController::onRunCommand); m_commands = commands; m_commandsRetry.stop(); } void TerminalPageController::ensureModelConnected() { // Self-healing: the cached QPointer may have been invalidated if a // superseded singleton instance was collected. Re-resolve and reconnect. if (m_modelConnected && m_model) { return; } AppModel* model = AppModel::instance(); if (!model) { return; // Engine hasn't created the singleton yet; retry on next call. } if (m_modelConnected) { // The previous connection target died; the new instance needs a fresh // connect (the old one died with the sender). m_modelConnected = false; } m_model = model; connect(model, &AppModel::focusedPaneChanged, this, &TerminalPageController::onFocusedPaneChanged); m_modelConnected = true; m_focusedPaneId = model->focusedPaneId(); m_pool = PanePool::instance(); } void TerminalPageController::setSplitHost(QQuickItem* host) { if (m_splitHost == host) { return; } m_splitHost = host; emit splitHostChanged(); } void TerminalPageController::setParkHost(QQuickItem* host) { if (m_parkHost == host) { return; } m_parkHost = host; emit parkHostChanged(); } void TerminalPageController::setShellShim(QObject* shim) { if (m_shellShim == shim) { return; } m_shellShim = shim; emit shellShimChanged(); } void TerminalPageController::setWorkspaceController(QObject* controller) { if (m_workspaceController == controller) { return; } m_workspaceController = controller; emit workspaceControllerChanged(); } // --------------------------------------------------------------------------- // Facade-index helpers // --------------------------------------------------------------------------- int TerminalPageController::activeWsIndex() const { if (!m_model) { return -1; } return m_model->workspaceIndexOf(m_model->activeWorkspaceId()); } int TerminalPageController::activeWorkspaceIndex() const { // Ported from CommandRegistry.qml activeWorkspaceIndex(): id-compare // walk, 0 fallback when no workspace matches. if (!m_model) { return 0; } const QString activeId = m_model->activeWorkspaceId(); const QList& workspaces = m_model->workspaces(); for (const auto [index, workspace] : std::views::zip(std::views::iota(0), workspaces)) { if (workspace->id() == activeId) { return index; } } return 0; } int TerminalPageController::activeTabIndex() const { if (!m_model) { return -1; } const int wsIndex = activeWsIndex(); if (wsIndex < 0) { return -1; } const QList& workspaces = m_model->workspaces(); if (wsIndex >= workspaces.size()) { return -1; } return workspaces.at(wsIndex)->activeTabIndex(); } // --------------------------------------------------------------------------- // Page-facing handlers // --------------------------------------------------------------------------- void TerminalPageController::newTab() { ensureModelConnected(); if (!m_model) { return; } const int wsIndex = activeWsIndex(); m_model->newTab(wsIndex < 0 ? 0 : wsIndex); } void TerminalPageController::closeTab(int wsIndex, int tabIndex) { ensureModelConnected(); if (!m_model) { return; } const QList& workspaces = m_model->workspaces(); if (wsIndex < 0 || wsIndex >= workspaces.size()) { return; } const Workspace* workspace = workspaces.at(wsIndex); if (workspace->tabs().size() <= 1) { // Last tab: the UI asks before the model removes it (the model's // closeTab removes unconditionally). Page decides, then calls the // workspace-delete path itself. emit closeLastTabRequested(wsIndex, workspace->name()); return; } m_model->closeTab(wsIndex, tabIndex); } void TerminalPageController::selectTab(int wsIndex, int tabIndex) { ensureModelConnected(); if (m_model) { m_model->selectTab(wsIndex, tabIndex); } } void TerminalPageController::selectWorkspace(int index) { ensureModelConnected(); if (m_model) { m_model->selectWorkspace(index); } } void TerminalPageController::nextTab() { ensureModelConnected(); if (m_model) { m_model->nextTab(); } } void TerminalPageController::previousTab() { ensureModelConnected(); if (m_model) { m_model->previousTab(); } } void TerminalPageController::nextWorkspace() { ensureModelConnected(); if (m_model) { m_model->nextWorkspace(); } } void TerminalPageController::previousWorkspace() { ensureModelConnected(); if (m_model) { m_model->previousWorkspace(); } } void TerminalPageController::selectWorkspaceByNumber(int number) { ensureModelConnected(); if (m_model) { m_model->selectWorkspaceByNumber(number); } } void TerminalPageController::splitFocused(const QString& direction) { ensureModelConnected(); if (!m_model) { return; } const QString paneId = m_model->focusedPaneId(); if (paneId.isEmpty()) { return; } m_model->splitPane(paneId, directionFromString(direction)); } void TerminalPageController::closeFocused() { ensureModelConnected(); if (!m_model) { return; } const QString paneId = m_model->focusedPaneId(); if (!paneId.isEmpty()) { m_model->closePane(paneId); } } QString TerminalPageController::openPathInEditor(const QString& anchorPaneId, const QString& path, int line, int column) { ensureModelConnected(); if (m_model == nullptr || path.isEmpty() || anchorPaneId.isEmpty()) { return {}; } QString existing = m_model->editorPaneShowingPath(anchorPaneId, path); if (!existing.isEmpty()) { m_model->focusPane(existing); if (line > 0) { PanePool* pool = m_pool.data() != nullptr ? m_pool.data() : PanePool::instance(); if (pool != nullptr) { EditorController* controller = pool->editorControllerFor(existing); if (controller != nullptr) { controller->requestCursor(line - 1, qMax(0, column - 1)); } } } return existing; } return m_model->splitPaneWithKind(anchorPaneId, QStringLiteral("right"), QStringLiteral("editor"), path, line, column); } void TerminalPageController::keepOnlyFocused() { ensureModelConnected(); if (!m_model) { return; } const QString paneId = m_model->focusedPaneId(); if (!paneId.isEmpty()) { m_model->keepOnlyPane(paneId); } } void TerminalPageController::focusNext() { ensureModelConnected(); if (!m_model) { return; } const int wsIndex = activeWsIndex(); const int tabIndexInWs = activeTabIndex(); if (wsIndex < 0 || tabIndexInWs < 0) { return; } SplitContainerNode* tree = m_model->tabTree(wsIndex, tabIndexInWs); if (!tree) { return; } const QStringList ids = paneIdsInLayoutOrder(*tree); const qsizetype count = ids.size(); if (count == 0) { return; } const qsizetype current = ids.indexOf(m_model->focusedPaneId()); const qsizetype next = (current + 1) % count; m_model->focusPane(ids.at(next)); } void TerminalPageController::focusPrevious() { ensureModelConnected(); if (!m_model) { return; } const int wsIndex = activeWsIndex(); const int tabIndexInWs = activeTabIndex(); if (wsIndex < 0 || tabIndexInWs < 0) { return; } SplitContainerNode* tree = m_model->tabTree(wsIndex, tabIndexInWs); if (!tree) { return; } const QStringList ids = paneIdsInLayoutOrder(*tree); const qsizetype count = ids.size(); if (count == 0) { return; } const qsizetype current = ids.indexOf(m_model->focusedPaneId()); const qsizetype previous = (current - 1 + count) % count; m_model->focusPane(ids.at(previous)); } void TerminalPageController::focusDirection(const QString& direction) { ensureModelConnected(); if (!m_model) { return; } const QString paneId = m_model->focusedPaneId(); if (paneId.isEmpty()) { return; } m_model->focusDirection(paneId, directionFromString(direction)); } void TerminalPageController::swapDirection(const QString& direction) { ensureModelConnected(); if (!m_model) { return; } const QString srcId = m_model->focusedPaneId(); if (srcId.isEmpty()) { return; } const QString targetId = spatialNeighborPaneId(srcId, direction); if (targetId.isEmpty()) { return; } m_model->swapPanes(srcId, targetId); } void TerminalPageController::copyFocused() { ensureModelConnected(); if (!m_model) { return; } m_pool = PanePool::instance(); if (!m_pool) { return; } auto* term = qobject_cast( m_pool->item(m_model->focusedPaneId())); if (term) { term->copySelection(); } } void TerminalPageController::pasteFocused() { ensureModelConnected(); if (!m_model) { return; } m_pool = PanePool::instance(); if (!m_pool) { return; } auto* term = qobject_cast( m_pool->item(m_model->focusedPaneId())); if (term) { term->pasteFromClipboard(); } } void TerminalPageController::resetFocused() { ensureModelConnected(); if (!m_model) { return; } m_pool = PanePool::instance(); if (!m_pool) { return; } auto* term = qobject_cast( m_pool->item(m_model->focusedPaneId())); if (term) { term->resetTerminal(); } } void TerminalPageController::resetLayout() { ensureModelConnected(); if (m_model) { m_model->resetLayout(); } } void TerminalPageController::jumpTo(const QVariantMap& candidate) { ensureModelConnected(); if (!m_model) { return; } const QString paneId = candidate.value(QStringLiteral("paneId")).toString(); if (!paneId.isEmpty()) { m_model->revealPane(paneId); return; } const QString tabId = candidate.value(QStringLiteral("tabId")).toString(); if (!tabId.isEmpty()) { const QString workspaceId = candidate.value(QStringLiteral("workspaceId")).toString(); const int wsIndex = m_model->workspaceIndexOf(workspaceId); if (wsIndex >= 0) { m_model->selectWorkspace(wsIndex); m_model->selectTab(wsIndex, m_model->tabIndexOf(workspaceId, tabId)); } return; } const QString workspaceId = candidate.value(QStringLiteral("workspaceId")).toString(); if (!workspaceId.isEmpty()) { m_model->selectWorkspace(m_model->workspaceIndexOf(workspaceId)); } } void TerminalPageController::paneClicked(const QString& paneId) { ensureModelConnected(); if (m_model && !paneId.isEmpty()) { m_model->focusPane(paneId); } } void TerminalPageController::refocusFocused() { ensureModelConnected(); if (!m_model) { return; } m_pool = PanePool::instance(); if (!m_pool) { return; } const QString paneId = m_model->focusedPaneId(); if (paneId.isEmpty()) { return; } auto* term = qobject_cast(m_pool->item(paneId)); if (term) { term->forceActiveFocus(); } } QObject* TerminalPageController::focusedPaneItem() { // Resolve the model lazily: the controller is constructed before the // AppModel QML singleton exists, and the boot focusedPaneChanged emit // may predate the connect — so the initial id is picked up here rather // than only via signal. ensureModelConnected(); if (!m_model) { return nullptr; } const QString paneId = m_model->focusedPaneId(); if (paneId.isEmpty()) { return nullptr; } PanePool* pool = m_pool.data(); if (!pool) { pool = PanePool::instance(); } if (pool) { return pool->item(paneId); } return nullptr; } // --------------------------------------------------------------------------- // Focus mirror // --------------------------------------------------------------------------- void TerminalPageController::onFocusedPaneChanged(const QString& paneId) { ensureModelConnected(); if (m_focusedPaneId != paneId) { m_focusedPaneId = paneId; emit focusedPaneIdChanged(); } if (paneId.isEmpty()) { return; } m_pool = PanePool::instance(); if (!m_pool) { return; } auto* term = qobject_cast(m_pool->item(paneId)); if (term) { term->forceActiveFocus(); } } // --------------------------------------------------------------------------- // Pane-level helpers (by paneId through the pool) // --------------------------------------------------------------------------- QString TerminalPageController::spatialNeighborPaneId( const QString& fromPaneId, const QString& direction) const { if (fromPaneId.isEmpty() || !m_model) { return {}; } const int wsIndex = activeWsIndex(); const int tabIndexInWs = activeTabIndex(); if (wsIndex < 0 || tabIndexInWs < 0) { return {}; } SplitContainerNode* tree = m_model->tabTree(wsIndex, tabIndexInWs); if (!tree) { return {}; } // Model-native: the same tree geometry the model owns. return ::spatialNeighborPaneId(*tree, fromPaneId, direction); } // --------------------------------------------------------------------------- // Commands pub-sub // --------------------------------------------------------------------------- void TerminalPageController::onRunCommand(const QString& commandId, const QVariant& ctx) { // ctx arrives as a QVariant: a map {index: int, sidebar: QVariant()} for // the workspace-row commands, or invalid for global ones. Safe-extract. const QVariantMap ctxMap = ctx.toMap(); const int ctxIndex = ctxMap.value("index", -1).toInt(); // Workspace commands route through the injected WorkspaceController (the // page assigns it; guarded while unwired). auto* workspaceController = qobject_cast(m_workspaceController.data()); if (commandId == QLatin1String("jumpToPane")) { emit openJumpRequested(); } else if (commandId == QLatin1String("commandPalette")) { emit openPaletteRequested(); } else if (commandId == QLatin1String("preferences")) { emit openSettingsRequested(); } else if (commandId == QLatin1String("missionControl")) { emit toggleMissionControlRequested(); } else if (commandId == QLatin1String("toggleSidebar")) { emit toggleSidebarRequested(); } else if (commandId == QLatin1String("newWorkspace")) { // The sidebar owns the AddWorkspaceMenu popup. emit openAddWorkspaceRequested(); } else if (commandId == QLatin1String("addSameDirectory")) { if (workspaceController) { // Kind 0 = Directory (WorkspaceKind enum order). workspaceController->addWorkspaceDirect( 0, WorkspaceController::currentDir(), "", ""); } } else if (commandId == QLatin1String("addJjWorkspace")) { if (workspaceController) { workspaceController->pickJjWorkspace(ctxIndex); } } else if (commandId == QLatin1String("addGitWorktree")) { if (workspaceController) { workspaceController->pickGitWorktree(ctxIndex); } } else if (commandId == QLatin1String("addLocation")) { emit openPathFuzzyRequested(); } else if (commandId == QLatin1String("closeWorkspace")) { if (m_model) { m_model->deleteWorkspace(activeWorkspaceIndex()); } } else if (commandId == QLatin1String("workspaceSetDirectory")) { emit setDirectoryRequested(ctxIndex); } else if (commandId == QLatin1String("workspaceForgetJj")) { if (workspaceController && ctxIndex >= 0) { workspaceController->forgetJjWorkspace(ctxIndex); } } else if (commandId == QLatin1String("workspaceDelete")) { if (m_model && ctxIndex >= 0) { m_model->deleteWorkspace(ctxIndex); } } else if (commandId == QLatin1String("workspaceRename")) { // The sidebar owns the inline rename edit UI. emit renameWorkspaceRequested(ctxIndex); } else if (commandId == QLatin1String("renameTab")) { // The rename dialog is a UI decision point; the page/main.qml owns it. emit renameTabRequested(); } else if (commandId == QLatin1String("openSearch") || commandId == QLatin1String("closeSearch")) { // The find bar is per-pane chrome: an editor pane flips its pooled // controller's flag, a terminal pane leaves it to the page. The pane menu // passes its own {paneId} context; the keyboard path passes null and means // the focused pane. ensureModelConnected(); QString paneId = ctxMap.value(QStringLiteral("paneId")).toString(); if (paneId.isEmpty() && m_model != nullptr) { paneId = m_model->focusedPaneId(); } if (m_model != nullptr && !paneId.isEmpty() && m_model->paneKindOf(paneId) == QStringLiteral("editor")) { PanePool* pool = PanePool::instance(); EditorController* editor = pool != nullptr ? pool->editorControllerFor(paneId) : nullptr; if (editor != nullptr) { if (commandId == QLatin1String("openSearch")) { editor->setSearchBarOpen(true); } else { // Closing ends the search, the same way the bar's own close does: // one rule for the ✕, Escape and the command. editor->clearSearch(); editor->setSearchBarOpen(false); } } } else if (commandId == QLatin1String("openSearch")) { // A pane-targeted UI decision point: the page owns the search bar. emit openSearchRequested(); } else { emit closeSearchRequested(); } } else if (commandId == QLatin1String("searchNext")) { if (auto* term = qobject_cast(focusedPaneItem())) { term->searchNext(); } } else if (commandId == QLatin1String("searchPrevious")) { if (auto* term = qobject_cast(focusedPaneItem())) { term->searchPrevious(); } } else if (commandId == QLatin1String("enterCopyMode")) { if (auto* term = qobject_cast(focusedPaneItem())) { term->enterCopyMode(); } } else if (commandId == QLatin1String("exitCopyMode")) { if (auto* term = qobject_cast(focusedPaneItem())) { term->exitCopyMode(); } } else if (commandId == QLatin1String("editorUndo") || commandId == QLatin1String("editorRedo")) { // The buffer lives in the pooled controller. The pane menu passes its own // {paneId} context; the keyboard path passes null and means the focused // pane. ensureModelConnected(); QString paneId = ctxMap.value(QStringLiteral("paneId")).toString(); if (paneId.isEmpty() && m_model != nullptr) { paneId = m_model->focusedPaneId(); } PanePool* pool = PanePool::instance(); EditorController* editor = pool != nullptr ? pool->editorControllerFor(paneId) : nullptr; if (editor != nullptr) { if (commandId == QLatin1String("editorUndo")) { editor->undo(); } else { editor->redo(); } } } }