#include "Commands.h" #include #include #include #include #include #include #include "AppModel.h" #include "PanePool.h" #include "TerminalQuickItem.h" #include "Workspace.h" #include "XenoSettings.h" // --------------------------------------------------------------------------- // CommandDef // --------------------------------------------------------------------------- CommandDef::CommandDef(QString commandId, QString title, QString category, QStringList menus, QObject *parent) : QObject(parent), m_commandId(std::move(commandId)), m_title(std::move(title)), m_category(std::move(category)), m_menus(std::move(menus)) {} void CommandDef::setEnabled(bool enabled) { if (m_enabled == enabled) { return; } m_enabled = enabled; emit enabledChanged(); } QString CommandDef::shortcutHint() const { if (m_sequence.isEmpty()) { return {}; } // On macOS "Ctrl" IS the Cmd key (Qt's modifier mapping); render the // human display form accordingly. #ifdef Q_OS_MACOS QString hint = m_sequence; return hint.replace(QStringLiteral("Ctrl"), QStringLiteral("Cmd")); #else return m_sequence; #endif } CommandDef *CommandDef::setMenuLabel(QString label) { m_menuLabel = std::move(label); return this; } void CommandDef::applySequence(QString sequence) { if (m_sequence == sequence) { return; } m_sequence = std::move(sequence); emit sequenceChanged(); } CommandDef *CommandDef::setSequence(QString sequence) { m_defaultSequence = sequence; m_sequence = std::move(sequence); return this; } CommandDef *CommandDef::setIntercept(bool intercept) { m_intercept = intercept; return this; } CommandDef *CommandDef::setSeparators(QStringList separators) { m_separators = std::move(separators); return this; } CommandDef *CommandDef::setPaletteVisible(bool visible) { m_paletteVisible = visible; return this; } CommandDef *CommandDef::setTarget(QString target) { m_target = std::move(target); return this; } // --------------------------------------------------------------------------- // Commands singleton // --------------------------------------------------------------------------- Commands *Commands::s_instance = nullptr; Commands::Commands(QObject *parent) : QObject(parent) { s_instance = this; ensureModelConnected(); // --- Clipboard ------------------------------------------------------------ // The QML registry derived copy's global `enabled` from the focused pane's // hasSelection, but selection changes do not notify the model, so the // binding could go stale. Keep both enabled=true globally; the ctx-gated // commandEnabledFor decides (menu refresh calls commandEnabledFor before // showing). // clang-format off auto* copy = new CommandDef( QStringLiteral("copy"), tr("Copy"), QStringLiteral("clipboard"), {QStringLiteral("edit"), QStringLiteral("pane")}, this); copy->setSequence(QStringLiteral("Ctrl+C"))->setIntercept(false) ->setTarget(QStringLiteral("terminalPage")); // clang-format on m_commands.append(copy); auto *paste = new CommandDef( QStringLiteral("paste"), tr("Paste"), QStringLiteral("clipboard"), {QStringLiteral("edit"), QStringLiteral("pane")}, this); paste->setSequence(QStringLiteral("Ctrl+V")) ->setIntercept(false) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(paste); // --- Pane splitting // --------------------------------------------------------- auto *splitHorizontal = new CommandDef(QStringLiteral("splitHorizontal"), tr("Split Horizontal"), QStringLiteral("pane"), {QStringLiteral("view"), QStringLiteral("pane")}, this); splitHorizontal->setSequence(QStringLiteral("Ctrl+Shift+H")) ->setSeparators({QStringLiteral("pane")}) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(splitHorizontal); auto *splitVertical = new CommandDef(QStringLiteral("splitVertical"), tr("Split Vertical"), QStringLiteral("pane"), {QStringLiteral("view"), QStringLiteral("pane")}, this); splitVertical->setSequence(QStringLiteral("Ctrl+Shift+V")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(splitVertical); auto *splitWithTerminal = new CommandDef(QStringLiteral("splitWithTerminal"), tr("Split with Terminal"), QStringLiteral("pane"), {QStringLiteral("view"), QStringLiteral("pane")}, this); splitWithTerminal->setSequence(QStringLiteral("Ctrl+Shift+T")) ->setSeparators({QStringLiteral("pane")}) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(splitWithTerminal); auto *splitWithEditor = new CommandDef(QStringLiteral("splitWithEditor"), tr("Split with Editor"), QStringLiteral("pane"), {QStringLiteral("view"), QStringLiteral("pane")}, this); splitWithEditor->setSequence(QStringLiteral("Ctrl+Shift+E")) ->setSeparators({QStringLiteral("pane")}) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(splitWithEditor); // --- Pane lifecycle // --------------------------------------------------------- auto *closePane = new CommandDef( QStringLiteral("closePane"), tr("Close Pane"), QStringLiteral("pane"), {QStringLiteral("view"), QStringLiteral("pane")}, this); closePane->setSequence(QStringLiteral("Ctrl+Alt+W")) ->setSeparators({QStringLiteral("view"), QStringLiteral("pane")}) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(closePane); auto *keepOnlyPane = new CommandDef(QStringLiteral("keepOnlyPane"), tr("Keep Only This Pane"), QStringLiteral("pane"), {QStringLiteral("view"), QStringLiteral("pane")}, this); keepOnlyPane->setSequence(QStringLiteral("Ctrl+Alt+O")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(keepOnlyPane); auto *focusNextPane = new CommandDef(QStringLiteral("focusNextPane"), tr("Focus Next Pane"), QStringLiteral("pane"), {QStringLiteral("view"), QStringLiteral("pane")}, this); focusNextPane->setSequence(QStringLiteral("Ctrl+]")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(focusNextPane); // --- Pane zoom ------------------------------------------------------------ // One per-tab zoom state on XenoTab; maximize is a zoom that also // re-asserts focus (they share the model state, two spellings of the // intent). globalEnabled's "pane"-category gate (hasFocus) is exactly // "a zoom is possible or active": a zoom needs a focused pane, and an // active zoom implies one (the zoomed pane keeps focus). auto *togglePaneZoom = new CommandDef(QStringLiteral("togglePaneZoom"), tr("Toggle Pane Zoom"), QStringLiteral("pane"), {QStringLiteral("view"), QStringLiteral("pane")}, this); togglePaneZoom->setSequence(QStringLiteral("Ctrl+Z")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(togglePaneZoom); auto *maximizePane = new CommandDef(QStringLiteral("maximizePane"), tr("Maximize Pane"), QStringLiteral("pane"), {QStringLiteral("pane")}, this); maximizePane->setSequence(QStringLiteral("Ctrl+Shift+Z")) ->setSeparators({QStringLiteral("pane")}) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(maximizePane); // --- Pane focus movement (shortcuts + palette only) // -------------------------- auto *focusPaneLeft = new CommandDef(QStringLiteral("focusPaneLeft"), tr("Focus Pane Left"), QStringLiteral("pane"), {}, this); focusPaneLeft->setSequence(QStringLiteral("Ctrl+Left")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(focusPaneLeft); auto *focusPaneRight = new CommandDef(QStringLiteral("focusPaneRight"), tr("Focus Pane Right"), QStringLiteral("pane"), {}, this); focusPaneRight->setSequence(QStringLiteral("Ctrl+Right")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(focusPaneRight); auto *focusPaneUp = new CommandDef(QStringLiteral("focusPaneUp"), tr("Focus Pane Up"), QStringLiteral("pane"), {}, this); focusPaneUp->setSequence(QStringLiteral("Ctrl+Up")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(focusPaneUp); auto *focusPaneDown = new CommandDef(QStringLiteral("focusPaneDown"), tr("Focus Pane Down"), QStringLiteral("pane"), {}, this); focusPaneDown->setSequence(QStringLiteral("Ctrl+Down")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(focusPaneDown); // --- Pane swapping (shortcuts + palette only) // -------------------------------- auto *swapPaneLeft = new CommandDef(QStringLiteral("swapPaneLeft"), tr("Swap Pane Left"), QStringLiteral("pane"), {}, this); swapPaneLeft->setSequence(QStringLiteral("Ctrl+Shift+Left")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(swapPaneLeft); auto *swapPaneRight = new CommandDef(QStringLiteral("swapPaneRight"), tr("Swap Pane Right"), QStringLiteral("pane"), {}, this); swapPaneRight->setSequence(QStringLiteral("Ctrl+Shift+Right")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(swapPaneRight); auto *swapPaneUp = new CommandDef(QStringLiteral("swapPaneUp"), tr("Swap Pane Up"), QStringLiteral("pane"), {}, this); swapPaneUp->setSequence(QStringLiteral("Ctrl+Shift+Up")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(swapPaneUp); auto *swapPaneDown = new CommandDef(QStringLiteral("swapPaneDown"), tr("Swap Pane Down"), QStringLiteral("pane"), {}, this); swapPaneDown->setSequence(QStringLiteral("Ctrl+Shift+Down")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(swapPaneDown); // --- Terminal reset // ----------------------------------------------------------- auto *resetTerminal = new CommandDef(QStringLiteral("resetTerminal"), tr("Reset Terminal"), QStringLiteral("pane"), {QStringLiteral("edit"), QStringLiteral("pane")}, this); resetTerminal->setMenuLabel(tr("Reset")) ->setSeparators({QStringLiteral("edit"), QStringLiteral("pane")}) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(resetTerminal); // --- Scrollback search ------------------------------------------------ // openSearch/closeSearch are pane-targeted UI decisions (the search bar // lives on the pane); next/previous route to the focused pane's item. auto *openSearch = new CommandDef(QStringLiteral("openSearch"), tr("Find in Terminal"), QStringLiteral("pane"), {}, this); openSearch->setSequence(QStringLiteral("Ctrl+F")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(openSearch); auto *closeSearch = new CommandDef(QStringLiteral("closeSearch"), tr("Close Search"), QStringLiteral("pane"), {}, this); // Escape is handled by the search bar itself (Keys.onEscapePressed) and by // the copy-mode key path on the focused item. It must NOT enter the global // intercept whitelist, because that accepts the ShortcutOverride for every // Escape and stops the key from ever reaching the program in the pane. closeSearch->setSequence(QStringLiteral("Esc")) ->setIntercept(false) ->setPaletteVisible(false) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(closeSearch); auto *searchNext = new CommandDef(QStringLiteral("searchNext"), tr("Next Match"), QStringLiteral("pane"), {}, this); searchNext->setSequence(QStringLiteral("Ctrl+G")) ->setPaletteVisible(false) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(searchNext); auto *searchPrevious = new CommandDef(QStringLiteral("searchPrevious"), tr("Previous Match"), QStringLiteral("pane"), {}, this); searchPrevious->setSequence(QStringLiteral("Ctrl+Shift+G")) ->setPaletteVisible(false) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(searchPrevious); // --- Copy mode ----------------------------------------------------------- // enterCopyMode/exitCopyMode are pane-targeted UI decisions routed like // the search commands; move/select/yank route straight to the focused // pane's item (the mode's own key handling drives them normally). auto *enterCopyMode = new CommandDef(QStringLiteral("enterCopyMode"), tr("Copy Mode"), QStringLiteral("pane"), {}, this); enterCopyMode->setTarget(QStringLiteral("terminalPage")); m_commands.append(enterCopyMode); auto *exitCopyMode = new CommandDef(QStringLiteral("exitCopyMode"), tr("Leave Copy Mode"), QStringLiteral("pane"), {}, this); exitCopyMode->setPaletteVisible(false) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(exitCopyMode); // --- Tabs ----------------------------------------------------------------- auto *newTab = new CommandDef( QStringLiteral("newTab"), tr("New Tab"), QStringLiteral("tab"), {QStringLiteral("file"), QStringLiteral("workspace")}, this); newTab->setSequence(QStringLiteral("Ctrl+N")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(newTab); auto *closeTab = new CommandDef(QStringLiteral("closeTab"), tr("Close Tab"), QStringLiteral("tab"), {QStringLiteral("file")}, this); closeTab->setSequence(QStringLiteral("Ctrl+W")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(closeTab); auto *nextTab = new CommandDef(QStringLiteral("nextTab"), tr("Next Tab"), QStringLiteral("tab"), {}, this); nextTab->setSequence(QStringLiteral("Ctrl+PgDown")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(nextTab); auto *previousTab = new CommandDef(QStringLiteral("previousTab"), tr("Previous Tab"), QStringLiteral("tab"), {}, this); previousTab->setSequence(QStringLiteral("Ctrl+PgUp")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(previousTab); // Tab reorder: move the ACTIVE tab one slot left/right inside its // workspace. No-op at the strip's ends; the enabled predicate below // mirrors the model's guards exactly. auto *moveTabLeft = new CommandDef(QStringLiteral("moveTabLeft"), tr("Move Tab Left"), QStringLiteral("tab"), {}, this); moveTabLeft->setSequence(QStringLiteral("Ctrl+Shift+PgUp")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(moveTabLeft); auto *moveTabRight = new CommandDef(QStringLiteral("moveTabRight"), tr("Move Tab Right"), QStringLiteral("tab"), {}, this); moveTabRight->setSequence(QStringLiteral("Ctrl+Shift+PgDown")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(moveTabRight); auto *renameTab = new CommandDef(QStringLiteral("renameTab"), tr("Rename Tab…"), QStringLiteral("tab"), {QStringLiteral("file")}, this); renameTab->setTarget(QStringLiteral("terminalPage")); m_commands.append(renameTab); // --- Workspace navigation (palette + shortcuts) // ------------------------------- auto *previousWorkspace = new CommandDef( QStringLiteral("previousWorkspace"), tr("Previous Workspace"), QStringLiteral("workspace"), {}, this); previousWorkspace->setSequence(QStringLiteral("Alt+Up")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(previousWorkspace); auto *nextWorkspace = new CommandDef(QStringLiteral("nextWorkspace"), tr("Next Workspace"), QStringLiteral("workspace"), {}, this); nextWorkspace->setSequence(QStringLiteral("Alt+Down")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(nextWorkspace); // Jump-to-workspace N (1..9): paletteVisible=false; enabled when that // many workspaces exist. for (const int n : std::views::iota(1, 10)) { auto *jump = new CommandDef(QStringLiteral("selectWorkspace%1").arg(n), tr("Jump to Workspace %1").arg(n), QStringLiteral("workspace"), {}, this); jump->setSequence(QStringLiteral("Ctrl+%1").arg(n)) ->setPaletteVisible(false) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(jump); } // --- Fuzzy jump (Cmd+P) and the command palette (Cmd+Shift+P) // --------------- auto *jumpToPane = new CommandDef(QStringLiteral("jumpToPane"), tr("Jump to Pane"), QStringLiteral("workspace"), {}, this); jumpToPane->setSequence(QStringLiteral("Ctrl+P")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(jumpToPane); auto *commandPalette = new CommandDef(QStringLiteral("commandPalette"), tr("Command Palette"), QStringLiteral("app"), {}, this); commandPalette->setSequence(QStringLiteral("Ctrl+Shift+P")) ->setPaletteVisible(false) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(commandPalette); // --- View ----------------------------------------------------------------- auto *resetLayout = new CommandDef(QStringLiteral("resetLayout"), tr("Reset Layout"), QStringLiteral("view"), {QStringLiteral("file")}, this); resetLayout->setTarget(QStringLiteral("terminalPage")); m_commands.append(resetLayout); auto *equalizeSplits = new CommandDef(QStringLiteral("equalizeSplits"), tr("Equalize Splits"), QStringLiteral("view"), {QStringLiteral("view")}, this); equalizeSplits->setTarget(QStringLiteral("terminalPage")); m_commands.append(equalizeSplits); auto *toggleSidebar = new CommandDef(QStringLiteral("toggleSidebar"), tr("Toggle Sidebar"), QStringLiteral("view"), {}, this); toggleSidebar->setSequence(QStringLiteral("Ctrl+B")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(toggleSidebar); auto *missionControl = new CommandDef(QStringLiteral("missionControl"), tr("Mission Control"), QStringLiteral("view"), {}, this); missionControl->setSequence(QStringLiteral("Ctrl+M")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(missionControl); // --- Application ----------------------------------------------------------- auto *preferences = new CommandDef(QStringLiteral("preferences"), tr("Preferences…"), QStringLiteral("app"), {QStringLiteral("app")}, this); preferences->setSequence(QStringLiteral("Ctrl+,")) ->setTarget(QStringLiteral("terminalPage")); m_commands.append(preferences); auto *quit = new CommandDef(QStringLiteral("quit"), tr("Quit Xenomorphic"), QStringLiteral("app"), {QStringLiteral("app")}, this); quit->setSequence(QStringLiteral("Ctrl+Q")) ->setSeparators({QStringLiteral("app")}) ->setTarget(QStringLiteral("app")); m_commands.append(quit); // --- New workspace (opens the AddWorkspaceMenu dropdown) ------------------- auto *newWorkspace = new CommandDef(QStringLiteral("newWorkspace"), tr("New Workspace…"), QStringLiteral("add"), {QStringLiteral("file")}, this); newWorkspace->setSeparators({QStringLiteral("file")}) ->setPaletteVisible(false) ->setTarget(QStringLiteral("workspaces")); m_commands.append(newWorkspace); // --- Add-workspace sources (AddWorkspaceMenu + workspace-row menu) // -------------------------------------------------------------------------- auto *addSameDirectory = new CommandDef(QStringLiteral("addSameDirectory"), tr("Same Directory"), QStringLiteral("add"), {QStringLiteral("add")}, this); addSameDirectory->setTarget(QStringLiteral("workspaces")); m_commands.append(addSameDirectory); auto *addJjWorkspace = new CommandDef(QStringLiteral("addJjWorkspace"), tr("New jj Workspace"), QStringLiteral("add"), {QStringLiteral("add"), QStringLiteral("workspace")}, this); addJjWorkspace->setSeparators({QStringLiteral("workspace")}) ->setTarget(QStringLiteral("workspaces")); m_commands.append(addJjWorkspace); auto *addGitWorktree = new CommandDef(QStringLiteral("addGitWorktree"), tr("New Git Worktree"), QStringLiteral("add"), {QStringLiteral("add"), QStringLiteral("workspace")}, this); addGitWorktree->setTarget(QStringLiteral("workspaces")); m_commands.append(addGitWorktree); auto *addLocation = new CommandDef(QStringLiteral("addLocation"), tr("New Location…"), QStringLiteral("add"), {QStringLiteral("add")}, this); addLocation->setTarget(QStringLiteral("workspaces")); m_commands.append(addLocation); // --- Close workspace // --------------------------------------------------------- Deletes the // active workspace; deleting the last workspace rebuilds a default one // (AppModel::deleteWorkspace never leaves zero workspaces). auto *closeWorkspace = new CommandDef( QStringLiteral("closeWorkspace"), tr("Close Workspace"), QStringLiteral("workspace"), {QStringLiteral("file")}, this); closeWorkspace->setTarget(QStringLiteral("workspaces")); m_commands.append(closeWorkspace); // --- Workspace row commands (target the row the menu was opened on) -------- // Context is {index, sidebar}. Menus-only: paletteVisible=false and no // global shortcut. auto *workspaceRename = new CommandDef( QStringLiteral("workspaceRename"), tr("Rename"), QStringLiteral("workspace"), {QStringLiteral("workspace")}, this); workspaceRename->setPaletteVisible(false)->setTarget( QStringLiteral("workspaces")); m_commands.append(workspaceRename); auto *workspaceSetDirectory = new CommandDef( QStringLiteral("workspaceSetDirectory"), tr("Set Directory"), QStringLiteral("workspace"), {QStringLiteral("workspace")}, this); workspaceSetDirectory->setPaletteVisible(false)->setTarget( QStringLiteral("workspaces")); m_commands.append(workspaceSetDirectory); auto *workspaceForgetJj = new CommandDef( QStringLiteral("workspaceForgetJj"), tr("Forget jj Workspace"), QStringLiteral("workspace"), {QStringLiteral("workspace")}, this); workspaceForgetJj->setSeparators({QStringLiteral("workspace")}) ->setPaletteVisible(false) ->setTarget(QStringLiteral("workspaces")); m_commands.append(workspaceForgetJj); auto *workspaceDelete = new CommandDef( QStringLiteral("workspaceDelete"), tr("Delete Workspace"), QStringLiteral("workspace"), {QStringLiteral("workspace")}, this); workspaceDelete->setSeparators({QStringLiteral("workspace")}) ->setPaletteVisible(false) ->setTarget(QStringLiteral("workspaces")); m_commands.append(workspaceDelete); for (CommandDef *command : std::as_const(m_commands)) { m_byId.insert(command->commandId(), command); } recomputeEnabled(); // Resolve effective sequences from the persisted overrides and keep // following them. ensureModelConnected retries via the event loop when // the AppModel singleton is not up yet, so applyOverrides also runs on // the (re)connect path. applyOverrides(); // The app-level command: quitting needs no controller. connect(this, &Commands::runCommand, this, [](const QString &commandId) { if (commandId != QLatin1String("quit")) { return; } if (QGuiApplication *gui = qGuiApp) { // Queued so the dispatch() caller unwinds before the loop exits. QMetaObject::invokeMethod(gui, &QCoreApplication::quit, Qt::QueuedConnection); } }); } Commands::~Commands() { if (s_instance == this) { s_instance = nullptr; } } Commands *Commands::instance() { return s_instance; } Commands *Commands::create(QQmlEngine *engine, QJSEngine * /*scriptEngine*/) { auto *commands = new Commands(engine); QQmlEngine::setObjectOwnership(commands, QQmlEngine::CppOwnership); return commands; } // --------------------------------------------------------------------------- // Registry reads // --------------------------------------------------------------------------- QVariantList Commands::commands() const { QVariantList out; out.reserve(m_commands.size()); for (CommandDef *command : m_commands) { out.append(QVariant::fromValue(command)); } return out; } QVariantList Commands::shortcutCommands() const { QVariantList out; for (CommandDef *command : m_commands) { if (!command->sequence().isEmpty()) { out.append(QVariant::fromValue(command)); } } return out; } QVariantList Commands::paletteCommands() const { QVariantList out; for (CommandDef *command : m_commands) { if (command->paletteVisible()) { out.append(QVariant::fromValue(command)); } } return out; } QVariantList Commands::byMenu(const QString &menu) const { QVariantList out; for (CommandDef *command : m_commands) { if (command->menus().contains(menu)) { out.append(QVariant::fromValue(command)); } } return out; } QStringList Commands::whitelistSequences() const { QStringList out; QSet seen; for (CommandDef *command : m_commands) { const QString &sequence = command->sequence(); if (command->intercept() && !sequence.isEmpty() && !seen.contains(sequence)) { seen.insert(sequence); out.append(sequence); } } return out; } // --------------------------------------------------------------------------- // Sequence overrides // --------------------------------------------------------------------------- void Commands::applyOverrides() { ensureModelConnected(); const QVariantMap overrides = (m_model != nullptr && m_model->settings() != nullptr) ? m_model->settings()->commandSequences() : QVariantMap(); bool changed = false; for (CommandDef *command : std::as_const(m_commands)) { const QString overridden = overrides.value(command->commandId()).toString(); const QString next = overridden.isEmpty() ? command->defaultSequence() : overridden; if (command->sequence() != next) { changed = true; } command->applySequence(next); } if (changed) { emit commandsChanged(); } } QString Commands::commandForSequence(const QString &sequence) const { if (sequence.trimmed().isEmpty()) { return {}; } const QKeySequence wanted(sequence); if (wanted.isEmpty()) { return {}; } for (CommandDef *command : m_commands) { if (!command->sequence().isEmpty() && QKeySequence(command->sequence()) == wanted) { return command->commandId(); } } return {}; } bool Commands::setCommandSequence(const QString &commandId, const QString &sequence) { ensureModelConnected(); CommandDef *command = find(commandId); if (command == nullptr || (m_model == nullptr || m_model->settings() == nullptr)) { return false; } const QString trimmed = sequence.trimmed(); if (trimmed.isEmpty()) { // Clear: restore the built-in literal. m_model->settings()->clearCommandSequence(commandId); return true; } const QKeySequence wanted(trimmed); if (wanted.isEmpty()) { return false; } // Conflict rejection: the sequence must be free or already this // command's (re-binding to itself, or its own literal, is fine). for (CommandDef *other : std::as_const(m_commands)) { if (other != command && !other->sequence().isEmpty() && QKeySequence(other->sequence()) == wanted) { return false; } } m_model->settings()->setCommandSequence(commandId, trimmed); return true; } void Commands::clearCommandSequence(const QString &commandId) { ensureModelConnected(); if (m_model != nullptr && m_model->settings() != nullptr) { m_model->settings()->clearCommandSequence(commandId); } } void Commands::clearAllCommandSequences() { ensureModelConnected(); if (m_model != nullptr && m_model->settings() != nullptr) { m_model->settings()->clearAllCommandSequences(); } } // --------------------------------------------------------------------------- // Per-target state // --------------------------------------------------------------------------- CommandDef *Commands::find(const QString &commandId) const { return m_byId.value(commandId, nullptr); } namespace { // QML hands JS objects across the boundary as QJSValue-carrying variants // (var-property round-trips), not QVariantMaps. Unwrap once so every ctx // gate sees the map form. QVariant unwrapJsCtx(const QVariant &ctx) { if (ctx.metaType() == QMetaType::fromType()) { return ctx.value().toVariant(); } return ctx; } } // namespace int Commands::workspaceIndexFromCtx(const QVariant &ctx) { const QVariant unwrapped = unwrapJsCtx(ctx); if (!unwrapped.isValid() || unwrapped.isNull()) { return -1; } if (unwrapped.metaType() == QMetaType::fromType()) { return unwrapped.toMap().value(QStringLiteral("index"), -1).toInt(); } if (unwrapped.canConvert()) { return unwrapped.toMap().value(QStringLiteral("index"), -1).toInt(); } return -1; } QString Commands::paneIdFromCtx(const QVariant &ctx) { const QVariant unwrapped = unwrapJsCtx(ctx); if (!unwrapped.isValid() || unwrapped.isNull()) { return {}; } // Pane contexts are the TerminalQuickItem itself. if (auto *term = unwrapped.value()) { return PanePool::instance() != nullptr ? PanePool::instance()->paneIdOf(term) : QString(); } if (unwrapped.metaType() == QMetaType::fromType()) { return unwrapped.toMap().value(QStringLiteral("paneId")).toString(); } return {}; } bool Commands::globalEnabled(const CommandDef &command) const { if (m_model == nullptr) { return true; } const bool hasFocus = !m_model->focusedPaneId().isEmpty(); const qsizetype workspaceCount = m_model->workspaces().size(); if (command.commandId() == QLatin1String("copy") || command.commandId() == QLatin1String("paste")) { // See the constructor note: selection state does not notify the model; // keep these globally enabled, the ctx gate decides. return true; } if (command.commandId() == QLatin1String("resetTerminal")) { return hasFocus && PanePool::instance() != nullptr && PanePool::instance()->item(m_model->focusedPaneId()) != nullptr; } // Search commands: a focused pane with a live item. if (command.commandId() == QLatin1String("openSearch") || command.commandId() == QLatin1String("closeSearch") || command.commandId() == QLatin1String("searchNext") || command.commandId() == QLatin1String("searchPrevious") || command.commandId() == QLatin1String("enterCopyMode") || command.commandId() == QLatin1String("exitCopyMode")) { return hasFocus && PanePool::instance() != nullptr && PanePool::instance()->item(m_model->focusedPaneId()) != nullptr; } // Pane zoom needs a sibling to hide: a single-pane tab zooming to // itself changes nothing, so the command greys out. Maximize stays // enabled on a single-pane tab — it still collapses the chrome. Checked // BEFORE the generic pane-category gate below, which would otherwise // answer for every pane command first. if (command.commandId() == QLatin1String("togglePaneZoom")) { if (!hasFocus) { return false; } const Workspace *ws = activeWorkspace(); if (ws == nullptr || ws->tabs().isEmpty()) { return false; } const XenoTab *tab = ws->tabs().at(ws->activeTabIndex()); return tab != nullptr && tab->paneIdList().size() > 1; } // Pane-splitting, pane lifecycle, focus movement, swapping. if (command.category() == QLatin1String("pane")) { return hasFocus; } // Tab reorder: needs >=2 tabs and room in the asked direction. Checked // BEFORE the generic tab-category gate below. if (command.commandId() == QLatin1String("moveTabLeft") || command.commandId() == QLatin1String("moveTabRight")) { const Workspace *ws = activeWorkspace(); if (ws == nullptr) { return false; } const int count = static_cast(ws->tabs().size()); if (count < 2) { return false; } const int index = ws->activeTabIndex(); if (command.commandId() == QLatin1String("moveTabLeft")) { return index > 0; } return index < count - 1; } // Tab commands: any workspace exists. if (command.category() == QLatin1String("tab")) { return workspaceCount > 0; } // Workspace navigation: previous/next need >1; selectWorkspaceN needs >=n. if (command.commandId() == QLatin1String("previousWorkspace") || command.commandId() == QLatin1String("nextWorkspace")) { return workspaceCount > 1; } if (command.commandId().startsWith(QLatin1String("selectWorkspace"))) { bool ok = false; QString id = command.commandId(); const int n = id.remove(QLatin1String("selectWorkspace")).toInt(&ok); return ok && workspaceCount >= n; } if (command.commandId() == QLatin1String("closeWorkspace")) { return workspaceCount > 0; } if (command.commandId() == QLatin1String("addJjWorkspace")) { const Workspace *ws = activeWorkspace(); return ws != nullptr && ws->isJjRepo(); } if (command.commandId() == QLatin1String("addGitWorktree")) { const Workspace *ws = activeWorkspace(); return ws != nullptr && ws->isGitRepo(); } // quit, preferences, palette, jump, view, add-menu popups: always true. return true; } bool Commands::commandEnabledFor(const QString &commandId, const QVariant &ctx) { ensureModelConnected(); CommandDef *command = find(commandId); if (command == nullptr) { return false; } const bool hasCtx = ctx.isValid() && !ctx.isNull(); if (!hasCtx) { return command->enabled(); } // Context-gated commands (ported from CommandRegistry.qml enabledFor). if (commandId == QLatin1String("copy")) { if (auto *term = ctx.value()) { return term->hasActiveSelection(); } return false; } if (commandId == QLatin1String("paste")) { if (auto *term = ctx.value()) { return !term->hasActiveSelection(); } return false; } if (commandId == QLatin1String("workspaceForgetJj")) { const int index = workspaceIndexFromCtx(ctx); if (index < 0 || m_model == nullptr || index >= m_model->workspaces().size()) { return false; } return m_model->workspaces().at(index)->kind() == WorkspaceKind::JjWorkspace; } if (commandId == QLatin1String("addJjWorkspace") || commandId == QLatin1String("addGitWorktree")) { // Workspace-row context: offer what the CLICKED card's repo supports. // A null ctx (the add menu, the palette) keeps using command->enabled, // which globalEnabled gates on the active workspace. const int index = workspaceIndexFromCtx(ctx); if (index < 0 || m_model == nullptr || index >= m_model->workspaces().size()) { return false; } const Workspace *ws = m_model->workspaces().at(index); return commandId == QLatin1String("addJjWorkspace") ? ws->isJjRepo() : ws->isGitRepo(); } if (commandId == QLatin1String("newTab")) { // Workspace-row context: the row's index must be valid. const int index = workspaceIndexFromCtx(ctx); return index >= 0 || (m_model != nullptr && !m_model->workspaces().isEmpty()); } // Workspace-row commands (rename/setDirectory/forget/delete): need a // valid row index. if (command->menus().contains(QLatin1String("workspace"))) { return workspaceIndexFromCtx(ctx) >= 0; } // Pane-context commands otherwise: the pane must still exist. if (command->menus().contains(QLatin1String("pane"))) { const QString paneId = paneIdFromCtx(ctx); return !paneId.isEmpty(); } return command->enabled(); } bool Commands::commandVisibleFor(const QString &commandId, const QVariant &ctx) { // Ported from CommandRegistry.qml: only workspaceForgetJj overrides the // base (always-visible) implementation, and its visibleFor == enabledFor. if (commandId == QLatin1String("workspaceForgetJj")) { return commandEnabledFor(commandId, ctx); } return true; } void Commands::dispatch(const QString &commandId, const QVariant &ctx) { // The Commands singleton may be created before AppModel's (singleton // create order is not guaranteed); retry the connection on every // dispatch so model-executed commands never silently no-op. ensureModelConnected(); CommandDef *command = find(commandId); if (command == nullptr) { return; } if (!commandEnabledFor(commandId, ctx)) { return; } // Pure model/pool commands execute directly inside Commands (they have // model access); everything else publishes to the controllers. const QVariant effectiveCtx = unwrapJsCtx(ctx); const QString focusedPaneId = m_model != nullptr ? m_model->focusedPaneId() : QString(); // Pane-context commands fall back to the focused pane (the QML registry's // `context ? context.paneId : AppModel.focusedPaneId` pattern). const QString targetPaneId = [&effectiveCtx, &focusedPaneId]() { const QString fromCtx = paneIdFromCtx(effectiveCtx); return fromCtx.isEmpty() ? focusedPaneId : fromCtx; }(); if (m_model == nullptr) { emit runCommand(commandId, effectiveCtx); return; } if (commandId == QLatin1String("splitHorizontal")) { m_model->splitPaneFromString(targetPaneId, QStringLiteral("down")); return; } if (commandId == QLatin1String("splitVertical")) { m_model->splitPaneFromString(targetPaneId, QStringLiteral("right")); return; } if (commandId == QLatin1String("splitWithTerminal")) { m_model->splitPaneWithKind(targetPaneId, QStringLiteral("right"), QStringLiteral("terminal"), QString(), 0, 0); return; } if (commandId == QLatin1String("splitWithEditor")) { // An editor request with no path becomes a file-only picker, so this key // opens a chooser and never an empty editor. m_model->splitPaneWithKind(targetPaneId, QStringLiteral("right"), QStringLiteral("editor"), QString(), 0, 0); return; } if (commandId == QLatin1String("closePane")) { if (m_model->paneDirty(targetPaneId)) { emit dirtyPaneCloseRequested(targetPaneId); return; } m_model->closePane(targetPaneId); return; } if (commandId == QLatin1String("keepOnlyPane")) { if (m_model->paneDirty(targetPaneId)) { emit dirtyPaneCloseRequested(targetPaneId); return; } m_model->keepOnlyPane(targetPaneId); return; } if (commandId == QLatin1String("focusNextPane")) { m_model->focusNextPane(); return; } if (commandId == QLatin1String("togglePaneZoom")) { m_model->togglePaneZoom(); return; } if (commandId == QLatin1String("maximizePane")) { m_model->maximizePane(); return; } if (commandId == QLatin1String("focusPaneLeft")) { m_model->focusDirectionFromString(targetPaneId, QStringLiteral("left")); return; } if (commandId == QLatin1String("focusPaneRight")) { m_model->focusDirectionFromString(targetPaneId, QStringLiteral("right")); return; } if (commandId == QLatin1String("focusPaneUp")) { m_model->focusDirectionFromString(targetPaneId, QStringLiteral("up")); return; } if (commandId == QLatin1String("focusPaneDown")) { m_model->focusDirectionFromString(targetPaneId, QStringLiteral("down")); return; } if (commandId == QLatin1String("swapPaneLeft")) { if (PanePool *pool = PanePool::instance()) { pool->swapFocusedDirection(QStringLiteral("left")); } return; } if (commandId == QLatin1String("swapPaneRight")) { if (PanePool *pool = PanePool::instance()) { pool->swapFocusedDirection(QStringLiteral("right")); } return; } if (commandId == QLatin1String("swapPaneUp")) { if (PanePool *pool = PanePool::instance()) { pool->swapFocusedDirection(QStringLiteral("up")); } return; } if (commandId == QLatin1String("swapPaneDown")) { if (PanePool *pool = PanePool::instance()) { pool->swapFocusedDirection(QStringLiteral("down")); } return; } if (commandId == QLatin1String("resetTerminal")) { if (auto *term = qobject_cast( PanePool::instance() != nullptr ? PanePool::instance()->item(targetPaneId) : nullptr)) { term->resetTerminal(); } return; } if (commandId == QLatin1String("copy")) { if (ctx.isValid() && !ctx.isNull()) { if (auto *term = ctx.value()) { term->copySelection(); } return; } if (auto *term = qobject_cast( PanePool::instance() != nullptr ? PanePool::instance()->item(focusedPaneId) : nullptr)) { term->copySelection(); } return; } if (commandId == QLatin1String("paste")) { if (ctx.isValid() && !ctx.isNull()) { if (auto *term = ctx.value()) { term->pasteFromClipboard(); } return; } if (auto *term = qobject_cast( PanePool::instance() != nullptr ? PanePool::instance()->item(focusedPaneId) : nullptr)) { term->pasteFromClipboard(); } return; } if (commandId == QLatin1String("newTab")) { const int index = workspaceIndexFromCtx(effectiveCtx); if (index >= 0) { m_model->newTab(index); return; } m_model->newTab(m_model->workspaceIndexOf(m_model->activeWorkspaceId())); return; } if (commandId == QLatin1String("closeTab")) { const int wsIndex = m_model->workspaceIndexOf(m_model->activeWorkspaceId()); const QList& workspaces = m_model->workspaces(); if (wsIndex >= 0 && wsIndex < workspaces.size()) { const int tabIndex = workspaces.at(wsIndex)->activeTabIndex(); if (!m_model->dirtyEditorPanesInTab(wsIndex, tabIndex).isEmpty()) { emit dirtyTabCloseRequested(wsIndex, tabIndex); return; } m_model->closeTab(wsIndex, tabIndex); } return; } if (commandId == QLatin1String("nextTab")) { m_model->nextTab(); return; } if (commandId == QLatin1String("previousTab")) { m_model->previousTab(); return; } if (commandId == QLatin1String("moveTabLeft") || commandId == QLatin1String("moveTabRight")) { const int wsIndex = m_model->workspaceIndexOf(m_model->activeWorkspaceId()); const QList& workspaces = m_model->workspaces(); if (wsIndex < 0 || wsIndex >= workspaces.size()) { return; } const int tabIndex = workspaces.at(wsIndex)->activeTabIndex(); m_model->moveTab(wsIndex, tabIndex, tabIndex + (commandId == QLatin1String("moveTabLeft") ? -1 : 1)); return; } if (commandId == QLatin1String("renameTab")) { // A dialog decision point: publish to the controllers. emit runCommand(commandId, effectiveCtx); return; } if (commandId == QLatin1String("previousWorkspace")) { m_model->previousWorkspace(); return; } if (commandId == QLatin1String("nextWorkspace")) { m_model->nextWorkspace(); return; } if (commandId.startsWith(QLatin1String("selectWorkspace"))) { bool ok = false; QString id = commandId; const int n = id.remove(QLatin1String("selectWorkspace")).toInt(&ok); if (ok) { m_model->selectWorkspaceByNumber(n); } return; } if (commandId == QLatin1String("resetLayout")) { m_model->resetLayout(); return; } if (commandId == QLatin1String("equalizeSplits")) { m_model->equalizeSplits(); return; } // Everything else (jumpToPane, commandPalette, resetLayout aside — // handled above — toggleSidebar, missionControl, preferences, quit, // newWorkspace, the add-workspace sources, closeWorkspace, and the // workspace-row commands) needs page/dialog/menu state that Commands // does not know about: publish to the controllers. emit runCommand(commandId, effectiveCtx); } // --------------------------------------------------------------------------- // Enabled recompute // --------------------------------------------------------------------------- Workspace *Commands::activeWorkspace() { AppModel *model = AppModel::instance(); if (model == nullptr) { return nullptr; } const QString activeId = model->activeWorkspaceId(); if (activeId.isEmpty()) { return nullptr; } for (Workspace *ws : model->workspaces()) { if (ws != nullptr && ws->id() == activeId) { return ws; } } return nullptr; } void Commands::ensureModelConnected() { if (m_modelConnected && m_model != nullptr) { return; } AppModel *model = AppModel::instance(); if (model == nullptr) { return; } if (m_modelConnected) { // The previous connection target died; reconnect to the new instance. m_modelConnected = false; } m_model = model; connect(model, &AppModel::focusedPaneChanged, this, &Commands::recomputeEnabled); connect(model, &AppModel::activeWorkspaceChanged, this, &Commands::recomputeEnabled); connect(model, &AppModel::workspacesChanged, this, &Commands::recomputeEnabled); connect(model, &AppModel::tabsChanged, this, &Commands::recomputeEnabled); connect(model, &AppModel::activeTabChanged, this, &Commands::recomputeEnabled); connect(model, &AppModel::structureChanged, this, &Commands::recomputeEnabled); connect(model, &AppModel::workspaceDataChanged, this, &Commands::recomputeEnabled); if (model->settings() != nullptr) { connect(model->settings(), &XenoSettings::commandSequencesChanged, this, &Commands::applyOverrides); } m_modelConnected = true; applyOverrides(); } void Commands::recomputeEnabled() { ensureModelConnected(); for (CommandDef *command : std::as_const(m_commands)) { command->setEnabled(globalEnabled(*command)); } }