Something went wrong. Try again.
The agentic engineering control plane for the posthuman future
Something went wrong. Try again.
117 kB · 3289 lines
C++
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290#include "AppModel.h"
#include <QDir>#include <QJsonArray>#include <QMetaObject>#include <QTimer>#include <tuple>#include <algorithm>#include <functional>#include <ranges>#include <utility>
#include "FontCatalog.h"#include "PathFuzzy.h"#include "SessionStore.h"#include "XenoSettings.h"
// ---------------------------------------------------------------------------// Construction / singleton// ---------------------------------------------------------------------------
AppModel* AppModel::s_instance = nullptr;
AppModel::AppModel(QObject* parent) : QObject(parent), m_settings(new XenoSettings(this)), m_fontCatalog(new FontCatalog(this)), m_saveTimer(new QTimer(this)){ m_saveTimer->setSingleShot(true); m_saveTimer->setInterval(500); connect(m_saveTimer, &QTimer::timeout, this, &AppModel::saveSessionNow); s_instance = this;}
AppModel::~AppModel(){ if (s_instance == this) { s_instance = nullptr; }}
AppModel* AppModel::instance(){ return s_instance;}
AppModel* AppModel::create(QQmlEngine* /*engine*/, QJSEngine* /*jsEngine*/){ // The QML singleton: one AppModel for the engine lifetime. return new AppModel();}
// ---------------------------------------------------------------------------// Lookup helpers.// ---------------------------------------------------------------------------
int AppModel::workspaceIndexOf(const QString& workspaceId) const{ for (auto&& [i, workspace] : std::views::zip(std::views::iota(0), m_workspaces)) { if (workspace->id() == workspaceId) { return static_cast<int>(i); } } return -1;}
int AppModel::tabIndexOf(const QString& workspaceId, const QString& tabId) const{ const int wsIndex = workspaceIndexOf(workspaceId); if (wsIndex < 0) { return -1; } const QList<XenoTab*>& tabs = m_workspaces.at(wsIndex)->tabs(); for (auto&& [t, tab] : std::views::zip(std::views::iota(0), tabs)) { if (tab->id() == tabId) { return static_cast<int>(t); } } return -1;}
SplitContainerNode* AppModel::tabTree(int wsIndex, int tabIndex) const{ // Callers mutate through the returned pointer; the const here guards // this accessor's own reads only (same pattern as a lazy-cache getter; // the model owns its trees). if (wsIndex < 0 || wsIndex >= m_workspaces.size()) { return nullptr; } const QList<XenoTab*>& tabs = m_workspaces.at(wsIndex)->tabs(); if (tabIndex < 0 || tabIndex >= tabs.size()) { return nullptr; } return tabs.at(static_cast<qsizetype>(tabIndex))->treeRoot();}
// ---------------------------------------------------------------------------// Path index (one map, id-keyed: paneId OR containerId -> location).// Written only by mutators, in the same transaction as the tree. Pane AND// container entries; tests audit the map by re-walking all trees.// ---------------------------------------------------------------------------
namespace {
// Recursively index one subtree into `paths`.void indexSubtree(const SplitNode& node, const QString& workspaceId, const QString& tabId, QVector<int> path, QHash<QString, NodeLocation>& paths){ node.visit([&](const auto& typed) { using T = std::decay_t<decltype(typed)>; if constexpr (std::is_same_v<T, PaneNode>) { if (!typed.paneId().isEmpty()) { paths.insert(typed.paneId(), NodeLocation{.workspaceId = workspaceId, .tabId = tabId, .path = path}); } } else { paths.insert(typed.containerId(), NodeLocation{.workspaceId = workspaceId, .tabId = tabId, .path = path}); for (const int i : std::views::iota(0, typed.childCount())) { path.push_back(i); indexSubtree(*typed.childAt(i), workspaceId, tabId, path, paths); path.removeLast(); } } });}
} // namespace
void AppModel::rebuildPathIndex(const QString& workspaceId, const QString& tabId){ // Drop this tab's stale entries; keep every other tab's. QHash<QString, NodeLocation> fresh; fresh.reserve(m_paths.size()); for (auto it = m_paths.constBegin(); it != m_paths.constEnd(); ++it) { if (it.value().workspaceId != workspaceId || it.value().tabId != tabId) { fresh.insert(it.key(), it.value()); } } const int wsIndex = workspaceIndexOf(workspaceId); const int tabIndex = tabIndexOf(workspaceId, tabId); if (wsIndex >= 0 && tabIndex >= 0) { SplitContainerNode* tree = tabTree(wsIndex, tabIndex); if (tree != nullptr) { QVector<int> rootPath; // empty = the tab root container indexSubtree(*tree, workspaceId, tabId, rootPath, fresh); } } m_paths = std::move(fresh);}
void AppModel::normalizeTree(SplitNode& tree) // NOLINT(readability-convert-member-functions-to-static){ auto* container = tree.asContainer(); if (container == nullptr) { return; } QList<qreal> ratios; ratios.reserve(container->ratios().size()); for (const qreal ratio : container->ratios()) { ratios.append(ratio < 0.0 ? ratio : std::clamp(ratio, 0.0, 1.0)); } container->setRatios(ratios); renormalize(*container);}
// ---------------------------------------------------------------------------// Session load. The QObject tree IS the model: loadSession builds// Workspace/XenoTab/node objects directly.// ---------------------------------------------------------------------------
namespace {
QString objectString(const QJsonObject& object, const char* key){ return object.value(QLatin1String(key)).toString();}
// Visit every PaneNode of `root` in layout order. `walk` hands the visitor// containers too; this filters them out so pane-only scans stay readable.template <typename Visitor>void forEachPane(const SplitNode& root, Visitor&& visitor) // NOLINT(cppcoreguidelines-missing-std-forward){ walk(root, [&](const auto& typed) { using T = std::decay_t<decltype(typed)>; if constexpr (std::is_same_v<T, PaneNode>) { visitor(typed); } });}
// The session wire's kind strings (byte-compatible with the pre-refactor// schema; NOT the runtime kind representation — that is WorkspaceKind).QString workspaceKindToString(WorkspaceKind kind){ switch (kind) { case WorkspaceKind::Directory: return QStringLiteral("directory"); case WorkspaceKind::Repository: return QStringLiteral("repository"); case WorkspaceKind::JjWorkspace: return QStringLiteral("jjWorkspace"); case WorkspaceKind::GitWorktree: return QStringLiteral("gitWorktree"); } return QStringLiteral("directory");}
WorkspaceKind workspaceKindFromString(const QString& kind){ if (kind == QLatin1String("repository")) { return WorkspaceKind::Repository; } if (kind == QLatin1String("jjWorkspace")) { return WorkspaceKind::JjWorkspace; } if (kind == QLatin1String("gitWorktree")) { return WorkspaceKind::GitWorktree; } // Tolerance mirrors buildFromSession: unknown kinds are directory. return WorkspaceKind::Directory;}
// Layout-order index of the tab's remembered pane (-1 when unknown).int lastPaneIndexOf(const XenoTab& tab){ if (tab.lastActivePaneId().isEmpty() || tab.treeRoot() == nullptr) { return -1; } const QStringList ids = paneIdsInLayoutOrder(*tab.treeRoot()); return static_cast<int>(ids.indexOf(tab.lastActivePaneId()));}
// A tab root holding one empty leaf (buildFromSession's emptyLeaf(path)):// the restore seeds the pane's spawn cwd from the workspace path.SplitContainerNode* emptyLeafTree(const QString& cwd = {}){ Q_UNUSED(cwd); // spawn cwd lands in the pane record, not the tree auto* root = new SplitContainerNode(SplitAxis::Horizontal, newUuid(), nullptr); root->appendChild(new PaneNode(newUuid(), nullptr), 1.0); return root;}
// Collect the leaf descriptor objects of a session tree in DFS (layout)// order (Shell.treeLeaves).QList<QJsonObject> collectLeaves(const QJsonObject& tree){ QList<QJsonObject> out; if (tree.isEmpty()) { return out; } if (tree.value(QLatin1String("type")).toString() == QLatin1String("leaf")) { out.append(tree); return out; } const QJsonArray children = tree.value(QLatin1String("children")).toArray(); for (const auto& child : children) { if (child.isObject()) { const QJsonObject obj = child.toObject(); for (const QJsonObject& leaf : collectLeaves(obj)) { out.append(leaf); } } } return out;}
// Absolute pane cwd at restore (Shell.leafCwd): workspace path + cwdRel,// else the legacy absolute cwd, else the workspace path, else "".QString leafCwdFromJson(const QJsonObject& leafJson, const QString& workspacePath){ const QString cwdRel = objectString(leafJson, "cwdRel"); if (!cwdRel.isEmpty() && !workspacePath.isEmpty()) { return cwdRel == QLatin1String(".") ? workspacePath : workspacePath + QLatin1Char('/') + cwdRel; } const QString cwd = objectString(leafJson, "cwd"); if (!cwd.isEmpty()) { return cwd; // NOLINT(performance-no-automatic-move) } return workspacePath;}
// Workspace-relative cwd (Shell.relativeCwd): "." for the workspace// root itself, "sub/dir" under it, "" when not under the workspace path.// The workspace path is canonicalized because lsof (the live-cwd source)// reports symlink-resolved paths (e.g. /tmp -> /private/tmp on macOS).QString relativeCwd(const QString& workspacePath, const QString& absolute){ if (workspacePath.isEmpty() || absolute.isEmpty()) { return {}; } QString base = PathFuzzy::canonical(workspacePath); if (base.isEmpty()) { base = workspacePath; } if (absolute == base) { return QStringLiteral("."); } const QString prefix = base.endsWith(QLatin1Char('/')) ? base : base + QLatin1Char('/'); if (absolute.startsWith(prefix)) { return absolute.mid(prefix.size()); } return {};}
QString defaultNameFromPath(const QString& path){ if (path.isEmpty()) { return QStringLiteral("workspace"); } const auto slash = path.lastIndexOf(QLatin1Char('/')); const QString last = path.mid(slash + 1); return last.isEmpty() ? QStringLiteral("workspace") : last;}
// One workspace from a session JSON object (mirrors Shell.buildFromSession:// defaults for every missing field; every workspace ends with >=1 tab;// generated ids fill absent ones; pane records are seeded with leafCwd from// cwdRel).Workspace* workspaceFromJson(const QJsonObject& wsJson, QHash<QString, PaneRecord>& panes){ const QString path = objectString(wsJson, "path"); const QString name = wsJson.contains(QLatin1String("name")) ? objectString(wsJson, "name") : QStringLiteral("workspace"); const QString repoRoot = objectString(wsJson, "repoRoot"); const QString jjName = objectString(wsJson, "jjWorkspaceName"); const QString id = wsJson.contains(QLatin1String("id")) ? objectString(wsJson, "id") : newUuid();
Workspace* workspace = nullptr; switch (workspaceKindFromString(objectString(wsJson, "kind"))) { case WorkspaceKind::Directory: workspace = new DirWorkspace(id, name, path, nullptr); break; case WorkspaceKind::Repository: workspace = new RepositoryWorkspace(id, name, path, repoRoot, nullptr); break; case WorkspaceKind::JjWorkspace: workspace = new JjWorkspace(id, name, path, repoRoot, jjName, nullptr); break; case WorkspaceKind::GitWorktree: workspace = new GitWorktree(id, name, path, repoRoot, nullptr); break; } // Detected VCS capabilities (additive; missing keys -> false). workspace->setJjRepo(wsJson.value(QLatin1String("isJjRepo")).toBool()); workspace->setGitRepo(wsJson.value(QLatin1String("isGitRepo")).toBool());
const QString wsId = workspace->id(); const QString wsPath = workspace->path(); QList<XenoTab*> tabs; const QJsonArray tabsJson = wsJson.value(QLatin1String("tabs")).toArray(); for (const auto& tabValue : tabsJson) { if (!tabValue.isObject()) { continue; } const QJsonObject tabObj = tabValue.toObject(); auto* tab = new XenoTab(tabObj.contains(QLatin1String("id")) ? objectString(tabObj, "id") : newUuid(), workspace); tab->setActive(tabObj.value(QLatin1String("active")).toBool()); tab->setLastActivePaneId(objectString(tabObj, "lastActivePaneId")); const QJsonValue treeValue = tabObj.value(QLatin1String("tree")); std::unique_ptr<SplitNode> parsed = treeValue.isObject() ? fromJson(treeValue.toObject()) : nullptr; if (parsed == nullptr) { parsed.reset(emptyLeafTree()); } // The tab root is always a container (the >=2-children invariant): a // bare-leaf parse is wrapped in a one-child horizontal container here // (the normalizeSessionTree semantics), then handed to the tab. SplitNode* parsedNode = parsed.get(); SplitContainerNode* root = parsedNode->asContainer(); if (root == nullptr) { // Bare leaf: wrap it (normalizeSessionTree semantics). auto* wrapped = new SplitContainerNode(SplitAxis::Horizontal, newUuid(), nullptr); wrapped->appendChild(parsedNode, 1.0); root = wrapped; } // setTreeRoot QObject-parents the root to the tab (taking the whole // subtree); only then does the unique_ptr let go. The released pointer // is deliberately discarded (std::ignore) — the tab owns it now. tab->setTreeRoot(root); // The released pointer is deliberately discarded — the tab owns the // whole subtree now. std::ignore consumes the nodiscard-like lint. static_cast<void>(std::ignore = parsed.release()); // Seed pane records with the leaf spawn cwd from cwdRel. const QStringList ids = tab->treeRoot() != nullptr ? paneIdsInLayoutOrder(*tab->treeRoot()) : QStringList(); const QList<QJsonObject> leaves = collectLeaves(tabObj.value(QLatin1String("tree")).toObject()); for (auto&& [p, paneId] : std::views::zip(std::views::iota(0), ids)) { const int idx = static_cast<int>(p); PaneRecord record; record.paneId = paneId; record.workspaceId = wsId; record.tabId = tab->id(); // A corrupted tree (e.g. a zero-children node) can yield MORE pane // ids than leaf descriptors: fall back to the workspace path for // the missing spawn cwd instead of indexing out of range. record.spawnCwd = idx < static_cast<int>(leaves.size()) ? leafCwdFromJson(leaves.at(static_cast<qsizetype>(idx)), wsPath) : wsPath; // Same corrupted-tree guard as spawnCwd: fall back to an empty title // when the leaf descriptor is missing. record.spawnTitle = idx < static_cast<int>(leaves.size()) ? objectString(leaves.at(static_cast<qsizetype>(idx)), "title") : QString(); record.editorPath = idx < static_cast<int>(leaves.size()) ? objectString(leaves.at(static_cast<qsizetype>(idx)), "editorPath") : QString(); panes.insert(record.paneId, record); } tabs.append(tab); } if (tabs.isEmpty()) { auto* tab = new XenoTab(newUuid(), workspace); tab->setActive(true); tab->setTreeRoot(emptyLeafTree(path)); tabs.append(tab); } workspace->setTabs(tabs); return workspace;}
} // namespace
void AppModel::loadSession(const QVariantMap& session){ qDeleteAll(m_workspaces); m_workspaces.clear(); m_panes.clear(); const QVariantList workspaces = session.value(QLatin1String("workspaces")).toList(); const QJsonDocument document = QJsonDocument::fromVariant(session); const QJsonObject root = document.object(); const QJsonArray wsArray = root.value(QLatin1String("workspaces")).toArray(); for (const auto& wsValue : wsArray) { if (!wsValue.isObject()) { continue; } Workspace* workspace = workspaceFromJson(wsValue.toObject(), m_panes); // Normalize every tab tree: clamp + renormalize measured ratios (the // bare-leaf wrap already happened in workspaceFromJson). for (XenoTab* tab : workspace->tabs()) { if (tab->treeRoot() != nullptr) { normalizeTree(*tab->treeRoot()); } } m_workspaces.append(workspace); } // Startup validation ran above. Fall back to the default workspace when // every workspace in the session was unusable. if (m_workspaces.isEmpty()) { Workspace* workspace = new DirWorkspace(newUuid(), QStringLiteral("workspace"), QString(), nullptr); workspace->setParent(this); auto* tab = new XenoTab(newUuid(), workspace); tab->setActive(true); tab->setTreeRoot(emptyLeafTree()); workspace->setTabs({tab}); m_workspaces.append(workspace); m_activeWorkspaceIndex = 0; // Register the default pane's record so focus/title lookups resolve. const QString defaultPaneId = firstPaneIdOfTab(0, 0); PaneRecord record; record.paneId = defaultPaneId; record.workspaceId = workspace->id(); record.tabId = tab->id(); record.spawnCwd = QString(); m_panes.insert(defaultPaneId, record); rebuildAllPathIndexes(); m_activePath = {.workspaceId = workspace->id(), .tabId = tab->id(), .paneId = defaultPaneId}; emit workspacesChanged(); emit activeWorkspaceChanged(0); emit focusedPaneChanged(m_activePath.paneId); return; } // activeWorkspace by NAME (the schema's selection key). const QString activeName = root.value(QLatin1String("activeWorkspace")).toString(); m_activeWorkspaceIndex = 0; for (auto&& [i, workspace] : std::views::zip(std::views::iota(0), m_workspaces)) { if (workspace->name() == activeName) { m_activeWorkspaceIndex = static_cast<int>(i); break; } } for (Workspace* workspace : m_workspaces) { workspace->setParent(this); } rebuildAllPathIndexes(); // Resolve the persisted active tab of the selected workspace (the // session JSON flags it via tab "active"; workspaceFromJson guarantees // a flagged tab only when it fabricates one, so scan defensively). Workspace* activeWorkspace = m_workspaces.at(m_activeWorkspaceIndex); int activeTabIndex = 0; const QList<XenoTab*>& activeTabs = activeWorkspace->tabs(); for (auto&& [t, tab] : std::views::zip(std::views::iota(0), activeTabs)) { if (tab->active()) { activeTabIndex = static_cast<int>(t); break; } } m_activePath = { .workspaceId = activeWorkspace->id(), .tabId = activeTabs.at(activeTabIndex)->id(), .paneId = activeTabs.at(activeTabIndex)->lastActivePaneId().isEmpty() ? firstPaneIdOfTab(m_activeWorkspaceIndex, activeTabIndex) : activeTabs.at(activeTabIndex)->lastActivePaneId()}; emit workspacesChanged(); emit activeWorkspaceChanged(m_activeWorkspaceIndex); emit focusedPaneChanged(m_activePath.paneId);}
// ---------------------------------------------------------------------------// Startup: load the session or build the default workspace. Mirrors// Shell.Component.onCompleted -> buildFromSession/buildDefaultWorkspace.// ---------------------------------------------------------------------------
void AppModel::startup(const QVariantList& startupCommands, const QString& command, const QString& workingDir){ const QVariantMap session = SessionStore::load(); const QVariantList workspaces = session.value(QLatin1String("workspaces")) .toList(); if (!session.isEmpty() && !workspaces.isEmpty()) { loadSession(session); return; } // Default workspace (buildDefaultWorkspace): kind directory, name // "workspace", path = workingDir, one tab, one pane. auto* workspace = new DirWorkspace(newUuid(), QStringLiteral("workspace"), workingDir, this); auto* tab = new XenoTab(newUuid(), workspace); tab->setActive(true); tab->setTreeRoot(emptyLeafTree(workingDir)); workspace->setTabs({tab}); m_workspaces.append(workspace); m_activeWorkspaceIndex = 0; const QString defaultPaneId = firstPaneIdOfTab(0, 0); PaneRecord record; record.paneId = defaultPaneId; record.workspaceId = workspace->id(); record.tabId = tab->id(); record.spawnCwd = workingDir; record.spawnCommand = command; record.spawnInitialCommands = std::accumulate( startupCommands.cbegin(), startupCommands.cend(), QStringList(), [](QStringList acc, const QVariant& line) { acc.append(line.toString()); return acc; }); m_panes.insert(defaultPaneId, record); rebuildAllPathIndexes(); m_activePath = {.workspaceId = workspace->id(), .tabId = tab->id(), .paneId = defaultPaneId}; emit workspacesChanged(); emit activeWorkspaceChanged(0); emit focusedPaneChanged(m_activePath.paneId);}
void AppModel::rebuildAllPathIndexes(){ m_paths.clear(); for (const Workspace* workspace : m_workspaces) { const QString workspaceId = workspace->id(); for (const XenoTab* tab : workspace->tabs()) { if (tab->treeRoot() == nullptr) { continue; } QVector<int> rootPath; indexSubtree(*tab->treeRoot(), workspaceId, tab->id(), rootPath, m_paths); } }}
// ---------------------------------------------------------------------------// Workspace mutators.// ---------------------------------------------------------------------------
QString AppModel::addWorkspace(WorkspaceKind kind, const QString& path, const QString& name, const QString& repoRoot){ const QString resolvedName = !name.isEmpty() ? name : defaultNameFromPath(path); const QString id = newUuid(); Workspace* workspace = nullptr; switch (kind) { case WorkspaceKind::Directory: workspace = new DirWorkspace(id, resolvedName, path, this); break; case WorkspaceKind::Repository: workspace = new RepositoryWorkspace(id, resolvedName, path, repoRoot, this); break; case WorkspaceKind::JjWorkspace: workspace = new JjWorkspace(id, resolvedName, path, repoRoot, resolvedName, this); break; case WorkspaceKind::GitWorktree: workspace = new GitWorktree(id, resolvedName, path, repoRoot, this); break; } auto* tab = new XenoTab(newUuid(), workspace); tab->setActive(true); tab->setTreeRoot(emptyLeafTree(path)); workspace->setTabs({tab}); // Pane record for the empty leaf's pane. Read the id straight from the // fresh tab's tree: firstPaneIdOfTab indexes m_workspaces, which does // not contain this workspace yet. const QString paneId = paneIdsInLayoutOrder(*tab->treeRoot()).constFirst(); PaneRecord record; record.paneId = paneId; record.workspaceId = workspace->id(); record.tabId = tab->id(); record.spawnCwd = path; record.spawnCommand = QString(); record.spawnTitle = QString(); m_panes.insert(paneId, record); m_workspaces.append(workspace); rebuildAllPathIndexes(); emit workspacesChanged(); selectWorkspace(static_cast<int>(m_workspaces.size()) - 1); return workspace->id();}
void AppModel::deleteWorkspace(int index){ if (index < 0 || index >= m_workspaces.size()) { return; } // Destroy the deleted workspace's panes (pool wiring kills the PTYs). for (const XenoTab* tab : m_workspaces.at(index)->tabs()) { removeTabPaneRecords(index, *tab); } m_workspaces.at(index)->deleteLater(); m_workspaces.remove(index); if (m_workspaces.isEmpty()) { // Never leave zero workspaces: recreate the default one. auto* workspace = new DirWorkspace(newUuid(), QStringLiteral("workspace"), QString(), this); auto* tab = new XenoTab(newUuid(), workspace); tab->setActive(true); tab->setTreeRoot(emptyLeafTree()); workspace->setTabs({tab}); m_workspaces.append(workspace); m_activeWorkspaceIndex = 0; rebuildAllPathIndexes(); m_activePath = {.workspaceId = workspace->id(), .tabId = tab->id(), .paneId = firstPaneIdOfTab(0, 0)}; emit workspacesChanged(); emit activeWorkspaceChanged(0); emit focusedPaneChanged(m_activePath.paneId); return; } if (index < m_activeWorkspaceIndex) { m_activeWorkspaceIndex -= 1; } else if (m_activeWorkspaceIndex >= m_workspaces.size()) { m_activeWorkspaceIndex = static_cast<int>(m_workspaces.size()) - 1; } emit workspacesChanged(); selectWorkspace(m_activeWorkspaceIndex);}
void AppModel::reseatWorkspaceKind(int index, WorkspaceKind kind, const QString& repoRoot, const QString& jjWorkspaceName){ if (index < 0 || index >= m_workspaces.size()) { return; } Workspace* old = m_workspaces.at(index); if (old->kind() == kind) { return; } // The replacement carries the shared product fields (id/name/path) plus // the caller-resolved kind-specific data and the old object's detected // VCS capabilities (the controller overwrites them via // setWorkspaceRepoCapabilities right after); the tabs MOVE to the new // object (reparented), so XenoTab identity survives. const bool oldIsJjRepo = old->isJjRepo(); const bool oldIsGitRepo = old->isGitRepo(); Workspace* replacement = nullptr; switch (kind) { case WorkspaceKind::Directory: replacement = new DirWorkspace(old->id(), old->name(), old->path(), this); break; case WorkspaceKind::Repository: replacement = new RepositoryWorkspace(old->id(), old->name(), old->path(), repoRoot, this); break; case WorkspaceKind::JjWorkspace: replacement = new JjWorkspace(old->id(), old->name(), old->path(), repoRoot, jjWorkspaceName, this); break; case WorkspaceKind::GitWorktree: replacement = new GitWorktree(old->id(), old->name(), old->path(), repoRoot, this); break; } const QList<XenoTab*> tabs = old->tabs(); for (XenoTab* tab : tabs) { tab->setParent(replacement); } replacement->setTabs(tabs); replacement->setJjRepo(oldIsJjRepo); replacement->setGitRepo(oldIsGitRepo); m_workspaces[index] = replacement; old->deleteLater(); emit workspaceDataChanged(index); emit workspacesChanged(); saveSessionSoon();}
void AppModel::setWorkspaceRepoCapabilities(int index, bool isJjRepo, bool isGitRepo){ if (index < 0 || index >= m_workspaces.size()) { return; } Workspace* workspace = m_workspaces.at(index); if (workspace->isJjRepo() == isJjRepo && workspace->isGitRepo() == isGitRepo) { return; } workspace->setJjRepo(isJjRepo); workspace->setGitRepo(isGitRepo); emit workspaceDataChanged(index); emit workspacesChanged(); saveSessionSoon();}
void AppModel::renameWorkspace(int index, const QString& name){ if (index < 0 || index >= m_workspaces.size()) { return; } m_workspaces.at(index)->setName(name); emit workspaceDataChanged(index); emit workspacesChanged(); saveSessionSoon();}
void AppModel::setWorkspaceDirectory(int index, const QString& path){ if (index < 0 || index >= m_workspaces.size()) { return; } m_workspaces.at(index)->setPath(path); emit workspaceDataChanged(index); emit workspacesChanged(); saveSessionSoon();}
void AppModel::forgetJjWorkspace(int index){ if (index < 0 || index >= m_workspaces.size()) { return; } auto* jj = qobject_cast<JjWorkspace*>(m_workspaces.at(index)); if (jj == nullptr) { return; } // JjWorkspace -> RepositoryWorkspace replacement; the jj workspace name // dies. reseatWorkspaceKind(index, WorkspaceKind::Repository, jj->repoRoot(), jj->jjWorkspaceName());}
void AppModel::selectWorkspace(int index){ if (index < 0 || index >= m_workspaces.size()) { return; } m_activeWorkspaceIndex = index; // Restore-on-select: repoint the active path to the tab's // lastActivePaneId (or the first pane in layout order when never // focused). Workspace* workspace = m_workspaces.at(index); const QList<XenoTab*>& tabs = workspace->tabs(); int activeTab = 0; // Per-workspace active-tab memory: honor the tab the workspace had // flagged active when it was last selected (fall back to the first). for (auto&& [t, tab] : std::views::zip(std::views::iota(0), tabs)) { if (tab->active()) { activeTab = static_cast<int>(t); break; } } for (auto&& [t, tab] : std::views::zip(std::views::iota(0), tabs)) { tab->setActive(static_cast<int>(t) == activeTab); } const QString tabId = tabs.at(activeTab)->id(); QString paneId = tabs.at(activeTab)->lastActivePaneId(); if (paneId.isEmpty()) { const QStringList ids = tabTree(index, activeTab) != nullptr ? paneIdsInLayoutOrder(*tabTree(index, activeTab)) : QStringList(); paneId = ids.isEmpty() ? QString() : ids.constFirst(); } m_activePath.workspaceId = workspace->id(); m_activePath.tabId = tabId; m_activePath.paneId = paneId; emit activeWorkspaceChanged(index); emit activeTabChanged(index, activeTab); emit focusedPaneChanged(m_activePath.paneId); saveSessionSoon();}
void AppModel::setMissionControlOpen(bool open){ if (m_missionControlOpen == open) { return; } m_missionControlOpen = open; emit missionControlOpenChanged();}
void AppModel::nextWorkspace(){ if (m_workspaces.size() < 2) { return; } selectWorkspace((m_activeWorkspaceIndex + 1) % static_cast<int>(m_workspaces.size()));}
void AppModel::previousWorkspace(){ if (m_workspaces.size() < 2) { return; } selectWorkspace((m_activeWorkspaceIndex - 1 + static_cast<int>(m_workspaces.size())) % static_cast<int>(m_workspaces.size()));}
void AppModel::selectWorkspaceByNumber(int number){ selectWorkspace(number - 1);}
// ---------------------------------------------------------------------------// Tab mutators.// ---------------------------------------------------------------------------
void AppModel::newTab(int wsIndex){ if (wsIndex < 0 || wsIndex >= m_workspaces.size()) { return; } Workspace* workspace = m_workspaces.at(wsIndex); for (XenoTab* tab : workspace->tabs()) { tab->setActive(false); } auto* tab = new XenoTab(newUuid(), workspace); tab->setActive(true); tab->setTreeRoot(emptyLeafTree(workspace->path())); const int tabIndex = static_cast<int>(workspace->tabs().size()); workspace->insertTab(tabIndex, tab); const QStringList ids = paneIdsInLayoutOrder(*tab->treeRoot()); if (!ids.isEmpty()) { PaneRecord record; record.paneId = ids.constFirst(); record.workspaceId = workspace->id(); record.tabId = tab->id(); record.spawnCwd = workspace->path(); m_panes.insert(ids.constFirst(), record); } rebuildPathIndex(workspace->id(), tab->id()); emit tabsChanged(m_activeWorkspaceIndex); emit activeTabChanged(m_activeWorkspaceIndex, tabIndex); emit workspacesChanged(); saveSessionSoon();}
void AppModel::closeTab(int wsIndex, int tabIndex){ if (wsIndex < 0 || wsIndex >= m_workspaces.size()) { return; } Workspace* workspace = m_workspaces.at(wsIndex); if (workspace->tabs().isEmpty()) { return; } if (workspace->tabs().size() <= 1) { // Closing the last tab closes the whole workspace (the UI asks via // CloseLastTabDialog before calling this; the model mutator removes // unconditionally). deleteWorkspace(wsIndex); return; } const int idx = std::clamp( tabIndex, 0, static_cast<int>(workspace->tabs().size()) - 1); const bool closingActive = workspace->tabs().at(idx)->active(); const QString removedTabId = workspace->tabs().at(idx)->id(); // The removed tab's panes die: pool items (PTYs) + records. removeTabPaneRecords(wsIndex, *workspace->tabs().at(idx)); workspace->removeTab(idx); if (closingActive) { const int newIdx = std::min(idx, static_cast<int>(workspace->tabs().size()) - 1); const auto& tabs = workspace->tabs(); for (auto&& [t, tab] : std::views::zip(std::views::iota(0), tabs)) { tab->setActive(static_cast<int>(t) == newIdx); } // Focus lands on the newly active tab's remembered pane. QString paneId = workspace->tabs().at(newIdx)->lastActivePaneId(); if (paneId.isEmpty()) { const QStringList ids = paneIdsInLayoutOrder(*workspace->tabs().at(newIdx)->treeRoot()); paneId = ids.isEmpty() ? QString() : ids.constFirst(); } m_activePath.tabId = workspace->tabs().at(newIdx)->id(); m_activePath.paneId = paneId; emit activeTabChanged(wsIndex, newIdx); emit focusedPaneChanged(m_activePath.paneId); } rebuildPathIndex(workspace->id(), removedTabId); emit tabsChanged(wsIndex); emit workspacesChanged(); saveSessionSoon();}
void AppModel::selectTab(int wsIndex, int tabIndex){ if (wsIndex < 0 || wsIndex >= m_workspaces.size()) { return; } Workspace* workspace = m_workspaces.at(wsIndex); if (tabIndex < 0 || tabIndex >= workspace->tabs().size()) { return; } // setActiveTabIndex flips the per-tab active flags AND emits // activeTabChanged — the NOTIFY behind Workspace::activeTabIndex, which // SplitTreeDelegate's containerNode binding reads // (ws.tabs[ws.activeTabIndex].treeRoot). Without it the binding never // re-evaluates: the split host keeps rendering the PREVIOUS tab's tree // and the newly selected tab's panes never materialize (and never gain // keyboard focus). workspace->setActiveTabIndex(tabIndex); // Restore-on-select: lastActivePaneId, else first pane in layout order. QString paneId = workspace->tabs().at(tabIndex)->lastActivePaneId(); if (paneId.isEmpty()) { const QStringList ids = paneIdsInLayoutOrder(*workspace->tabs().at(tabIndex)->treeRoot()); paneId = ids.isEmpty() ? QString() : ids.constFirst(); } m_activePath.workspaceId = workspace->id(); m_activePath.tabId = workspace->tabs().at(tabIndex)->id(); m_activePath.paneId = paneId; emit activeTabChanged(wsIndex, tabIndex); emit focusedPaneChanged(m_activePath.paneId); saveSessionSoon();}
// User-set title override for the tab at (wsIndex, tabIndex). Empty clears.// View-only state: no session save, no focus/order/structure change; the// displayed title flips via XenoTab::setTitleOverride. Not persisted, so a// restart falls back to the automatic name.void AppModel::setTabTitleOverride(int wsIndex, int tabIndex, const QString& title){ if (wsIndex < 0 || tabIndex < 0 || wsIndex >= m_workspaces.size() || tabIndex >= m_workspaces.at(wsIndex)->tabs().size()) { return; } m_workspaces.at(wsIndex)->tabs().at(tabIndex)->setTitleOverride( title.trimmed());}
void AppModel::nextTab(){ Workspace* workspace = m_workspaces.at(m_activeWorkspaceIndex); if (workspace->tabs().size() < 2) { return; } int current = 0; for (auto&& [t, tab] : std::views::zip(std::views::iota(0), workspace->tabs())) { if (tab->active()) { current = static_cast<int>(t); break; } } selectTab(m_activeWorkspaceIndex, (current + 1) % static_cast<int>(workspace->tabs().size()));}
void AppModel::previousTab(){ Workspace* workspace = m_workspaces.at(m_activeWorkspaceIndex); if (workspace->tabs().size() < 2) { return; } int current = 0; for (auto&& [t, tab] : std::views::zip(std::views::iota(0), workspace->tabs())) { if (tab->active()) { current = static_cast<int>(t); break; } } selectTab(m_activeWorkspaceIndex, (current - 1 + static_cast<int>(workspace->tabs().size())) % static_cast<int>(workspace->tabs().size()));}
void AppModel::moveTab(int wsIndex, int tabIndex, int toIndex){ if (wsIndex < 0 || wsIndex >= m_workspaces.size()) { return; } Workspace* workspace = m_workspaces.at(wsIndex); const int count = static_cast<int>(workspace->tabs().size()); if (count < 2) { return; } if (tabIndex < 0 || tabIndex >= count) { return; } const int to = std::clamp(toIndex, 0, count - 1); if (to == tabIndex) { return; } const bool movingActive = workspace->tabs().at(tabIndex)->active(); const QString movedTabId = workspace->tabs().at(tabIndex)->id(); // Where the active tab's index lands after the removal+insert: the moved // tab's destination when it IS the active tab; otherwise unchanged while // it sits before the move span, shifted by one when it sits inside it. const int currentActive = workspace->activeTabIndex(); int newActiveIndex = currentActive; if (movingActive) { newActiveIndex = to; } else if (currentActive > tabIndex && currentActive <= to) { newActiveIndex = currentActive - 1; } else if (currentActive < tabIndex && currentActive >= to) { newActiveIndex = currentActive + 1; } workspace->moveTab(tabIndex, to); // The active tab is the SAME TAB after the move: re-assert its flag at // the new index so Workspace::activeTabIndex() (which scans the active // flag) and the QML binding both read the moved position. workspace->setActiveTabIndex(newActiveIndex); if (movingActive) { m_activePath.tabId = movedTabId; emit activeTabChanged(wsIndex, to); } emit tabsChanged(wsIndex); saveSessionSoon();}
// ---------------------------------------------------------------------------// Pane/tree mutators. Every one keeps the path index in the same// transaction and upholds the invariants (containers >=2 children with// auto-collapse, ratios renormalized, pane in exactly one position,// never-zero workspace/tab/pane cascade). Node-level NOTIFYs drive the// reactive delegates; AppModel emits the coarse signals for the ordered// views.// ---------------------------------------------------------------------------
namespace {
// Map a QML-facing direction string to a SplitDirection. Accepts both the// splitFocused spellings ("splitLeft"/"splitRight"/"splitUp"/"splitDown")// and the spatial spellings ("left"/"right"/"up"/"down"); anything unknown// falls back to Down (mirrors TerminalPageController::directionFromString,// kept local so the Quick-free core has no dependency on the controller).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;}
// Insert `dragged` into `parent` beside the child at `targetSlot` along// the wanted axis: same axis takes half the target's share (its measured// ratio, else the coalesced 1/n share); an axis change wraps a fresh// container with the wanted axis over the target's slot INHERITING the// target's ratio, 50/50 inside. Shared by movePaneBeside's same-tab and// cross-tab branches.void insertBeside(PaneNode* dragged, SplitContainerNode* parent, const int targetSlot, const SplitAxis wantAxis, const bool before){ if (parent->axis() == static_cast<int>(wantAxis)) { const qreal targetRatio = parent->ratioAt(targetSlot); qreal baseRatio = 1.0; if (targetRatio >= 0.0) { baseRatio = targetRatio; } else if (parent->childCount() > 0) { baseRatio = 1.0 / static_cast<qreal>(parent->childCount()); } const int insertAt = before ? targetSlot : targetSlot + 1; parent->insertChild(insertAt, dragged, baseRatio / 2.0); parent->setRatioAt(before ? targetSlot + 1 : targetSlot, baseRatio / 2.0); renormalize(*parent); } else { const qreal inheritedRatio = parent->ratioAt(targetSlot); auto* wrapper = new SplitContainerNode(wantAxis, newUuid(), nullptr); SplitNode* targetChild = parent->takeChild(targetSlot); if (before) { wrapper->appendChild(dragged, 0.5); wrapper->appendChild(targetChild, 0.5); } else { wrapper->appendChild(targetChild, 0.5); wrapper->appendChild(dragged, 0.5); } renormalize(*wrapper); parent->insertChild(targetSlot, wrapper, inheritedRatio); renormalize(*parent); }}
} // namespace
void AppModel::splitPane(const QString& paneId, SplitDirection direction){ static_cast<void>(splitPaneCore(paneId, direction, defaultSplitKindFor(paneId), QString(), 0, 0));}
PaneKind AppModel::defaultSplitKindFor(const QString& paneId) const{ const QString word = paneKindOf(paneId); if (word == QLatin1String("editor")) { return PaneKind::Editor; } return PaneKind::Terminal;}
QString AppModel::splitPaneCore(const QString& paneId, SplitDirection direction, PaneKind kind, const QString& path, int line, int column){ const auto location = m_paths.constFind(paneId); if (location == m_paths.constEnd()) { return {}; } const QString workspaceId = location.value().workspaceId; const QString tabId = location.value().tabId; const int wsIndex = workspaceIndexOf(workspaceId); const int tabIndex = tabIndexOf(workspaceId, tabId); if (wsIndex < 0 || tabIndex < 0) { return {}; } SplitContainerNode* tree = tabTree(wsIndex, tabIndex); if (tree == nullptr) { return {}; } XenoTab* tab = m_workspaces.at(wsIndex)->tabs().at(tabIndex); // Splitting a zoomed pane ends the zoom: the fresh sibling is exactly // what the zoom was hiding, and the split focuses it. Structural // changes that make a zoom meaningless clear it, the same rule // keepOnlyPane and the pane-move hygiene follow. if (tab != nullptr && !tab->zoomedPaneId().isEmpty()) { tab->setZoomedPaneId(QString()); } const bool vertical = direction == SplitDirection::Up || direction == SplitDirection::Down; const SplitAxis wantAxis = vertical ? SplitAxis::Vertical : SplitAxis::Horizontal; const bool before = direction == SplitDirection::Left || direction == SplitDirection::Up;
// The pane's parent container: the tab root when the path holds only // the leaf index. (Tab roots are always containers — the session loader // wraps bare-leaf roots.) QVector<int> containerPath = location.value().path; if (containerPath.isEmpty()) { return {}; // malformed: a container root always has a non-empty path } const int leafIndex = containerPath.takeLast(); SplitContainerNode* parent = [&]() -> SplitContainerNode* { SplitContainerNode* node = tree; for (const int step : containerPath) { if (step < 0 || step >= node->childCount()) { return nullptr; } SplitNode* next = node->childAt(step); node = next->asContainer(); if (node == nullptr) { return nullptr; } } return node; }(); if (parent == nullptr || leafIndex < 0 || leafIndex >= parent->childCount()) { return {}; }
// Base share the old pane splits in two. Measured: its own ratio, so // P and F each get paneRatio/2 (50/50) and the container's measured sum // is preserved; renormalize below then rebalances any drift so the sum // is exactly 1. Unmeasured (legacy -1): start from QML's coalesced 1/n // share so a fresh sub-pane from an otherwise-fully-measured container // gets a sensible half, then renormalize rebalances every sibling // proportionally to a sum of 1. const int paneSlot = leafIndex; const qreal paneRatio = parent->ratioAt(paneSlot); qreal baseRatio = 1.0; if (paneRatio >= 0.0) { baseRatio = paneRatio; } else if (parent->childCount() > 0) { baseRatio = 1.0 / static_cast<qreal>(parent->childCount()); } const qreal half = baseRatio / 2.0;
// Fresh pane inherits the origin pane's cwd (tmux-like): the live cwd // note wins, else the spawn cwd. const PaneRecord originRecord = m_panes.value(paneId); PaneKind freshKind = kind; PaneRecord record; record.paneId = newUuid(); record.workspaceId = workspaceId; record.tabId = tabId; record.spawnCwd = !originRecord.cwd.isEmpty() ? originRecord.cwd : originRecord.spawnCwd; record.spawnCommand = QString(); record.spawnTitle = QString(); if (freshKind == PaneKind::Editor && path.isEmpty()) { // An editor pane always has a file, so an editor request with no file is // a chooser that offers files only. The arm-editor command relies on // this. freshKind = PaneKind::Picker; record.pickerFilesOnly = true; } if (freshKind == PaneKind::Editor) { record.editorPath = path; record.editorLine = line; record.editorColumn = column; }
// Seed the fresh pane's record BEFORE the tree mutation: insertChild // fires childrenChanged synchronously, the reactive delegate // materializes the new pane, and its start() reads paneSpawnSpec // immediately — the record must already be there. m_panes.insert(record.paneId, record);
if (parent->axis() == static_cast<int>(wantAxis)) { // Same axis: insert a sibling next to the pane. The old pane and the // fresh sibling split the old share 50/50. The insert shifts the old // pane to leafIndex+1 when inserting before it (`before`), so set the // halved ratio on the pane's ACTUAL slot, not a stale leafIndex. const int insertAt = before ? leafIndex : leafIndex + 1; auto* fresh = new PaneNode(record.paneId, nullptr, freshKind); parent->insertChild(insertAt, fresh, half); parent->setRatioAt(before ? leafIndex + 1 : leafIndex, half); renormalize(*parent); // Focus the new pane (splitting focuses the fresh side, like today). m_activePath.paneId = record.paneId; emit focusedPaneChanged(record.paneId); } else { // Axis change: wrap. A fresh sub-container takes over the old pane's // slot in the parent, INHERITING the old pane's ratio (so the slot is // never dropped); inside the wrapper the pane and the fresh sibling // are 50/50. const qreal inheritedRatio = parent->ratioAt(paneSlot); auto* wrapper = new SplitContainerNode(wantAxis, newUuid(), nullptr); auto* fresh = new PaneNode(record.paneId, nullptr, freshKind); SplitNode* paneChild = parent->takeChild(paneSlot); if (before) { wrapper->appendChild(fresh, 0.5); wrapper->appendChild(paneChild, 0.5); } else { wrapper->appendChild(paneChild, 0.5); wrapper->appendChild(fresh, 0.5); } renormalize(*wrapper); parent->insertChild(paneSlot, wrapper, inheritedRatio); renormalize(*parent); m_activePath.paneId = record.paneId; emit focusedPaneChanged(record.paneId); } rebuildPathIndex(workspaceId, tabId); tab->notifyStructureChanged(); emit structureChanged(wsIndex, tabIndex); syncTabTitle(wsIndex, tabIndex); saveSessionSoon(); return record.paneId;}
QString AppModel::splitPaneWithKind(const QString& paneId, const QString& direction, const QString& kind, const QString& path, int line, int column){ return splitPaneCore(paneId, directionFromString(direction), paneKindFromJson(kind), path, line, column);}
void AppModel::setPaneKindFromString(const QString& paneId, const QString& kind, const QString& path){ setPaneKind(paneId, paneKindFromJson(kind), path);}
void AppModel::setPaneKind(const QString& paneId, PaneKind kind, const QString& path){ const auto location = m_paths.constFind(paneId); if (location == m_paths.constEnd()) { return; } auto recordIt = m_panes.find(paneId); if (recordIt == m_panes.end()) { return; } PaneKind wanted = kind; if (wanted == PaneKind::Editor && path.isEmpty()) { wanted = PaneKind::Picker; recordIt.value().pickerFilesOnly = true; } else { recordIt.value().pickerFilesOnly = false; } recordIt.value().editorPath = wanted == PaneKind::Editor ? path : QString(); recordIt.value().editorLine = 0; recordIt.value().editorColumn = 0; const int wsIndex = workspaceIndexOf(location.value().workspaceId); const int tabIndex = tabIndexOf(location.value().workspaceId, location.value().tabId); SplitContainerNode* tree = tabTree(wsIndex, tabIndex); PaneNode* leaf = nullptr; if (tree != nullptr) { forEachPane(*tree, [&](const PaneNode& node) { if (leaf == nullptr && node.paneId() == paneId) { // NOLINTNEXTLINE(cppcoreguidelines-pro-type-const-cast) leaf = const_cast<PaneNode*>(&node); } }); } if (leaf == nullptr || leaf->paneKind() == wanted) { saveSessionSoon(); return; } // The pane stops showing what it showed. Drop the pooled content first: a // shell the pane no longer displays, or an editor controller on another // file. Pool cleanup before the kind notify keeps the rebuilt wrapper from // adopting the old content. destroyPoolPanes({paneId}); leaf->setPaneKind(wanted); if (wsIndex >= 0 && tabIndex >= 0) { syncTabTitle(wsIndex, tabIndex); m_workspaces.at(wsIndex)->tabs().at(tabIndex)->notifyStructureChanged(); emit structureChanged(wsIndex, tabIndex); } saveSessionSoon();}
void AppModel::splitPaneFromString(const QString& paneId, const QString& direction){ splitPane(paneId, directionFromString(direction));}
// Container collapse cascade shared by closePane and movePaneBeside: walk// the ancestors along `removedPath` (a container path with the removed// leaf's index already taken off) and promote any container left holding// one child into ITS parent, keeping the >=2-children invariant.void AppModel::collapseSingletons(SplitContainerNode* tree, const QVector<int>& removedPath){ QVector<int> walkPath = removedPath; while (!walkPath.isEmpty()) { const int idx = walkPath.takeLast(); SplitContainerNode* owner = [&]() -> SplitContainerNode* { SplitContainerNode* node = tree; for (const int step : walkPath) { if (step < 0 || step >= node->childCount()) { return nullptr; } SplitNode* next = node->childAt(step); node = next->asContainer(); if (node == nullptr) { return nullptr; } } return node; }(); if (owner == nullptr) { break; } if (idx < 0 || idx >= owner->childCount()) { break; } auto* child = owner->childAt(idx)->asContainer(); if (child == nullptr) { break; } if (child->childCount() == 0) { // The removed pane was this container's last child: the empty // container dies (leaving it in the tree renders a permanent gap). child->deleteLater(); (void)owner->takeChild(idx); // The owner itself may now be a singleton/empty — continue up. continue; } if (child->childCount() != 1) { break; } // Promote the container's SOLE CHILD into the owner at the same slot // (the container itself dies). Moving the container back into its own // slot would be a no-op that leaves the singleton in the tree. const qreal slotRatio = owner->ratioAt(idx); SplitNode* sole = child->takeChild(0); child->deleteLater(); (void)owner->takeChild(idx); owner->insertChild(idx, sole, slotRatio); renormalize(*owner); // Continue up: the promoted child may itself be a singleton now. }}
void AppModel::closePane(const QString& paneId){ const auto location = m_paths.constFind(paneId); if (location == m_paths.constEnd()) { return; } const QString workspaceId = location.value().workspaceId; const QString tabId = location.value().tabId; const int wsIndex = workspaceIndexOf(workspaceId); const int tabIndex = tabIndexOf(workspaceId, tabId); if (wsIndex < 0 || tabIndex < 0) { return; } SplitContainerNode* tree = tabTree(wsIndex, tabIndex); if (tree == nullptr) { return; } // Locate the pane's parent container + slot by walking the path. QVector<int> panePath = location.value().path; if (panePath.isEmpty()) { return; } const int leafIndex = panePath.takeLast(); SplitContainerNode* parent = [&]() -> SplitContainerNode* { SplitContainerNode* node = tree; for (const int step : panePath) { if (step < 0 || step >= node->childCount()) { return nullptr; } SplitNode* next = node->childAt(step); node = next->asContainer(); if (node == nullptr) { return nullptr; } } return node; }(); if (parent == nullptr || leafIndex < 0 || leafIndex >= parent->childCount()) { return; } // Remember the pane's sibling position for focus repointing: the // sibling that absorbs the closed pane's space is the next pane in // layout order (wrapping to the first when it was last). const QStringList layoutBefore = paneIdsInLayoutOrder(*tree); const auto panePos = layoutBefore.indexOf(paneId); // When the closed pane was the last in layout order, wrap to the first // remaining sibling (the absorbing one either way). QString absorbId; if (layoutBefore.size() > 1) { qsizetype next = panePos + 1; if (next >= layoutBefore.size()) { next = (panePos == 0) ? 0 : panePos - 1; } absorbId = layoutBefore.at(next); }
SplitNode* doomed = parent->takeChild(leafIndex); doomed->deleteLater(); renormalize(*parent); m_panes.remove(paneId); destroyPoolPanes({paneId});
// Container collapse cascade: any ancestor container now holding one // child promotes that child into ITS parent (>=2-children invariant). collapseSingletons(tree, panePath);
// Never-zero cascade: the tab's tree lost its last pane -> auto-close // the tab (which auto-closes the workspace when it was the last tab). if (tree->childCount() == 0 || paneIdsInLayoutOrder(*tree).isEmpty()) { closeTab(wsIndex, tabIndex); return; } // Focus: repoint to the absorbing sibling. The tab's remembered pane // follows (the closed pane's title must not keep labeling the tab). if (m_activePath.paneId == paneId) { m_activePath.paneId = absorbId; m_workspaces.at(wsIndex)->tabs().at(tabIndex)->setLastActivePaneId( absorbId); emit tabDataChanged(wsIndex, tabIndex); syncTabTitle(wsIndex, tabIndex); emit focusedPaneChanged(m_activePath.paneId); } rebuildPathIndex(workspaceId, tabId); clearZoomReferencing(wsIndex, tabIndex, {paneId}); m_workspaces.at(wsIndex)->tabs().at(tabIndex)->notifyStructureChanged(); emit structureChanged(wsIndex, tabIndex); saveSessionSoon();}
void AppModel::keepOnlyPane(const QString& paneId){ const auto location = m_paths.constFind(paneId); if (location == m_paths.constEnd()) { return; } const QString workspaceId = location.value().workspaceId; const QString tabId = location.value().tabId; const int wsIndex = workspaceIndexOf(workspaceId); const int tabIndex = tabIndexOf(workspaceId, tabId); SplitContainerNode* tree = tabTree(wsIndex, tabIndex); if (tree == nullptr) { return; } // Remove every other pane's record; the survivor keeps its identity. const QStringList doomed = paneIdsInLayoutOrder(*tree); QStringList doomedOthers; for (const QString& victim : doomed) { if (victim != paneId) { m_panes.remove(victim); doomedOthers.append(victim); } } destroyPoolPanes(doomedOthers); // The survivor becomes the root's sole child: a fresh tab root // container holding just its pane, ratios even, old containers gone. // The survivor's kind travels with it. PaneKind survivorKind = PaneKind::Terminal; forEachPane(*tree, [&](const PaneNode& node) { if (node.paneId() == paneId) { survivorKind = node.paneKind(); } }); auto* newRoot = new SplitContainerNode(SplitAxis::Horizontal, newUuid(), nullptr); newRoot->appendChild(new PaneNode(paneId, nullptr, survivorKind), 1.0); tree->setParent(nullptr); // detached from the tab; deleteLater below tree->deleteLater(); m_workspaces.at(wsIndex)->tabs().at(tabIndex)->setTreeRoot(newRoot); if (m_activePath.paneId != paneId) { m_activePath.paneId = paneId; emit focusedPaneChanged(paneId); } rebuildPathIndex(workspaceId, tabId); // keepOnlyPane destroyed every other pane: clear this tab's zoom if it // referenced one of them, and re-zoom to the survivor (which fills the // tab anyway) if the survivor itself was the zoom target — same id. clearZoomReferencing(wsIndex, tabIndex, doomedOthers); m_workspaces.at(wsIndex)->tabs().at(tabIndex)->notifyStructureChanged(); emit structureChanged(wsIndex, tabIndex); saveSessionSoon();}
void AppModel::swapPanes(const QString& a, const QString& b){ const auto aLoc = m_paths.constFind(a); const auto bLoc = m_paths.constFind(b); if (aLoc == m_paths.constEnd() || bLoc == m_paths.constEnd() || a == b) { return; } // Exchange the pane NODES of the two positions; the tree shapes // (and ratios) stay untouched — only identities swap slots. Works // across tabs and workspaces: both sides resolve through the path // index independently. auto resolve = [](SplitContainerNode* root, const QVector<int>& path) -> std::pair<SplitContainerNode*, int> { QVector<int> walk = path; if (walk.isEmpty()) { return {nullptr, -1}; } const int leaf = walk.takeLast(); SplitContainerNode* node = root; for (const int step : walk) { if (step < 0 || step >= node->childCount()) { return {nullptr, -1}; } SplitNode* next = node->childAt(step); node = next->asContainer(); if (node == nullptr) { return {nullptr, -1}; } } return {node, leaf}; }; const int aWsIndex = workspaceIndexOf(aLoc.value().workspaceId); const int aTabIndex = tabIndexOf(aLoc.value().workspaceId, aLoc.value().tabId); const int bWsIndex = workspaceIndexOf(bLoc.value().workspaceId); const int bTabIndex = tabIndexOf(bLoc.value().workspaceId, bLoc.value().tabId); if (aWsIndex < 0 || aTabIndex < 0 || bWsIndex < 0 || bTabIndex < 0) { return; } SplitContainerNode* aTree = tabTree(aWsIndex, aTabIndex); SplitContainerNode* bTree = tabTree(bWsIndex, bTabIndex); if (aTree == nullptr || bTree == nullptr) { return; } const auto [aParent, aSlot] = resolve(aTree, aLoc.value().path); const auto [bParent, bSlot] = resolve(bTree, bLoc.value().path); if (aParent == nullptr || bParent == nullptr) { return; } SplitNode* aNode = aParent->childAt(aSlot); SplitNode* bNode = bParent->childAt(bSlot); if (aNode->asPane() == nullptr || bNode->asPane() == nullptr) { return; } // Detach both, reinsert crossed (order: take the later slot first when // in the same container so indices stay valid). const qreal aRatio = aParent->ratioAt(aSlot); const qreal bRatio = bParent->ratioAt(bSlot); SplitNode* takenB = bParent->takeChild(bSlot); SplitNode* takenA = aParent->takeChild(aSlot); aParent->insertChild(aSlot, takenB, bRatio); bParent->insertChild(bSlot, takenA, aRatio); // The records follow the pane ids: each pane's live location is now // the OTHER pane's (workspace, tab). Spawn fields stay untouched. rehomeRecord(a, bLoc.value().workspaceId, bLoc.value().tabId); rehomeRecord(b, aLoc.value().workspaceId, aLoc.value().tabId); // Focus memory follows the exchange: a tab whose remembered pane left // remembers the pane that arrived. auto* aTab = m_workspaces.at(aWsIndex)->tabs().at(aTabIndex); auto* bTab = m_workspaces.at(bWsIndex)->tabs().at(bTabIndex); if (aTab->lastActivePaneId() == a) { aTab->setLastActivePaneId(b); emit tabDataChanged(aWsIndex, aTabIndex); syncTabTitle(aWsIndex, aTabIndex); } if (bTab->lastActivePaneId() == b) { bTab->setLastActivePaneId(a); emit tabDataChanged(bWsIndex, bTabIndex); syncTabTitle(bWsIndex, bTabIndex); } rebuildAllPathIndexes(); aTab->notifyStructureChanged(); bTab->notifyStructureChanged(); emit structureChanged(aWsIndex, aTabIndex); emit structureChanged(bWsIndex, bTabIndex); saveSessionSoon();}
void AppModel::movePaneBeside(const QString& paneId, const QString& targetPaneId, const QString& direction){ const auto loc = m_paths.constFind(paneId); const auto targetLoc = m_paths.constFind(targetPaneId); if (loc == m_paths.constEnd() || targetLoc == m_paths.constEnd() || paneId == targetPaneId) { return; } const bool vertical = direction == QLatin1String("up") || direction == QLatin1String("down"); const SplitAxis wantAxis = vertical ? SplitAxis::Vertical : SplitAxis::Horizontal; const bool before = direction == QLatin1String("left") || direction == QLatin1String("up");
// Full locations resolve through the id-keyed path index: the source // and the destination may live in different tabs or workspaces. const QString workspaceId = loc.value().workspaceId; const QString tabId = loc.value().tabId; const int wsIndex = workspaceIndexOf(workspaceId); const int tabIndex = tabIndexOf(workspaceId, tabId); const int dstWsIndex = workspaceIndexOf(targetLoc.value().workspaceId); const int dstTabIndex = tabIndexOf(targetLoc.value().workspaceId, targetLoc.value().tabId); if (wsIndex < 0 || tabIndex < 0 || dstWsIndex < 0 || dstTabIndex < 0) { return; } SplitContainerNode* tree = tabTree(wsIndex, tabIndex); SplitContainerNode* dstTree = tabTree(dstWsIndex, dstTabIndex); if (tree == nullptr || dstTree == nullptr) { return; } const bool sameTab = workspaceId == targetLoc.value().workspaceId && tabId == targetLoc.value().tabId; // Same-tab drops need a second pane in the shared tree. A cross-tab // drop may empty the source tree (the tab auto-closes), but dragging // the workspace's only tab's only pane would kill the workspace. if (sameTab ? paneIdsInLayoutOrder(*tree).size() < 2 : (m_workspaces.at(wsIndex)->tabs().size() <= 1 && paneIdsInLayoutOrder(*tree).size() <= 1)) { return; }
// Locate the dragged pane's parent + slot by path walk (same shape as // swapPanes' resolve), then take the node out keeping it alive: the // SAME PaneNode is reinserted, so pane identity, records, and pool // items survive the move. auto resolve = [](SplitContainerNode* root, const QVector<int>& path) -> std::pair<SplitContainerNode*, int> { QVector<int> walk = path; if (walk.isEmpty()) { return {nullptr, -1}; } const int leaf = walk.takeLast(); SplitContainerNode* node = root; for (const int step : walk) { if (step < 0 || step >= node->childCount()) { return {nullptr, -1}; } SplitNode* next = node->childAt(step); node = next->asContainer(); if (node == nullptr) { return {nullptr, -1}; } } return {node, leaf}; }; const auto [paneParent, paneSlot] = resolve(tree, loc.value().path); if (paneParent == nullptr || paneSlot < 0 || paneSlot >= paneParent->childCount() || paneParent->childAt(paneSlot)->asPane() == nullptr) { return; } PaneNode* dragged = paneParent->childAt(paneSlot)->asPane(); (void)paneParent->takeChild(paneSlot);
// Collapse any ancestor left holding one child, exactly like closePane. QVector<int> removedPath = loc.value().path; removedPath.takeLast(); renormalize(*paneParent); collapseSingletons(tree, removedPath);
if (sameTab) { // Same-tab in-page drop: re-resolve the target from the FRESH index // (its slot may have shifted during the removal/collapse) and insert // beside it. rebuildPathIndex(workspaceId, tabId); const auto freshTarget = m_paths.constFind(targetPaneId); if (freshTarget == m_paths.constEnd()) { return; } const auto [targetParent, targetSlot] = resolve(tree, freshTarget.value().path); if (targetParent == nullptr || targetSlot < 0 || targetSlot >= targetParent->childCount() || targetParent->childAt(targetSlot)->asPane() == nullptr) { return; } insertBeside(dragged, targetParent, targetSlot, wantAxis, before);
// A drop focuses the dropped pane (plus the tab's remembered pane // follows, like focusPane) — except in a zoomed tab, where the // visible pane keeps the focus, exactly as focusPane decides it. The // dropped pane is absent from the path index at this point (it was // taken out of the tree above), so the pin is resolved from the tab. XenoTab* tab = m_workspaces.at(wsIndex)->tabs().at(tabIndex); const QString focused = zoomPinInTab(*tab, paneId); m_activePath.paneId = focused; tab->setLastActivePaneId(focused); emit tabDataChanged(wsIndex, tabIndex); syncTabTitle(wsIndex, tabIndex); emit focusedPaneChanged(focused);
rebuildPathIndex(workspaceId, tabId); tab->notifyStructureChanged(); emit structureChanged(wsIndex, tabIndex); saveSessionSoon(); return; }
// Cross-tab or cross-workspace drop. The record rehomes FIRST: an // emptied source tab is closed below, and removeTabPaneRecords must // not touch the moved pane (its record no longer points at the // source, and the source tree holds no other panes' nodes for it). rehomeRecord(paneId, targetLoc.value().workspaceId, targetLoc.value().tabId); // The source tree lost its last pane: close the tab (the record has // already moved; removeTabPaneRecords finds an empty tree for the // dragged pane, so nothing else dies). closeTab emits the tab and // workspace list signals itself. if (tree->childCount() == 0 || paneIdsInLayoutOrder(*tree).isEmpty()) { closeTab(wsIndex, tabIndex); } else { // The source tab survived: repoint its remembered pane when it was // the dragged one. auto* srcTab = m_workspaces.at(wsIndex)->tabs().at(tabIndex); if (srcTab->lastActivePaneId() == paneId) { const QStringList remaining = paneIdsInLayoutOrder(*tree); srcTab->setLastActivePaneId(remaining.isEmpty() ? QString() : remaining.constFirst()); emit tabDataChanged(wsIndex, tabIndex); syncTabTitle(wsIndex, tabIndex); } }
// Re-resolve the target from the FRESH index (the source tab's index // was rebuilt above; the destination never shifted). rebuildAllPathIndexes(); const auto freshTarget = m_paths.constFind(targetPaneId); if (freshTarget == m_paths.constEnd()) { return; } const auto [targetParent, targetSlot] = resolve(dstTree, freshTarget.value().path); if (targetParent == nullptr || targetSlot < 0 || targetSlot >= targetParent->childCount() || targetParent->childAt(targetSlot)->asPane() == nullptr) { return; } insertBeside(dragged, targetParent, targetSlot, wantAxis, before);
// Reveal the drop: the destination tab becomes active and the dropped // pane takes focus (the model's drop-focuses rule; the pane is // visible when the overlay closes). The index must know the dragged // pane's NEW location first, or focusPane no-ops. The index is also // the source of truth for the destination indices: closing an emptied // source tab in the SAME workspace shifts every later tab's index. rebuildAllPathIndexes(); const auto dstLoc = m_paths.constFind(paneId); if (dstLoc == m_paths.constEnd()) { return; } const int finalDstWs = workspaceIndexOf(dstLoc.value().workspaceId); const int finalDstTab = tabIndexOf(dstLoc.value().workspaceId, dstLoc.value().tabId); if (finalDstWs < 0 || finalDstTab < 0) { return; } selectTab(finalDstWs, finalDstTab); focusPane(paneId); // The dragged pane left its source tab: drop a dangling zoom there. clearZoomReferencing(wsIndex, tabIndex, {paneId}); m_workspaces.at(finalDstWs)->tabs().at(finalDstTab) ->notifyStructureChanged(); emit structureChanged(finalDstWs, finalDstTab); saveSessionSoon();}
void AppModel::movePaneToTab(const QString& paneId, const int wsIndex, const int tabIndex){ const auto loc = m_paths.constFind(paneId); if (loc == m_paths.constEnd()) { return; } if (wsIndex < 0 || wsIndex >= m_workspaces.size()) { return; } Workspace* dstWorkspace = m_workspaces.at(wsIndex); if (tabIndex < 0 || tabIndex >= dstWorkspace->tabs().size()) { return; } XenoTab* dstTab = dstWorkspace->tabs().at(tabIndex); const QString dstWorkspaceId = dstWorkspace->id(); const QString dstTabId = dstTab->id(); const int srcWsIndex = workspaceIndexOf(loc.value().workspaceId); const int srcTabIndex = tabIndexOf(loc.value().workspaceId, loc.value().tabId); if (srcWsIndex < 0 || srcTabIndex < 0) { return; } SplitContainerNode* srcTree = tabTree(srcWsIndex, srcTabIndex); SplitContainerNode* dstTree = tabTree(wsIndex, tabIndex); if (srcTree == nullptr || dstTree == nullptr) { return; } const bool sameTab = loc.value().workspaceId == dstWorkspaceId && loc.value().tabId == dstTabId; // Same-tab append needs a second pane to move (otherwise it is a // no-op reshuffle); dragging the workspace's only tab's only pane // must not kill the workspace. if (paneIdsInLayoutOrder(*srcTree).size() < 2 || (sameTab && paneIdsInLayoutOrder(*dstTree).size() < 2) || (!sameTab && m_workspaces.at(srcWsIndex)->tabs().size() <= 1)) { return; }
// Locate + take the dragged pane's node (same walk as movePaneBeside). auto resolve = [](SplitContainerNode* root, const QVector<int>& path) -> std::pair<SplitContainerNode*, int> { QVector<int> walk = path; if (walk.isEmpty()) { return {nullptr, -1}; } const int leaf = walk.takeLast(); SplitContainerNode* node = root; for (const int step : walk) { if (step < 0 || step >= node->childCount()) { return {nullptr, -1}; } SplitNode* next = node->childAt(step); node = next->asContainer(); if (node == nullptr) { return {nullptr, -1}; } } return {node, leaf}; }; const auto [paneParent, paneSlot] = resolve(srcTree, loc.value().path); if (paneParent == nullptr || paneSlot < 0 || paneSlot >= paneParent->childCount() || paneParent->childAt(paneSlot)->asPane() == nullptr) { return; } PaneNode* dragged = paneParent->childAt(paneSlot)->asPane(); (void)paneParent->takeChild(paneSlot); QVector<int> removedPath = loc.value().path; removedPath.takeLast(); renormalize(*paneParent); collapseSingletons(srcTree, removedPath);
// Append to the destination tree's root container: last child, an // even 1/(n+1) share, renormalized. const int childCount = dstTree->childCount(); dstTree->appendChild(dragged, 1.0 / static_cast<qreal>(childCount + 1)); renormalize(*dstTree);
// The record rehomes BEFORE an emptied-source closeTab so // removeTabPaneRecords (an empty tree) leaves the moved pane alone. rehomeRecord(paneId, dstWorkspaceId, dstTabId); const bool srcEmptied = srcTree->childCount() == 0 || paneIdsInLayoutOrder(*srcTree).isEmpty(); if (srcEmptied) { closeTab(srcWsIndex, srcTabIndex); } else { auto* srcTab = m_workspaces.at(srcWsIndex)->tabs().at(srcTabIndex); if (srcTab->lastActivePaneId() == paneId) { const QStringList remaining = paneIdsInLayoutOrder(*srcTree); srcTab->setLastActivePaneId(remaining.isEmpty() ? QString() : remaining.constFirst()); emit tabDataChanged(srcWsIndex, srcTabIndex); syncTabTitle(srcWsIndex, srcTabIndex); } }
// Reveal the drop. The index must know the dragged pane's NEW // location first, or focusPane no-ops; it also re-derives the // destination indices (closing an emptied source tab in the SAME // workspace shifts every later tab's index). rebuildAllPathIndexes(); const auto dstLoc = m_paths.constFind(paneId); if (dstLoc == m_paths.constEnd()) { return; } const int finalDstWs = workspaceIndexOf(dstLoc.value().workspaceId); const int finalDstTab = tabIndexOf(dstLoc.value().workspaceId, dstLoc.value().tabId); if (finalDstWs < 0 || finalDstTab < 0) { return; } selectTab(finalDstWs, finalDstTab); focusPane(paneId); if (!srcEmptied) { clearZoomReferencing(srcWsIndex, srcTabIndex, {paneId}); m_workspaces.at(srcWsIndex)->tabs().at(srcTabIndex) ->notifyStructureChanged(); emit structureChanged(srcWsIndex, srcTabIndex); } dstTab->notifyStructureChanged(); emit structureChanged(finalDstWs, finalDstTab); saveSessionSoon();}
void AppModel::movePaneToNewTab(const QString& paneId, const int wsIndex){ const auto loc = m_paths.constFind(paneId); if (loc == m_paths.constEnd()) { return; } if (wsIndex < 0 || wsIndex >= m_workspaces.size()) { return; } Workspace* dstWorkspace = m_workspaces.at(wsIndex); const int srcWsIndex = workspaceIndexOf(loc.value().workspaceId); const int srcTabIndex = tabIndexOf(loc.value().workspaceId, loc.value().tabId); if (srcWsIndex < 0 || srcTabIndex < 0) { return; } SplitContainerNode* srcTree = tabTree(srcWsIndex, srcTabIndex); if (srcTree == nullptr) { return; } const bool sameWorkspace = srcWsIndex == wsIndex; // Dragging the last pane of the source workspace's only tab into a // DIFFERENT workspace would silently delete the source workspace. // Same-workspace drops are always allowed: the new tab exists before // the old one closes. if (!sameWorkspace && m_workspaces.at(srcWsIndex)->tabs().size() <= 1 && paneIdsInLayoutOrder(*srcTree).size() <= 1) { return; }
// Create the new tab FIRST so the source workspace never dips below // one tab during the move. auto* tab = new XenoTab(newUuid(), dstWorkspace); tab->setActive(false); auto* root = new SplitContainerNode(SplitAxis::Horizontal, newUuid(), nullptr);
// Locate + take the dragged pane's node (same walk as movePaneBeside). auto resolve = [](SplitContainerNode* tree, const QVector<int>& path) -> std::pair<SplitContainerNode*, int> { QVector<int> walk = path; if (walk.isEmpty()) { return {nullptr, -1}; } const int leaf = walk.takeLast(); SplitContainerNode* node = tree; for (const int step : walk) { if (step < 0 || step >= node->childCount()) { return {nullptr, -1}; } SplitNode* next = node->childAt(step); node = next->asContainer(); if (node == nullptr) { return {nullptr, -1}; } } return {node, leaf}; }; const auto [paneParent, paneSlot] = resolve(srcTree, loc.value().path); if (paneParent == nullptr || paneSlot < 0 || paneSlot >= paneParent->childCount() || paneParent->childAt(paneSlot)->asPane() == nullptr) { delete tab; return; } PaneNode* dragged = paneParent->childAt(paneSlot)->asPane(); (void)paneParent->takeChild(paneSlot); QVector<int> removedPath = loc.value().path; removedPath.takeLast(); renormalize(*paneParent); collapseSingletons(srcTree, removedPath);
// Safely parented: finish the new tab, then handle the source. root->appendChild(dragged, 1.0); renormalize(*root); tab->setTreeRoot(root); const int newTabIndex = static_cast<int>(dstWorkspace->tabs().size()); dstWorkspace->insertTab(newTabIndex, tab); tab->setLastActivePaneId(paneId); const QString dstWorkspaceId = dstWorkspace->id(); const QString dstTabId = tab->id();
// The record rehomes BEFORE an emptied-source closeTab. rehomeRecord(paneId, dstWorkspaceId, dstTabId); const bool srcEmptied = srcTree->childCount() == 0 || paneIdsInLayoutOrder(*srcTree).isEmpty(); if (srcEmptied) { closeTab(srcWsIndex, srcTabIndex); } else { auto* srcTab = m_workspaces.at(srcWsIndex)->tabs().at(srcTabIndex); if (srcTab->lastActivePaneId() == paneId) { const QStringList remaining = paneIdsInLayoutOrder(*srcTree); srcTab->setLastActivePaneId(remaining.isEmpty() ? QString() : remaining.constFirst()); emit tabDataChanged(srcWsIndex, srcTabIndex); syncTabTitle(srcWsIndex, srcTabIndex); } }
// Reveal the drop in the brand-new tab. The index must know the // dragged pane's NEW location first, or focusPane no-ops; it also // re-derives the destination tab index (closing an emptied source tab // in the SAME workspace shifts every later tab's index, including the // new tab's). rebuildAllPathIndexes(); const auto dstLoc = m_paths.constFind(paneId); if (dstLoc == m_paths.constEnd()) { return; } const int finalDstWs = workspaceIndexOf(dstLoc.value().workspaceId); const int finalDstTab = tabIndexOf(dstLoc.value().workspaceId, dstLoc.value().tabId); if (finalDstWs < 0 || finalDstTab < 0) { return; } selectTab(finalDstWs, finalDstTab); focusPane(paneId); if (!srcEmptied) { clearZoomReferencing(srcWsIndex, srcTabIndex, {paneId}); m_workspaces.at(srcWsIndex)->tabs().at(srcTabIndex) ->notifyStructureChanged(); emit structureChanged(srcWsIndex, srcTabIndex); } tab->notifyStructureChanged(); emit structureChanged(finalDstWs, finalDstTab); saveSessionSoon();}
void AppModel::rehomeRecord(const QString& paneId, const QString& workspaceId, const QString& tabId){ auto it = m_panes.find(paneId); if (it == m_panes.end()) { return; } it.value().workspaceId = workspaceId; it.value().tabId = tabId;}
// ---------------------------------------------------------------------------// Remaining tree mutators: focus, reveal, ratios, reset. Pool notes.// ---------------------------------------------------------------------------
void AppModel::focusPane(const QString& paneId){ if (!m_paths.contains(paneId)) { return; } // A zoomed tab shows one pane alone, and that pane is the only pane it // can focus: every request that would land on a hidden sibling is // redirected to the zoomed pane instead. Keyboard movement while zoomed // is therefore a no-op, and the indirect paths (tab restore, jump to a // pane, a dropped pane) land on the visible pane rather than typing // into a terminal the user cannot see. const NodeLocation location = m_paths.value(paneId); const int wsIndex = workspaceIndexOf(location.workspaceId); const int tabIndex = tabIndexOf(location.workspaceId, location.tabId); QString target = paneId; if (wsIndex >= 0 && tabIndex >= 0) { const XenoTab* tab = m_workspaces.at(wsIndex)->tabs().at(tabIndex); if (tab != nullptr) { target = zoomPinInTab(*tab, paneId); } } const NodeLocation targetLocation = m_paths.value(target); m_activePath.workspaceId = targetLocation.workspaceId; m_activePath.tabId = targetLocation.tabId; if (m_activePath.paneId == target) { return; } m_activePath.paneId = target; // Record the per-tab focus memory (drives tab titles + // restore-on-select). if (wsIndex >= 0 && tabIndex >= 0) { m_workspaces.at(wsIndex)->tabs().at(tabIndex)->setLastActivePaneId( target); emit tabDataChanged(wsIndex, tabIndex); // The tab title follows the remembered pane's live title. syncTabTitle(wsIndex, tabIndex); } emit focusedPaneChanged(target); saveSessionSoon();}
void AppModel::focusDirection(const QString& paneId, SplitDirection dir){ // Spatial focus: walk the path index to the pane's container, then pick // the sibling on the requested side (Left/Right on a Horizontal axis, // Up/Down on a Vertical one). Crossing a container boundary walks up. const auto location = m_paths.constFind(paneId); if (location == m_paths.constEnd()) { return; } SplitContainerNode* tree = tabTree(workspaceIndexOf(location.value().workspaceId), tabIndexOf(location.value().workspaceId, location.value().tabId)); if (tree == nullptr) { return; } QVector<int> path = location.value().path; const SplitAxis wantAxis = (dir == SplitDirection::Up || dir == SplitDirection::Down) ? SplitAxis::Vertical : SplitAxis::Horizontal; const bool back = dir == SplitDirection::Left || dir == SplitDirection::Up; QVector<int> walk = path; while (!walk.isEmpty()) { const int idx = walk.takeLast(); SplitContainerNode* container = [&]() -> SplitContainerNode* { SplitContainerNode* node = tree; for (const int step : walk) { if (step < 0 || step >= node->childCount()) { return nullptr; } SplitNode* next = node->childAt(step); node = next->asContainer(); if (node == nullptr) { return nullptr; } } return node; }(); if (container == nullptr || container->axis() != static_cast<int>(wantAxis)) { continue; } const int sibling = back ? idx - 1 : idx + 1; if (sibling < 0 || sibling >= container->childCount()) { continue; } const QStringList ids = paneIdsInLayoutOrder(*container->childAt(sibling)); if (!ids.isEmpty()) { focusPane(back ? ids.constLast() : ids.constFirst()); } return; }}
void AppModel::focusDirectionFromString(const QString& paneId, const QString& direction){ focusDirection(paneId, directionFromString(direction));}
void AppModel::focusNextPane(){ const int wsCount = static_cast<int>(m_workspaces.size()); if (m_activeWorkspaceIndex < 0 || m_activeWorkspaceIndex >= wsCount) { return; } const QList<XenoTab*>& tabs = m_workspaces.at(m_activeWorkspaceIndex)->tabs(); int activeTab = 0; for (auto&& [t, tab] : std::views::zip(std::views::iota(0), tabs)) { if (tab->active()) { activeTab = static_cast<int>(t); break; } } SplitContainerNode* tree = tabTree(m_activeWorkspaceIndex, activeTab); if (tree == nullptr) { return; } const QStringList ids = paneIdsInLayoutOrder(*tree); const int count = static_cast<int>(ids.size()); if (count == 0) { return; } const int current = static_cast<int>(ids.indexOf(m_activePath.paneId)); const int start = (current < 0) ? 0 : ((current + 1) % count); focusPane(ids.at(start));}
void AppModel::focusPreviousPane(){ const int wsCount = static_cast<int>(m_workspaces.size()); if (m_activeWorkspaceIndex < 0 || m_activeWorkspaceIndex >= wsCount) { return; } const QList<XenoTab*>& tabs = m_workspaces.at(m_activeWorkspaceIndex)->tabs(); int activeTab = 0; for (auto&& [t, tab] : std::views::zip(std::views::iota(0), tabs)) { if (tab->active()) { activeTab = static_cast<int>(t); break; } } SplitContainerNode* tree = tabTree(m_activeWorkspaceIndex, activeTab); if (tree == nullptr) { return; } const QStringList ids = paneIdsInLayoutOrder(*tree); const int count = static_cast<int>(ids.size()); if (count == 0) { return; } const int current = static_cast<int>(ids.indexOf(m_activePath.paneId)); const int start = (current < 0) ? 0 : ((current - 1 + count) % count); focusPane(ids.at(start));}
void AppModel::revealPane(const QString& paneId){ const auto location = m_paths.constFind(paneId); if (location == m_paths.constEnd()) { return; } const int wsIndex = workspaceIndexOf(location.value().workspaceId); if (wsIndex != m_activeWorkspaceIndex) { selectWorkspace(wsIndex); } const int tabIndex = tabIndexOf(location.value().workspaceId, location.value().tabId); if (tabIndex >= 0) { selectTab(wsIndex, tabIndex); } focusPane(paneId);}
void AppModel::setRatios(const QString& containerId, const QVariantList& ratios){ const auto location = m_paths.constFind(containerId); if (location == m_paths.constEnd()) { return; } SplitContainerNode* tree = tabTree(workspaceIndexOf(location.value().workspaceId), tabIndexOf(location.value().workspaceId, location.value().tabId)); if (tree == nullptr) { return; } SplitContainerNode* container = findContainer(*tree, containerId); if (container == nullptr || std::cmp_not_equal(ratios.size(), container->childCount())) { return; } QList<qreal> next; next.reserve(ratios.size()); for (const QVariant& value : ratios) { // QML pushes null for unmeasured siblings; -1 keeps them unmeasured // rather than clamping to 0. next.append(value.isValid() && !value.isNull() ? std::clamp(value.toDouble(), 0.0, 1.0) : -1.0); } container->setRatios(next); renormalize(*container); // The container's ratiosChanged NOTIFY (fired by setRatios/renormalize) // re-fires the QML geometry bindings in place — no tree rebuild, no // wrapper churn. saveSessionSoon();}
void AppModel::equalizeSplits(){ if (m_workspaces.isEmpty()) { return; } const QList<XenoTab*>& tabs = m_workspaces.at(m_activeWorkspaceIndex)->tabs(); int activeTab = 0; for (auto&& [t, tab] : std::views::zip(std::views::iota(0), tabs)) { if (tab->active()) { activeTab = static_cast<int>(t); break; } } SplitContainerNode* tree = tabTree(m_activeWorkspaceIndex, activeTab); if (tree == nullptr) { return; } // No structural change, so no path-index or coarse-signal churn: each // container's ratiosChanged NOTIFY re-fires the QML geometry bindings in // place (the same mechanism setRatios relies on). ::equalizeSplits(*tree); saveSessionSoon();}
void AppModel::resetLayout(){ // Collapse the active workspace to one tab with one empty pane // (Shell.resetLayout: the old tab list and split tree are dropped). if (m_workspaces.isEmpty()) { return; } Workspace* workspace = m_workspaces.at(m_activeWorkspaceIndex); QStringList resetDoomed; for (const XenoTab* tab : workspace->tabs()) { if (tab->treeRoot() == nullptr) { continue; } const QStringList ids = paneIdsInLayoutOrder(*tab->treeRoot()); for (const QString& pane : ids) { m_panes.remove(pane); } resetDoomed.append(ids); } destroyPoolPanes(resetDoomed); const QList<XenoTab*> oldTabs = workspace->tabs(); workspace->setTabs({}); for (XenoTab* tab : oldTabs) { tab->deleteLater(); } auto* tab = new XenoTab(newUuid(), workspace); tab->setActive(true); tab->setTreeRoot(emptyLeafTree(workspace->path())); workspace->insertTab(0, tab); // Fresh pane record for the new pane. const QString paneId = paneIdsInLayoutOrder(*tab->treeRoot()).constFirst(); PaneRecord record; record.paneId = paneId; record.workspaceId = workspace->id(); record.tabId = tab->id(); record.spawnCwd = workspace->path(); m_panes.insert(paneId, record); m_activePath.tabId = tab->id(); m_activePath.paneId = paneId; rebuildAllPathIndexes(); emit workspacesChanged(); emit tabsChanged(m_activeWorkspaceIndex); emit structureChanged(m_activeWorkspaceIndex, 0); emit activeTabChanged(m_activeWorkspaceIndex, 0); emit focusedPaneChanged(paneId); saveSessionSoon();}
// ---------------------------------------------------------------------------// Pane zoom.// ---------------------------------------------------------------------------
// Zoom is a pure RENDER gate on the owning tab: the split tree, its// ratios, the ActivePath, and the pane pool are never touched, so unzoom// is guaranteed to restore the exact previous layout by construction.// The pane id must be a leaf of the ACTIVE tab's tree (the renderer only// shows the active tab, and a single-pane tab zooms to itself — a// harmless identity zoom the renderer treats like unzoomed layout).
void AppModel::togglePaneZoom(){ const QString focused = m_activePath.paneId; if (focused.isEmpty() || !m_paths.contains(focused)) { return; } const NodeLocation location = m_paths.value(focused); if (location.workspaceId != m_activePath.workspaceId || location.tabId != m_activePath.tabId) { return; } Workspace* workspace = m_workspaces.at(m_activeWorkspaceIndex); if (workspace == nullptr || workspace->tabs().isEmpty()) { return; } XenoTab* tab = workspace->tabs().at(workspace->activeTabIndex()); if (tab == nullptr) { return; } const bool wasZoomed = tab->zoomedPaneId() == focused; tab->setZoomedPaneId(wasZoomed ? QString() : focused); // Losing the zoom ends the maximize that rode it, exactly as // clearPaneZoom does: a maximize is a zoom plus collapsed chrome, so // it must not outlive its zoom (a zoom toggle from a maximized pane leaves // the user with every pane back, not with chrome stuck hidden). if (wasZoomed && m_chromeMaximized) { unmaximizeChrome(); }}
void AppModel::maximizePane(){ const QString focused = m_activePath.paneId; if (focused.isEmpty() || !m_paths.contains(focused)) { return; } const NodeLocation location = m_paths.value(focused); if (location.workspaceId != m_activePath.workspaceId || location.tabId != m_activePath.tabId) { return; } Workspace* workspace = m_workspaces.at(m_activeWorkspaceIndex); if (workspace == nullptr || workspace->tabs().isEmpty()) { return; } XenoTab* tab = workspace->tabs().at(workspace->activeTabIndex()); if (tab == nullptr) { return; } // A maximize is a zoom with focus semantics: if this pane is already // the zoom, maximize means un-maximize (same toggle); otherwise zoom // this pane and re-assert the ActivePath so the keyboard follows. const bool alreadyZoomed = tab->zoomedPaneId() == focused; tab->setZoomedPaneId(alreadyZoomed ? QString() : focused); if (!alreadyZoomed && m_activePath.paneId != focused) { focusPane(focused); }
// Chrome maximize rides the SAME toggle: entering collapses the // sidebar and the tab strip; leaving restores ONLY what we changed. if (alreadyZoomed) { if (m_chromeMaximized) { unmaximizeChrome(); } return; } if (!m_chromeMaximized) { maximizeChrome(); }}
// Collapse the window chrome (sidebar + tab strip) around the zoomed// pane, remembering only what was actually visible beforehand. A sidebar// the user had already collapsed stays collapsed after restore; the// flags below are "we hid this" markers, not blanket snapshots.void AppModel::maximizeChrome(){ XenoSettings* settings = m_settings; if (settings == nullptr) { return; } m_restoreSidebar = !settings->sidebarCollapsed(); if (m_restoreSidebar) { settings->setSidebarCollapsed(true); } m_restoreTabStrip = !m_tabStripHidden; m_tabStripHidden = true; m_chromeMaximized = true; emit chromeMaximizedChanged();}
// Undo exactly the pieces maximizeChrome changed. Never touches chrome// the user hid themselves before maximizing.void AppModel::unmaximizeChrome(){ XenoSettings* settings = m_settings; if (settings == nullptr) { m_chromeMaximized = false; return; } if (m_restoreSidebar && settings->sidebarCollapsed()) { settings->setSidebarCollapsed(false); } if (m_restoreTabStrip) { m_tabStripHidden = false; } m_restoreSidebar = false; m_restoreTabStrip = false; m_chromeMaximized = false; emit chromeMaximizedChanged();}
void AppModel::clearPaneZoom(){ Workspace* workspace = m_workspaces.isEmpty() ? nullptr : m_workspaces.at(m_activeWorkspaceIndex); if (workspace == nullptr || workspace->tabs().isEmpty()) { return; } XenoTab* tab = workspace->tabs().at(workspace->activeTabIndex()); if (tab != nullptr) { tab->setZoomedPaneId(QString()); } // Losing the zoom ends a maximize: restore the chrome we collapsed. if (m_chromeMaximized) { unmaximizeChrome(); }}
QString AppModel::activeZoomedPaneId() const{ if (m_workspaces.isEmpty() || m_activeWorkspaceIndex >= m_workspaces.size()) { return {}; } const Workspace* workspace = m_workspaces.at(m_activeWorkspaceIndex); if (workspace == nullptr || workspace->tabs().isEmpty()) { return {}; } const XenoTab* tab = workspace->tabs().at(workspace->activeTabIndex()); return tab != nullptr ? tab->zoomedPaneId() : QString{};}
// The pane a focus request must land on while its tab is zoomed: the// zoomed pane. The zoom is only ever set on the focused pane, so this is// a no-op for the focus the zoom itself created; it corrects every other// path into a hidden pane (focus movement, tab restore, a drop).QString AppModel::zoomPinInTab(const XenoTab& tab, const QString& paneId){ const QString& zoom = tab.zoomedPaneId(); if (zoom.isEmpty() || zoom == paneId || !tab.paneIdList().contains(zoom)) { return paneId; } return zoom;}
// Hygiene: a zoomed pane that leaves its tab (close, move) must not leave// a dangling id. Called after structural mutations of (wsIndex, tabIndex)// with the pane ids that vanished; also clears when the zoomed id is no// longer a leaf of that tab's tree (covers tree collapses).void AppModel::clearZoomReferencing(int wsIndex, int tabIndex, const QStringList& removedPaneIds){ if (wsIndex < 0 || tabIndex < 0 || wsIndex >= m_workspaces.size() || tabIndex >= m_workspaces.at(wsIndex)->tabs().size()) { return; } XenoTab* tab = m_workspaces.at(wsIndex)->tabs().at(tabIndex); if (tab == nullptr || tab->zoomedPaneId().isEmpty()) { return; } const bool removed = std::ranges::any_of( removedPaneIds, [&tab](const QString& id) { return id == tab->zoomedPaneId(); }); if (removed || !tab->paneIdList().contains(tab->zoomedPaneId())) { tab->setZoomedPaneId(QString()); }}
// ---------------------------------------------------------------------------// Pool notes + reads for QML.// ---------------------------------------------------------------------------
void AppModel::syncTabTitle(int wsIndex, int tabIndex){ if (wsIndex < 0 || tabIndex < 0 || wsIndex >= m_workspaces.size() || tabIndex >= m_workspaces.at(wsIndex)->tabs().size()) { return; } // Same composition as tabDisplayTitle, minus the numbered fallback // (QML owns "Terminal %1"). m_workspaces.at(wsIndex)->tabs().at(tabIndex)->setTitle( tabDisplayTitle(wsIndex, tabIndex));}
void AppModel::notePaneTitle(const QString& paneId, const QString& title){ auto it = m_panes.find(paneId); if (it == m_panes.end()) { return; } if (it.value().title == title) { return; } it.value().title = title; // QML reads paneTitle(paneId) and tab titles via tab.title. // tabDataChanged re-emits for coarse listeners; syncTabTitle drives the // reactive binding. const NodeLocation location = m_paths.value(paneId); const int wsIndex = workspaceIndexOf(location.workspaceId); const int tabIndex = tabIndexOf(location.workspaceId, location.tabId); if (wsIndex >= 0 && tabIndex >= 0) { emit tabDataChanged(wsIndex, tabIndex); syncTabTitle(wsIndex, tabIndex); } saveSessionSoon();}
void AppModel::notePaneCwd(const QString& paneId, const QString& cwd){ const auto it = m_panes.find(paneId); if (it == m_panes.end()) { return; } it.value().cwd = cwd; saveSessionSoon();}
// Record the quit-time restart scan result for one pane. No-op for an// unknown paneId. Deliberately does NOT saveSessionSoon(): the quit path// calls saveSessionForQuit() right after the scan, and a debounce timer// firing during quit would be pointless.void AppModel::setPaneRestart(const QString& paneId, bool restart, const QStringList& argv){ const auto it = m_panes.find(paneId); if (it == m_panes.end()) { return; } it.value().restart = restart; it.value().restartArgv = argv;}
const Workspace* AppModel::paneWorkspace(const QString& paneId) const{ const PaneRecord record = m_panes.value(paneId); if (record.workspaceId.isEmpty()) { return nullptr; } for (const Workspace* workspace : m_workspaces) { if (workspace != nullptr && workspace->id() == record.workspaceId) { return workspace; } } return nullptr;}
QString AppModel::paneListingRoot(const QString& paneId) const{ const PaneRecord record = m_panes.value(paneId); if (!record.cwd.isEmpty()) { return record.cwd; } if (!record.spawnCwd.isEmpty()) { return record.spawnCwd; } const Workspace* workspace = paneWorkspace(paneId); if (workspace != nullptr && !workspace->path().isEmpty()) { return workspace->path(); } return QDir::homePath();}
QString AppModel::paneRepoBackend(const QString& paneId) const{ const Workspace* workspace = paneWorkspace(paneId); if (workspace == nullptr) { return {}; } switch (workspace->kind()) { case WorkspaceKind::JjWorkspace: return QStringLiteral("jj"); case WorkspaceKind::Repository: case WorkspaceKind::GitWorktree: return QStringLiteral("git"); case WorkspaceKind::Directory: break; } return {};}
QString AppModel::paneWorkspacePath(const QString& paneId) const{ const Workspace* workspace = paneWorkspace(paneId); if (workspace == nullptr) { return {}; } return workspace->path();}
QVariantMap AppModel::paneSpawnSpec(const QString& paneId) const{ const PaneRecord record = m_panes.value(paneId); QVariantMap spec; spec.insert(QStringLiteral("cwd"), record.spawnCwd); spec.insert(QStringLiteral("command"), record.spawnCommand); spec.insert(QStringLiteral("initialCommands"), record.spawnInitialCommands); spec.insert(QStringLiteral("title"), record.spawnTitle); spec.insert(QStringLiteral("restart"), record.restart); spec.insert(QStringLiteral("restartArgv"), QVariant(record.restartArgv)); return spec;}
QString AppModel::paneKindOf(const QString& paneId) const{ const NodeLocation location = m_paths.value(paneId); if (location.tabId.isEmpty()) { return {}; } const SplitContainerNode* tree = tabTree(workspaceIndexOf(location.workspaceId), tabIndexOf(location.workspaceId, location.tabId)); if (tree == nullptr) { return {}; } QString word; forEachPane(*tree, [&](const PaneNode& node) { if (word.isEmpty() && node.paneId() == paneId) { word = node.kindWord(); } }); return word;}
QString AppModel::editorPaneShowingPath(const QString& paneId, const QString& path) const{ if (path.isEmpty()) { return {}; } const NodeLocation location = m_paths.value(paneId); if (location.tabId.isEmpty()) { return {}; } const SplitContainerNode* tree = tabTree(workspaceIndexOf(location.workspaceId), tabIndexOf(location.workspaceId, location.tabId)); if (tree == nullptr) { return {}; } QString found; forEachPane(*tree, [&](const PaneNode& node) { if (!found.isEmpty() || node.paneKind() != PaneKind::Editor) { return; } if (m_panes.value(node.paneId()).editorPath == path) { found = node.paneId(); } }); return found;}
bool AppModel::paneDirty(const QString& paneId) const{ return m_panes.value(paneId).editorDirty;}
void AppModel::notePaneDirty(const QString& paneId, bool dirty){ auto it = m_panes.find(paneId); if (it == m_panes.end() || it.value().editorDirty == dirty) { return; } it.value().editorDirty = dirty;}
bool AppModel::panePickerFilesOnly(const QString& paneId) const{ return m_panes.value(paneId).pickerFilesOnly;}
QStringList AppModel::dirtyEditorPanesInTab(int wsIndex, int tabIndex) const{ const SplitContainerNode* tree = tabTree(wsIndex, tabIndex); if (tree == nullptr) { return {}; } QStringList out; forEachPane(*tree, [&](const PaneNode& node) { if (m_panes.value(node.paneId()).editorDirty) { out.append(node.paneId()); } }); return out;}
QStringList AppModel::dirtyEditorPanes() const{ QStringList out; for (auto it = m_panes.constBegin(); it != m_panes.constEnd(); ++it) { if (it.value().editorDirty) { out.append(it.key()); } } return out;}
QStringList AppModel::activePaneIds() const{ const Workspace* ws = m_workspaces.isEmpty() ? nullptr : m_workspaces.at(m_activeWorkspaceIndex); if (ws == nullptr) { return {}; } const QList<XenoTab*> tabs = ws->tabs(); int activeTab = 0; for (auto&& [t, tab] : std::views::zip(std::views::iota(0), tabs)) { if (tab->active()) { activeTab = static_cast<int>(t); break; } } SplitContainerNode* tree = tabTree(m_activeWorkspaceIndex, activeTab); return tree != nullptr ? paneIdsInLayoutOrder(*tree) : QStringList();}
QStringList AppModel::streamingPaneIds() const{ QStringList ids; for (auto&& [w, ws] : std::views::zip(std::views::iota(0), m_workspaces)) { if (ws == nullptr) { continue; } const QList<XenoTab*> tabs = ws->tabs(); int activeTab = 0; for (auto&& [t, tab] : std::views::zip(std::views::iota(0), tabs)) { if (tab->active()) { activeTab = static_cast<int>(t); break; } } SplitContainerNode* tree = tabTree(static_cast<int>(w), activeTab); if (tree != nullptr) { ids.append(paneIdsInLayoutOrder(*tree)); } } return ids;}
QVariantMap AppModel::tabLayout(int wsIndex, int tabIndex){ QVariantMap out; SplitContainerNode* tree = tabTree(wsIndex, tabIndex); if (tree == nullptr) { return out; } // {axis, children:[{leaf:true,paneId,ratio,title}|{axis,children}]} std::function<QVariant(const SplitNode&)> describe = [&](const SplitNode& node) -> QVariant { if (const auto* pane = node.asPane()) { QVariantMap leafMap; leafMap.insert("leaf", true); leafMap.insert("paneId", pane->paneId()); leafMap.insert("title", m_panes.value(pane->paneId()).title); return leafMap; } const auto& container = *node.asContainer(); QVariantMap map; map.insert("axis", container.axis()); QVariantList children; for (auto&& [ratio, childNode] : std::views::zip(container.ratios(), container)) { QVariantMap child; child.insert("ratio", ratio); child.insert("node", describe(*childNode)); children.append(child); } map.insert("children", children); return map; }; QVariantMap rootMap; QVariantList children; for (auto&& [ratio, childNode] : std::views::zip(tree->ratios(), *tree)) { QVariantMap child; child.insert("ratio", ratio); child.insert("node", describe(*childNode)); children.append(child); } rootMap.insert("children", children); return rootMap;}
QString AppModel::paneTitle(const QString& paneId) const{ const auto it = m_panes.constFind(paneId); if (it == m_panes.constEnd()) { return {}; } // Live OSC title wins; else the cwd basename; else the spawn title. if (!it.value().title.isEmpty()) { return it.value().title; } const QString cwd = it.value().cwd; if (!cwd.isEmpty()) { const auto slash = cwd.lastIndexOf(QLatin1Char('/')); return cwd.mid(slash + 1); } return it.value().spawnTitle;}
QVariantList AppModel::jumpCandidates() const{ QVariantList out; for (const Workspace* workspace : m_workspaces) { const QString wsName = workspace->name(); if (!wsName.isEmpty()) { QVariantMap wsCandidate; wsCandidate.insert("kind", "workspace"); wsCandidate.insert("workspaceId", workspace->id()); wsCandidate.insert("title", wsName); wsCandidate.insert("subtitle", QStringLiteral("Workspace")); out.append(wsCandidate); } int tabIndex = 0; for (const XenoTab* tab : workspace->tabs()) { const QString tabTitle = tab->lastActivePaneId().isEmpty() ? QStringLiteral("Terminal %1").arg(tabIndex + 1) : paneTitle(tab->lastActivePaneId()); QVariantMap tabMap; tabMap.insert("kind", "tab"); tabMap.insert("title", QStringLiteral("tab: %1").arg(tabTitle)); tabMap.insert("workspaceId", workspace->id()); tabMap.insert("tabId", tab->id()); out.append(tabMap); if (tab->treeRoot() == nullptr) { continue; } const QStringList ids = paneIdsInLayoutOrder(*tab->treeRoot()); for (const QString& pane : ids) { const QString title = paneTitle(pane); if (title.isEmpty()) { continue; } QVariantMap paneMap; paneMap.insert("kind", "pane"); paneMap.insert("title", title); paneMap.insert("workspaceId", workspace->id()); paneMap.insert("tabId", tab->id()); paneMap.insert("paneId", pane); out.append(paneMap); } ++tabIndex; } } return out;}
QString AppModel::tabDisplayTitle(int wsIndex, int tabIndex) const{ // Live title of the tab's remembered pane, else "" (the numbered // fallback is the UI's job: tabDisplayTitle wraps this with // tr("Terminal %1")). SplitContainerNode* tree = tabTree(wsIndex, tabIndex); if (tree == nullptr) { return {}; } const XenoTab& tab = *m_workspaces.at(wsIndex)->tabs().at(tabIndex); QString paneId = tab.lastActivePaneId(); if (paneId.isEmpty()) { const QStringList ids = paneIdsInLayoutOrder(*tree); paneId = ids.isEmpty() ? QString() : ids.constFirst(); } return paneTitle(paneId);}
QVariantMap AppModel::buildSessionRoot(bool scanQuit) const{ // The quit-time restart scan must run BEFORE the records are serialized // so the freshly recorded restart flags land in the JSON. QMetaObject // dispatch on the QPointer<QObject> keeps models/ compile-time // Quick-free (same technique as destroyPoolPanes). With no pool attached // (tests, or a quit before pool wiring) this is a safe no-op: the rest // of the session is still built and saved. // // ONLY the quit path passes scanQuit=true. The 500 ms debounce // (saveSessionNow) passes false: running a `ps` subprocess every 500 ms // would be a serious regression. if (scanQuit && m_pool != nullptr) { const bool ok = QMetaObject::invokeMethod(m_pool, "runRestartScan"); Q_UNUSED(ok); } QJsonArray workspacesJson; QString activeWorkspaceName; for (const Workspace* workspace : m_workspaces) { const QString workspacePath = workspace->path(); QJsonObject wsJson; // Additive id field; every other key matches the pre-refactor schema. wsJson.insert(QStringLiteral("id"), workspace->id()); wsJson.insert(QStringLiteral("name"), workspace->name()); wsJson.insert(QStringLiteral("kind"), workspaceKindToString(workspace->kind())); wsJson.insert(QStringLiteral("path"), workspace->path()); wsJson.insert(QStringLiteral("repoRoot"), workspace->repoRoot()); wsJson.insert(QStringLiteral("jjWorkspaceName"), workspace->jjWorkspaceName()); wsJson.insert(QStringLiteral("isJjRepo"), workspace->isJjRepo()); wsJson.insert(QStringLiteral("isGitRepo"), workspace->isGitRepo()); int activeTab = workspace->activeTabIndex(); wsJson.insert(QStringLiteral("activeTab"), activeTab); QJsonArray tabsJson; for (const XenoTab* tab : workspace->tabs()) { QJsonObject tabJson; tabJson.insert(QStringLiteral("title"), QString()); tabJson.insert(QStringLiteral("active"), tab->active()); // Additive id field (old readers ignore it). tabJson.insert(QStringLiteral("id"), tab->id()); // tree: nodes with axis strings + splits (ratio|-1); leaves carry // cwd/command/title/restart/restartArgv from the pane records. std::function<QJsonObject(const SplitNode&)> emitTree = [&](const SplitNode& node) -> QJsonObject { if (const auto* pane = node.asPane()) { QJsonObject leafJson; leafJson.insert(QStringLiteral("type"), QStringLiteral("leaf")); const PaneRecord record = m_panes.value(pane->paneId()); // A chooser is unstarted work: it saves as a terminal, so a // restored session never shows a chooser nobody asked for. const PaneKind kind = pane->paneKind() == PaneKind::Picker ? PaneKind::Terminal : pane->paneKind(); leafJson.insert(QStringLiteral("paneKind"), paneKindToJson(kind)); if (kind == PaneKind::Editor && !record.editorPath.isEmpty()) { leafJson.insert(QStringLiteral("editorPath"), record.editorPath); } leafJson.insert(QStringLiteral("cwd"), record.cwd.isEmpty() ? record.spawnCwd : record.cwd); leafJson.insert(QStringLiteral("command"), record.spawnCommand); leafJson.insert( QStringLiteral("title"), record.title.isEmpty() ? record.spawnTitle : record.title); leafJson.insert(QStringLiteral("restart"), record.restart); QJsonArray argv; for (const QString& word : record.restartArgv) { argv.append(word); } leafJson.insert(QStringLiteral("restartArgv"), argv); leafJson.insert(QStringLiteral("cwdRel"), relativeCwd(workspacePath, record.cwd.isEmpty() ? record.spawnCwd : record.cwd)); // Additive id fields. leafJson.insert(QStringLiteral("paneId"), pane->paneId()); return leafJson; } const auto& container = *node.asContainer(); QJsonObject nodeJson; nodeJson.insert(QStringLiteral("type"), QStringLiteral("node")); nodeJson.insert(QStringLiteral("axis"), splitAxisToJson(static_cast<SplitAxis>( container.axis() == 0 ? SplitAxis::Horizontal : SplitAxis::Vertical))); QJsonArray children; QJsonArray splits; for (auto&& [ratio, childNode] : std::views::zip(container.ratios(), container)) { splits.append(ratio >= 0.0 ? QJsonValue(ratio) : QJsonValue(-1)); children.append(emitTree(*childNode)); } nodeJson.insert(QStringLiteral("children"), children); nodeJson.insert(QStringLiteral("splits"), splits); return nodeJson; }; tabJson.insert(QStringLiteral("tree"), emitTree(*tab->treeRoot())); tabJson.insert(QStringLiteral("lastPaneIndex"), lastPaneIndexOf(*tab)); tabJson.insert(QStringLiteral("lastActivePaneId"), tab->lastActivePaneId()); tabsJson.append(tabJson); } wsJson.insert(QStringLiteral("tabs"), tabsJson); workspacesJson.append(wsJson); } QJsonObject root; root.insert(QStringLiteral("activeWorkspace"), m_workspaces.isEmpty() ? QString() : m_workspaces.at(m_activeWorkspaceIndex)->name()); root.insert(QStringLiteral("workspaces"), workspacesJson); return root.toVariantMap();}
void AppModel::saveSessionSoon(){ if (m_saveTimer != nullptr) { m_saveTimer->start(); }}
void AppModel::saveSessionNow(){ // Debounce autosave: scanQuit=false. A `ps` subprocess every 500 ms // would be a serious regression; only the quit path (saveSessionForQuit) // runs the restart scan. if (!SessionStore::save(buildSessionRoot(false))) { qWarning("Xenomorphic: failed to save session"); }}
void AppModel::saveSessionForQuit(){ // Stop the debounce timer FIRST: the quit-time restart scan below spins // a nested event loop (DaemonLink::refreshPanes -> requestSync), and the // still-armed 500 ms single-shot would fire inside it — a RE-ENTRANT // SessionStore::save with scanQuit=false that can clobber the quit-time // file (losing the freshly recorded restart flags) or interleave two // writes to the same path. Stopping a timer in a save function looks // like noise: it is load-bearing. if (m_saveTimer != nullptr) { m_saveTimer->stop(); } // Quit never drops an edit: hand every dirty editor to the pool, which // owns the controllers. The pool runs on the GUI thread and writes // synchronously, so the saves land before teardown. if (m_pool != nullptr) { const QStringList dirty = dirtyEditorPanes(); if (!dirty.isEmpty()) { const bool ok = QMetaObject::invokeMethod( m_pool, "saveDirtyEditors", Q_ARG(QStringList, dirty)); Q_UNUSED(ok); } } // The one quit-time save: scanQuit=true so the restart scan runs against // the daemon's pane cache (which outlives the app's QML teardown) and // the freshly recorded restart flags land in the session file. if (!SessionStore::save(buildSessionRoot(true))) { qWarning("Xenomorphic: failed to save session at quit"); }}
QVariantMap AppModel::collectSession(bool scanQuit) const{ return buildSessionRoot(scanQuit);}
void AppModel::removeTabPaneRecords(int wsIndex, const XenoTab& tab){ Q_UNUSED(wsIndex); if (tab.treeRoot() == nullptr) { return; } const QStringList ids = paneIdsInLayoutOrder(*tab.treeRoot()); for (const QString& pane : ids) { m_panes.remove(pane); } destroyPoolPanes(ids);}
void AppModel::destroyPoolPanes(const QStringList& paneIds){ if (paneIds.isEmpty() || m_pool == nullptr) { return; } // QMetaObject dispatch keeps models/ compile-time Quick-free; the pool // kills the PTYs and emits paneDestroyed (bumping the QML poolRevision). // Per-pane destroyPane: it is Q_INVOKABLE, so the name+signature lookup // always succeeds. for (const QString& paneId : paneIds) { const bool ok = QMetaObject::invokeMethod( m_pool, "destroyPane", Q_ARG(QString, paneId)); Q_UNUSED(ok); }}
QString AppModel::firstPaneIdOfTab(int wsIndex, int tabIndex) const{ SplitContainerNode* tree = tabTree(wsIndex, tabIndex); if (tree == nullptr) { return {}; } const QStringList ids = paneIdsInLayoutOrder(*tree); return ids.isEmpty() ? QString() : ids.constFirst();}
void AppModel::attachPool(QObject* pool){ if (m_pool == pool) { return; } if (m_pool != nullptr) { disconnect(m_pool, nullptr, this, nullptr); } m_pool = pool; if (pool == nullptr) { return; } // SIGNAL/SLOT macros connect by signature, keeping models/ compile-time // Quick-free (no QQuick* headers). const bool titleOk = connect(pool, SIGNAL(paneTitleChanged(QString,QString)), this, SLOT(paneTitleFromPool(QString,QString))); const bool cwdOk = connect(pool, SIGNAL(paneCwdChanged(QString,QString)), this, SLOT(paneCwdFromPool(QString,QString))); Q_UNUSED(cwdOk); const bool closeOk = connect(pool, SIGNAL(paneCloseRequested(QString)), this, SLOT(paneCloseFromPool(QString))); Q_UNUSED(titleOk); Q_UNUSED(closeOk);}
void AppModel::paneTitleFromPool(const QString& paneId, const QString& title){ notePaneTitle(paneId, title);}
void AppModel::paneCwdFromPool(const QString& paneId, const QString& cwd){ const auto it = m_panes.find(paneId); if (it != m_panes.end()) { it.value().cwd = cwd; }}
void AppModel::paneCloseFromPool(const QString& paneId){ closePane(paneId); // the pool's close request routes through the model}