#pragma once // The app model: a Quick-free singleton owning ALL app state — workspaces // (tagged-sum QObject hierarchy) → tabs (XenoTab) → split trees of pane // nodes (SplitNode) — plus the ActivePath (current selection/focus), the // id-keyed path index, the pane metadata table, and session persistence. // The QObject tree IS the model: mutators mutate it in place and QML binds // it directly. All structural mutations happen here and emit the coarse // signals the ordered views subscribe to. #include #include #include #include #include #include #include #include #include #include #include #include #include #include "SplitTree.h" #include "Workspace.h" class QQmlEngine; class QJSEngine; #include "XenoSettings.h" #include "FontCatalog.h" class PanePool; // Where a pane or container node lives: the id-keyed path index's value. // The key is either a paneId or a containerId; `path` is the split-tree // child-index route from the tab root to that node (empty = tab root). struct NodeLocation { QString workspaceId; QString tabId; QVector path; }; // The single CURRENT-selection/focus construct. Ids only, so structural // mutations (split/close/move/reorder) never rewrite it — only real focus // or selection moves do. struct ActivePath { QString workspaceId; QString tabId; QString paneId; // "" when nothing focused }; // Per-pane metadata (spawn state + live notes from the pool). struct PaneRecord { QString paneId; QString workspaceId; QString tabId; // Spawn state (session save + restore source). QString spawnCwd; QString spawnCommand; QStringList spawnInitialCommands; QString spawnTitle; bool restart = false; QStringList restartArgv; // Live notes (pool wiring). QString title; QString cwd; // Editor panes: the file, and the caret the pane opens at. editorLine and // editorColumn are transient: they seed one pane creation and are not // saved. editorDirty is transient too. pickerFilesOnly marks the chooser // the arm-editor split creates (files only, no terminal row). QString editorPath; int editorLine = 0; int editorColumn = 0; bool editorDirty = false; bool pickerFilesOnly = false; }; class AppModel : public QObject { Q_DISABLE_COPY_MOVE(AppModel) Q_OBJECT QML_ELEMENT QML_SINGLETON Q_ENUM(SplitAxis) Q_ENUM(SplitDirection) // Ordered list of workspaces (the model objects QML binds directly). Q_PROPERTY(QList workspaces READ workspaces NOTIFY workspacesChanged) // ActivePath exposure: three NOTIFY id properties (paneId "" when // nothing focused). Selecting a workspace/tab repoints to that tab's // lastActivePaneId; closing the focused pane repoints to the absorbing // sibling (or the new active tab's pane on auto-close). Q_PROPERTY(QString activeWorkspaceId READ activeWorkspaceId NOTIFY activeWorkspaceChanged) Q_PROPERTY(QString activeTabId READ activeTabId NOTIFY activeTabChanged) Q_PROPERTY(QString focusedPaneId READ focusedPaneId NOTIFY focusedPaneChanged) // Model-owned services (constructed in the AppModel constructor). Q_PROPERTY(XenoSettings* settings READ settings CONSTANT) Q_PROPERTY(FontCatalog* fontCatalog READ fontCatalog CONSTANT) // True while a modal surface (mission control) owns the keyboard. // Pooled terminal items gate their key paths on this so a stray focus // hand-off cannot leak keystrokes into a pane beneath the overlay. Q_PROPERTY(bool missionControlOpen READ missionControlOpen WRITE setMissionControlOpen NOTIFY missionControlOpenChanged) // True while the focused pane is maximized (zoom + collapsed chrome). // Chrome = sidebar + tab strip. The model remembers ONLY what the // maximize itself changed (a sidebar that was already collapsed stays // collapsed after restore); restore never clobbers the user's manual // sidebar/tab-strip state. Not session-persisted (transient chrome). Q_PROPERTY(bool chromeMaximized READ chromeMaximized NOTIFY chromeMaximizedChanged) public: // The QML singleton instance (also the constructor for tests). static AppModel* create(QQmlEngine* engine, QJSEngine* scriptEngine); static AppModel* instance(); explicit AppModel(QObject* parent = nullptr); ~AppModel() override; // --- properties ----------------------------------------------------------- [[nodiscard]] const QList& workspaces() const { return m_workspaces; } [[nodiscard]] XenoSettings* settings() const { return m_settings; } [[nodiscard]] FontCatalog* fontCatalog() const { return m_fontCatalog; } [[nodiscard]] bool missionControlOpen() const { return m_missionControlOpen; } void setMissionControlOpen(bool open); [[nodiscard]] bool chromeMaximized() const { return m_chromeMaximized; } [[nodiscard]] const QString& activeWorkspaceId() const { return m_activePath.workspaceId; } [[nodiscard]] const QString& activeTabId() const { return m_activePath.tabId; } [[nodiscard]] const QString& focusedPaneId() const { return m_activePath.paneId; } // --- pool wiring ---------------------------------------------------------- // QMetaObject connects by signature; keeps models/ Quick-free at compile // time. The pool's paneTitleChanged/paneCwdChanged/paneCloseRequested // signals feed notePaneTitle/notePaneCwd/closePane. Q_INVOKABLE void attachPool(QObject* pool); // --- startup -------------------------------------------------------------- // Load the session, or build the default workspace when none exists. // `startupCommands` seeds the default workspace's initialCommands, // `command` its spawn command, `workingDir` its cwd/path. Q_INVOKABLE void startup(const QVariantList& startupCommands, const QString& command, const QString& workingDir); // --- workspace mutators ---------------------------------------------------- Q_INVOKABLE QString addWorkspace(WorkspaceKind kind, const QString& path, const QString& name, const QString& repoRoot); Q_INVOKABLE void deleteWorkspace(int index); Q_INVOKABLE void renameWorkspace(int index, const QString& name); Q_INVOKABLE void selectWorkspace(int index); Q_INVOKABLE void nextWorkspace(); Q_INVOKABLE void previousWorkspace(); Q_INVOKABLE void selectWorkspaceByNumber(int number); Q_INVOKABLE void setWorkspaceDirectory(int index, const QString& path); // JjWorkspace -> RepositoryWorkspace kind mutation: the workspace OBJECT // is replaced (new RepositoryWorkspace, same id, tabs moved over); QML // re-resolves through the list notification. Q_INVOKABLE void forgetJjWorkspace(int index); // Replace the workspace at `index` with a fresh object of `kind` (same // id/name/path, tabs stolen, old object deleteLater'd), carrying // `repoRoot`/`jjWorkspaceName` for the kinds that have them and copying // the old object's isJjRepo/isGitRepo capabilities. No-op when the kind // already matches. Public Q_INVOKABLE so the controllers can reseat after // (re)detection. Q_INVOKABLE void reseatWorkspaceKind(int index, WorkspaceKind kind, const QString& repoRoot, const QString& jjWorkspaceName); // Set the detected VCS capability flags on the workspace at `index`. // No-op on an invalid index or unchanged values; emits workspacesChanged // (Commands gating depends on it) and saves the session when anything // changed. Q_INVOKABLE void setWorkspaceRepoCapabilities(int index, bool isJjRepo, bool isGitRepo); // --- tab mutators ---------------------------------------------------------- Q_INVOKABLE void newTab(int wsIndex); Q_INVOKABLE void closeTab(int wsIndex, int tabIndex); Q_INVOKABLE void selectTab(int wsIndex, int tabIndex); Q_INVOKABLE void nextTab(); Q_INVOKABLE void previousTab(); // Reorder: move the tab at (wsIndex, tabIndex) to `toIndex` inside the // same workspace (clamped; no-op at the ends or for <2 tabs). The // XenoTab objects travel unchanged; the active tab stays the SAME TAB // (its index follows the move). No pane records or trees are touched. Q_INVOKABLE void moveTab(int wsIndex, int tabIndex, int toIndex); // User title override for the tab at (wsIndex, tabIndex); empty clears. // While set, tab titles show the override and the reported title keeps // tracking underneath. No effect on order, focus, or pane state. Q_INVOKABLE void setTabTitleOverride(int wsIndex, int tabIndex, const QString& title); // --- pane/tree mutators ----------------------------------------------------- Q_INVOKABLE void splitPane(const QString& paneId, SplitDirection direction); // String-based split/focus overloads for QML. The SplitDirection enum // values attached to the AppModel QML singleton do not resolve in QML // (probing reports "Up=undefined"), so QML callers route directions // through these instead; C++ callers keep using the enum versions. // `direction` accepts "splitLeft"/"splitRight"/"splitUp"/"splitDown" or // the spatial spellings "left"/"right"/"up"/"down" (unknown falls back // to Down, mirroring TerminalPageController::directionFromString). Q_INVOKABLE void splitPaneFromString(const QString& paneId, const QString& direction); // QML passes a kind word ("terminal"|"editor"|"picker"). An unknown word or // an unknown pane id is a no-op. An editor request with an empty `path` // becomes a picker (the pane-kind invariant). Q_INVOKABLE void setPaneKindFromString(const QString& paneId, const QString& kind, const QString& path = QString()); // Split `paneId` on `direction` and create the fresh pane with `kind`. // `path`/`line`/`column` seed an editor pane. Returns the fresh pane id, or // "" when the pane or the direction is unknown. Q_INVOKABLE QString splitPaneWithKind(const QString& paneId, const QString& direction, const QString& kind, const QString& path, int line, int column); // The id of the editor pane showing `path` in `paneId`'s tab, or "". Q_INVOKABLE QString editorPaneShowingPath(const QString& paneId, const QString& path) const; // True while this pane's editor holds unsaved edits. Q_INVOKABLE bool paneDirty(const QString& paneId) const; Q_INVOKABLE void notePaneDirty(const QString& paneId, bool dirty); // The pane's kind as its word ("terminal"|"editor"|"picker"), or "" for an // unknown id. A read surface for QML, the probe and the tests. Q_INVOKABLE QString paneKindOf(const QString& paneId) const; // True for the chooser the arm-editor split creates. Q_INVOKABLE bool panePickerFilesOnly(const QString& paneId) const; // Dirty editor panes of one tab, and of the whole session. Q_INVOKABLE QStringList dirtyEditorPanesInTab(int wsIndex, int tabIndex) const; Q_INVOKABLE QStringList dirtyEditorPanes() const; Q_INVOKABLE void closePane(const QString& paneId); Q_INVOKABLE void keepOnlyPane(const QString& paneId); Q_INVOKABLE void swapPanes(const QString& a, const QString& b); // Move `paneId` to sit beside `targetPaneId` on `direction` ("left"| // "right"|"up"|"down" relative to the target): the model-native pane // drag-drop op. Works across tabs and workspaces: the path index // resolves both panes' full (workspace, tab) locations, and an emptied // source tab auto-closes. No-op when either id is unknown, the ids // match, the dragged pane is its workspace's only tab's only pane, or // (same tab) the tree holds fewer than 2 panes. Emits structureChanged // and saves the session like every other tree mutator. Q_INVOKABLE void movePaneBeside(const QString& paneId, const QString& targetPaneId, const QString& direction); // Append `paneId` as the last pane of the tree at (wsIndex, tabIndex): // the append drop target. The destination is a tab, so it is addressed // by indices; every pane involved is resolved by id through the path // index. An emptied source tab auto-closes. Q_INVOKABLE void movePaneToTab(const QString& paneId, int wsIndex, int tabIndex); // Move `paneId` into a brand-new tab appended to workspace `wsIndex`: // the page-background drop target. No-op when the source workspace // would lose its last tab's only pane to a DIFFERENT workspace (same- // workspace drops are always safe: the new tab exists before the old // one closes). Q_INVOKABLE void movePaneToNewTab(const QString& paneId, int wsIndex); Q_INVOKABLE void focusPane(const QString& paneId); Q_INVOKABLE void focusDirection(const QString& paneId, SplitDirection dir); // String-based focus overload (see splitPaneFromString for why QML uses // strings for directions). Q_INVOKABLE void focusDirectionFromString(const QString& paneId, const QString& direction); // Focus-cycle helpers: step to the next/previous pane in the active // tab's layout order, wrapping around. Unknown/empty focus falls back // to the first pane in layout order (keeps shortcuts usable at boot). Q_INVOKABLE void focusNextPane(); Q_INVOKABLE void focusPreviousPane(); Q_INVOKABLE void revealPane(const QString& paneId); Q_INVOKABLE void setRatios(const QString& containerId, const QVariantList& ratios); // Balance every container in the ACTIVE tab's tree to equal 1/n shares // (nested trees equal at every level). No-op on a single-pane tab: the // root there is a >=1-child container, and equal shares ARE its current // shape — nothing to change, nothing emitted. Q_INVOKABLE void equalizeSplits(); Q_INVOKABLE void resetLayout(); // --- pane zoom -------------------------------------------------------------- // Zoom is per-tab transient view state on XenoTab (zoomedPaneId): the id // of the pane the tab renders alone. It NEVER mutates the split tree, // its ratios, the ActivePath, or the pane pool — clearing it restores // the exact previous layout. togglePaneZoom flips zoom on the focused // pane; maximizePane zooms the focused pane AND claims focus (a maximize // is a zoom with "this is the only thing I want now" semantics); both // clear an existing zoom on the same pane, and either one clearing a // zoom that a maximize rode also restores the collapsed chrome, so a // maximize never outlives its zoom. clearPaneZoom drops the // active tab's zoom (no-op when none). All three emit // zoomedPaneIdChanged via the tab and save nothing (transient state). Q_INVOKABLE void togglePaneZoom(); Q_INVOKABLE void maximizePane(); Q_INVOKABLE void clearPaneZoom(); // The active tab's zoomed pane id ("" when none / no active tab). Q_INVOKABLE QString activeZoomedPaneId() const; // Chrome maximize (sidebar + tab strip collapse) and its restore. Only // chrome the maximize itself hid is restored; see the property note. void maximizeChrome(); void unmaximizeChrome(); // Tab-strip visibility is a model-owned flag (QML binds it) — the // sidebar's collapsed state lives in XenoSettings, but the tab strip // has no persisted equivalent and must not gain one. Q_PROPERTY(bool tabStripHidden READ tabStripHidden NOTIFY chromeMaximizedChanged) [[nodiscard]] bool tabStripHidden() const { return m_tabStripHidden; } // Test hook: pre-hide the tab strip WITHOUT a maximize so the restore // contract (only-what-we-changed) is exercisable. Not compiled into // QML-facing behavior. void setTabStripHiddenForTest(bool hidden) { m_tabStripHidden = hidden; } // --- pool notes ------------------------------------------------------------- Q_INVOKABLE void notePaneTitle(const QString& paneId, const QString& title); Q_INVOKABLE void notePaneCwd(const QString& paneId, const QString& cwd); // Record the quit-time restart scan result for one pane (the pool's // runRestartScan calls this). No-op for an unknown paneId. void setPaneRestart(const QString& paneId, bool restart, const QStringList& argv); // --- reads for QML ---------------------------------------------------------- Q_INVOKABLE QVariantMap tabLayout(int wsIndex, int tabIndex); Q_INVOKABLE QVariantList jumpCandidates() const; Q_INVOKABLE QString paneTitle(const QString& paneId) const; Q_INVOKABLE QString tabDisplayTitle(int wsIndex, int tabIndex) const; // The spawn state (cwd, command, initialCommands, title, restart, // restartArgv) for `paneId`, as a fully-keyed map with empty values for // an unknown id. TerminalPane.start() reads it once at creation so // session-restored panes spawn with their saved cwd/command/restart // state. // The directory a chooser pane lists: the pane's live cwd, else its spawn // cwd, else its workspace's path, else the home directory. A pane whose cwd // was never noted still belongs to a workspace, and listing that beats // listing the home directory by accident. Q_INVOKABLE [[nodiscard]] QString paneListingRoot(const QString& paneId) const; // The pane's workspace as the backend word the repo chooser wants: "jj" // for a jj workspace, "git" for a repository or a worktree, and "" for a // directory workspace or an unknown pane. Together with paneWorkspacePath // it tells the chooser whether to search the repository or walk paths. Q_INVOKABLE [[nodiscard]] QString paneRepoBackend(const QString& paneId) const; // The pane's workspace path, or "" for an unknown pane. The repo chooser // roots its relative file list here. Q_INVOKABLE [[nodiscard]] QString paneWorkspacePath(const QString& paneId) const; Q_INVOKABLE [[nodiscard]] QVariantMap paneSpawnSpec( const QString& paneId) const; // Pane ids of the ACTIVE tab in layout order (empty when there is no // active tab/tree). Pure logic over tabTree + paneIdsInLayoutOrder; the // PanePool uses it to decide which panes stream. Q_INVOKABLE [[nodiscard]] QStringList activePaneIds() const; // Pane ids of every workspace's ACTIVE tab, in layout order. This is the // set whose daemon streams stay attached while a preview surface is // visible: the sidebar cards and the mission-control workspace cards // sample each workspace's active-tab tree, so those panes must keep // receiving output while backgrounded. Q_INVOKABLE [[nodiscard]] QStringList streamingPaneIds() const; // Session persistence (500 ms debounce; same as today's persistTimer). void saveSessionSoon(); // --- test/driver access (not Q_INVOKABLE; C++ only) ------------------------ [[nodiscard]] const ActivePath& activePath() const { return m_activePath; } [[nodiscard]] const QHash& pathIndex() const { return m_paths; } [[nodiscard]] const QHash& panes() const { return m_panes; } // Container id -> containerId registry lookups for the controllers/tests. [[nodiscard]] int workspaceIndexOf(const QString& workspaceId) const; [[nodiscard]] int tabIndexOf(const QString& workspaceId, const QString& tabId) const; // The split tree of (wsIndex, tabIndex); nullptr when out of range. // Roots are always containers (the session loader wraps bare leaves). [[nodiscard]] SplitContainerNode* tabTree(int wsIndex, int tabIndex) const; // The pane record (spawn + live notes) for `paneId`; an empty record // when unknown. [[nodiscard]] PaneRecord paneRecord(const QString& paneId) const { return m_panes.value(paneId); } // Emit the whole session map (also the session-save entry point). // Q_INVOKABLE so the dev driver's session() probe can read it. Q_INVOKABLE [[nodiscard]] QVariantMap collectSession(bool scanQuit) const; signals: // --- coarse (the ordered views + Commands subscribe to these) ------------- void workspacesChanged(); void workspaceDataChanged(int wsIndex); void activeWorkspaceChanged(int wsIndex); void tabsChanged(int wsIndex); void tabDataChanged(int wsIndex, int tabIndex); void activeTabChanged(int wsIndex, int tabIndex); void structureChanged(int wsIndex, int tabIndex); void focusedPaneChanged(const QString& paneId); // Modal keyboard ownership (mission control open/close). void missionControlOpenChanged(); // Chrome maximize entered/left (sidebar + tab strip collapsed). void chromeMaximizedChanged(); public slots: void paneTitleFromPool(const QString& paneId, const QString& title); void paneCwdFromPool(const QString& paneId, const QString& cwd); void paneCloseFromPool(const QString& paneId); // Quit-time save: buildSessionRoot(true) — the ONLY path that runs the // restart scan. Wired to QGuiApplication::aboutToQuit in main.cpp via a // NAME-BASED QMetaObject::invokeMethod (main.cpp has no models/ include // dir), so it MUST be a slot or Q_INVOKABLE to be invokable by name. void saveSessionForQuit(); private: // Session load/save. `fromJson` fills generated ids where absent. void loadSession(const QVariantMap& session); QVariantMap buildSessionRoot(bool scanQuit) const; void saveSessionNow(); // Invariant enforcement helpers (called by every mutator). void rebuildPathIndex(const QString& workspaceId, const QString& tabId); void normalizeTree(SplitNode& tree); // Container collapse cascade: walk the ancestors along `removedPath` // (a container path with the leaf index already taken off) and promote // any container left holding one child into ITS parent — the // >=2-children invariant. Shared by closePane and movePaneBeside. static void collapseSingletons(SplitContainerNode* tree, const QVector& removedPath); void rebuildAllPathIndexes(); // The workspace a pane belongs to, resolved through its record's // workspaceId, or nullptr for an unknown pane. paneListingRoot and the two // repo-mode reads share this one traversal. [[nodiscard]] const Workspace* paneWorkspace(const QString& paneId) const; [[nodiscard]] QString firstPaneIdOfTab(int wsIndex, int tabIndex) const; void removeTabPaneRecords(int wsIndex, const XenoTab& tab); // Rewrite a pane record's live location (workspaceId/tabId) in place. // Spawn fields are untouched so the pane restores with its original // cwd and command. Used by the cross-tree move mutators before an // emptied-source closeTab so removeTabPaneRecords (an empty tree) // leaves the moved record alone. void rehomeRecord(const QString& paneId, const QString& workspaceId, const QString& tabId); // Kill the pool items for the given pane ids (QMetaObject::invokeMethod // on the attached pool's destroyPanes(QStringList) — keeps models/ // compile-time Quick-free). No-op without an attached pool (tests). void destroyPoolPanes(const QStringList& paneIds); // The one split implementation. `kind` is the fresh pane's kind; `path`, // `line` and `column` seed an editor pane. Returns the fresh pane id. QString splitPaneCore(const QString& paneId, SplitDirection direction, PaneKind kind, const QString& path, int line, int column); // The one kind mutation: rewrite the record payload, then the node kind. // The record is seeded BEFORE the node's NOTIFY, because that notify // rebuilds the view and the new view reads the record at creation (the // same seed-first rule splitPaneCore documents). void setPaneKind(const QString& paneId, PaneKind kind, const QString& path); // The kind a plain split of `paneId` creates: the source pane's own kind. // An editor source asks for an editor, and splitPaneCore turns an editor // request with no file into a file-only chooser. A picker source falls back // to a terminal. Unknown ids read as Terminal. [[nodiscard]] PaneKind defaultSplitKindFor(const QString& paneId) const; // Push the tab's computed display title (live pane title of the // remembered pane, else "") into its XenoTab so reactive QML bindings // on tab.title re-fire. Call wherever the title source changes: // pane title notes, focus moves, pane close repoints, rebuilds. void syncTabTitle(int wsIndex, int tabIndex); // Drop the zoom of any tab that references `paneId` (pane close/move // hygiene) and drop the zoom of every tab of (wsIndex, tabIndex) whose // zoomed pane vanished from that tree. No-op without a pool-free model. void clearZoomReferencing(int wsIndex, int tabIndex, const QStringList& removedPaneIds); // The pane a focus request must actually land on inside `tab`: the // tab's zoomed pane when the tab is zoomed and `paneId` is not it, else // `paneId`. A zoomed tab renders one pane alone, so its hidden siblings // must never hold the ActivePath — the keyboard would type into a // terminal nobody sees. Falls back to `paneId` when the zoom id is // stale (not a leaf of that tab's tree). [[nodiscard]] static QString zoomPinInTab(const XenoTab& tab, const QString& paneId); QList m_workspaces; QHash m_paths; ActivePath m_activePath; QHash m_panes; XenoSettings* m_settings; FontCatalog* m_fontCatalog; QPointer m_pool; // 500 ms debounced autosave (created in the constructor; single-shot). QTimer* m_saveTimer = nullptr; // The singleton instance (set by the constructor, cleared by the dtor). static AppModel* s_instance; // The active workspace's index (ids are canonical; this is a cache for // the ordered views). int m_activeWorkspaceIndex = 0; // Modal keyboard ownership (mission control open). bool m_missionControlOpen = false; // Chrome-maximize state: what we changed and how to undo it. Only the // pieces the maximize itself flipped are recorded, so restore never // clobbers a sidebar/tab strip the user had already hidden themselves. bool m_chromeMaximized = false; // Pre-maximize snapshots: true means "it was EXPANDED and we collapsed // it"; false means it was already hidden and restore must leave it. bool m_restoreSidebar = false; bool m_restoreTabStrip = false; bool m_tabStripHidden = false; };