Something went wrong. Try again.
The agentic engineering control plane for the posthuman future
Something went wrong. Try again.
20 kB · 578 lines
C++
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578#include "WorkspaceController.h"
#include "AppModel.h"#include "ConfigPaths.h"#include "GitBridge.h"#include "JjBridge.h"#include "NameGenerator.h"#include "PathFuzzy.h"#include "Workspace.h"
#include <QHash>#include <QStringList>#include <QUrl>
#include <ranges>
namespace {
// Map the int kind QML passes to the registered WorkspaceKind enum (the// WorkspaceKind value: 0 Directory, 1 Repository, 2 JjWorkspace,// 3 GitWorktree). Out-of-range kinds degrade to Directory.WorkspaceKind kindFromInt(int kind) { switch (kind) { case 0: return WorkspaceKind::Directory; case 1: return WorkspaceKind::Repository; case 2: return WorkspaceKind::JjWorkspace; case 3: return WorkspaceKind::GitWorktree; default: return WorkspaceKind::Directory; }}
} // namespace
WorkspaceController::WorkspaceController(QObject* parent) : QObject(parent), m_jjBridge(new JjBridge(this)), m_gitBridge(new GitBridge(this)) { connect(m_jjBridge, &JjBridge::identified, this, [this](const QString& path, const QString& repoRoot, const QString& workspaceName) { onJjIdentified(path, repoRoot, workspaceName); }); connect(m_jjBridge, &JjBridge::workspacesListed, this, &WorkspaceController::onJjWorkspacesListed); connect(m_jjBridge, &JjBridge::workspaceAdded, this, &WorkspaceController::onWorkspaceAdded); connect(m_jjBridge, &JjBridge::workspaceRenamed, this, &WorkspaceController::onWorkspaceRenamed); connect(m_jjBridge, &JjBridge::workspaceForgotten, this, &WorkspaceController::onWorkspaceForgotten);
connect(m_gitBridge, &GitBridge::identified, this, [this](const QString& path, const QString& repoRoot, bool isWorktree) { onGitIdentified(path, repoRoot, isWorktree); }); connect(m_gitBridge, &GitBridge::worktreesListed, this, &WorkspaceController::onGitWorktreesListed); connect(m_gitBridge, &GitBridge::worktreeAdded, this, &WorkspaceController::onWorktreeAdded);}
WorkspaceController::~WorkspaceController() = default;
void WorkspaceController::detectWorkspaceKind(int index) { AppModel* model = AppModel::instance(); if (model == nullptr || index < 0 || index >= static_cast<int>(model->workspaces().size())) { return; } Workspace* ws = model->workspaces().at(index); if (ws == nullptr) { return; } QString path = ws->path(); if (path.isEmpty()) { // Legacy/inferred workspaces (the startup default, restored sessions) // can have an empty explicit path: their root lives only in the // panes' live cwds. Promote the panes' common root onto the // workspace so detection (and the sidebar's path row) have a real // directory to work with. const QString inferred = inferWorkspaceRoot(*ws); if (inferred.isEmpty()) { // Nothing to infer either: the workspace has no VCS. model->setWorkspaceRepoCapabilities(index, false, false); return; } model->setWorkspaceDirectory(index, inferred); path = inferred; } // Bookkeeping keyed by the echoed path so concurrent probes // (detectAllWorkspaces) stay distinguishable. Workspaces sharing a // path share one entry (and one probe pair): the results apply to // every tracked index. Stale responses whose path no longer matches // a tracked workspace are dropped per-index. PendingDetect& pending = m_pendingDetects[path]; if (!pending.indexes.contains(index)) { pending.indexes.append(index); } if (pending.jjPending || pending.gitPending) { // A probe pair for this path is already in flight or partially // settled (detectAll visiting a sibling with the same path); the // results will reach this index too. Don't double-probe. return; } pending.jjPending = true; pending.gitPending = true; pending.jjFound = false; m_jjBridge->identify(path); m_gitBridge->identify(path);}
QString WorkspaceController::inferWorkspaceRoot(const Workspace& ws) { // Collect the live cwd of every pane across all tabs: the pane // record's live cwd (OSC-7 notes from the pool), falling back to its // spawn cwd (session-restored panes before the shell reports). QStringList cwds; const AppModel* model = AppModel::instance(); const QList<XenoTab*>& tabs = ws.tabs(); for (const XenoTab* tab : tabs) { if (tab == nullptr) { continue; } const QStringList paneIds = tab->paneIdList(); for (const QString& paneId : paneIds) { const PaneRecord record = model->paneRecord(paneId); const QString cwd = !record.cwd.isEmpty() ? record.cwd : record.spawnCwd; if (!cwd.isEmpty()) { cwds.append(cwd); } } } if (cwds.isEmpty()) { return {}; } // Component-wise common prefix (mirrors the sidebar preview's QML // inference), skipping a degenerate "/" root. QList<QStringList> split; split.reserve(cwds.size()); for (const QString& cwd : cwds) { QString c = cwd; while (c.size() > 1 && c.endsWith(QLatin1Char('/'))) { c.chop(1); } split.append(c.split(QLatin1Char('/'), Qt::KeepEmptyParts)); } QStringList common = split.first(); for (const QStringList& other : split) { qsizetype k = 0; while (k < common.size() && k < other.size() && common.at(k) == other.at(k)) { ++k; } common.resize(k); } if (common.isEmpty()) { return {}; } const QString joined = common.join(QLatin1Char('/')); // Ensure a leading slash ("", "/private" style segments join without // one); skip the filesystem root itself. const QString root = joined.startsWith(QLatin1Char('/')) ? joined : QLatin1Char('/') + joined; return root == QStringLiteral("/") ? QString() : root;}
void WorkspaceController::detectAllWorkspaces() { AppModel* model = AppModel::instance(); if (model == nullptr) { return; } for (auto&& [index, ws] : std::views::zip(std::views::iota(0), model->workspaces())) { detectWorkspaceKind(index); }}
void WorkspaceController::onJjIdentified(const QString& path, const QString& repoRoot, const QString& workspaceName) { const auto it = m_pendingDetects.find(path); if (it == m_pendingDetects.end()) { return; } PendingDetect& pending = it.value(); pending.jjPending = false; AppModel* model = AppModel::instance(); if (model == nullptr) { return; } if (repoRoot.isEmpty()) { // No jj workspace here; the git probe decides the kind. maybeFinishDetect(path, pending); return; } pending.jjFound = true; const QList<Workspace*>& workspaces = model->workspaces(); // Apply to every workspace tracked under this path whose path still // matches (per-index staleness: a workspace moved on since the probe // was issued just drops out of the entry). QList<int> indexes = pending.indexes; for (const int index : indexes) { if (index < 0 || index >= static_cast<int>(workspaces.size()) || workspaces.at(index) == nullptr || workspaces.at(index)->path() != path) { pending.indexes.removeOne(index); continue; } model->reseatWorkspaceKind(index, WorkspaceKind::JjWorkspace, repoRoot, workspaceName); model->setWorkspaceRepoCapabilities(index, true, false); } maybeFinishDetect(path, pending);}
void WorkspaceController::onGitIdentified(const QString& path, const QString& repoRoot, bool isWorktree) { const auto it = m_pendingDetects.find(path); if (it == m_pendingDetects.end()) { return; } PendingDetect& pending = it.value(); pending.gitPending = false; AppModel* model = AppModel::instance(); if (model == nullptr) { return; } const QList<Workspace*>& workspaces = model->workspaces(); QList<int> indexes = pending.indexes; for (const int index : indexes) { const bool valid = index >= 0 && index < static_cast<int>(workspaces.size()) && workspaces.at(index) != nullptr && workspaces.at(index)->path() == path; if (!valid) { pending.indexes.removeOne(index); continue; } if (!pending.jjFound) { if (repoRoot.isEmpty()) { // Neither VCS: back to a plain directory. model->setWorkspaceRepoCapabilities(index, false, false); if (workspaces.at(index)->kind() != WorkspaceKind::Directory) { model->reseatWorkspaceKind(index, WorkspaceKind::Directory, QString(), QString()); } } else { // jj found nothing: git decides repository vs linked worktree. model->reseatWorkspaceKind(index, isWorktree ? WorkspaceKind::GitWorktree : WorkspaceKind::Repository, repoRoot, QString()); model->setWorkspaceRepoCapabilities(index, false, true); } } else { // Colocated jj workspace: the git probe only flips the git flag. model->setWorkspaceRepoCapabilities(index, true, true); } } if (pending.indexes.isEmpty() || (!pending.jjPending && !pending.gitPending)) { m_pendingDetects.erase(it); }}
void WorkspaceController::maybeFinishDetect(const QString& path, const PendingDetect& pending) { // The git result owns the final bookkeeping cleanup; jj only finishes // early when git already returned. if (!pending.gitPending) { m_pendingDetects.remove(path); }}
void WorkspaceController::addWorkspaceDirect(int kind, const QString& path, const QString& name, const QString& repoRoot) { AppModel* model = AppModel::instance(); if (model == nullptr) { return; } const WorkspaceKind wsKind = kindFromInt(kind); const QString resolvedName = !name.isEmpty() ? name : defaultNameFromPath(path); // The model appends and selects the new workspace itself. model->addWorkspace(wsKind, path, resolvedName, repoRoot); // Uniform detection: the probe re-derives kind + capabilities from the // path and reseats idempotently, whatever the requested kind was. detectWorkspaceKind(static_cast<int>(model->workspaces().size()) - 1);}
void WorkspaceController::addJjWorkspace(const QString& repoRoot) { requestPickList(QStringLiteral("jj"), repoRoot);}
void WorkspaceController::addGitWorktree(const QString& repoRoot) { requestPickList(QStringLiteral("git"), repoRoot);}
void WorkspaceController::pickJjWorkspace(int index) { const Workspace* ws = index >= 0 ? workspaceAt(index) : activeWorkspace(); if (ws == nullptr || ws->repoRoot().isEmpty()) { return; } addJjWorkspace(ws->repoRoot());}
void WorkspaceController::pickGitWorktree(int index) { const Workspace* ws = index >= 0 ? workspaceAt(index) : activeWorkspace(); if (ws == nullptr || ws->repoRoot().isEmpty()) { return; } addGitWorktree(ws->repoRoot());}
const Workspace* WorkspaceController::activeWorkspace() { AppModel* model = AppModel::instance(); if (model == nullptr) { return nullptr; } const QString activeId = model->activeWorkspaceId(); for (Workspace* ws : model->workspaces()) { if (ws != nullptr && ws->id() == activeId) { return ws; } } return nullptr;}
const Workspace* WorkspaceController::workspaceAt(int index) { AppModel* model = AppModel::instance(); if (model == nullptr || index < 0 || index >= static_cast<int>(model->workspaces().size())) { return nullptr; } return model->workspaces().at(index);}
void WorkspaceController::requestPickList(const QString& mode, const QString& repoRoot) { m_pickMode = mode; m_pickRepoRoot = repoRoot; m_pickItems.clear(); if (mode == QStringLiteral("jj")) { m_jjBridge->listWorkspaces(repoRoot); } else { m_gitBridge->listWorktrees(repoRoot); }}
void WorkspaceController::addPickedWorkspace(const QString& mode, const QVariantMap& item) { AppModel* model = AppModel::instance(); if (model == nullptr) { return; } const WorkspaceKind kind = (mode == QStringLiteral("jj")) ? WorkspaceKind::JjWorkspace : WorkspaceKind::GitWorktree; const QString path = item.value(QStringLiteral("path")).toString(); const QString name = item.value(QStringLiteral("name")).toString(); const QString resolvedName = !name.isEmpty() ? name : defaultNameFromPath(path); model->addWorkspace(kind, path, resolvedName, m_pickRepoRoot);}
void WorkspaceController::addMemorableWorkspace(const QString& mode) { const QString repoRoot = m_pickRepoRoot; const QString base = NameGenerator::memorable(); QString name = base; int n = 2; const auto collides = [this](const QString& candidate) -> bool { for (const QVariant& itemVariant : m_pickItems) { const QVariantMap item = itemVariant.toMap(); if (item.value(QStringLiteral("name")).toString() == candidate) { return true; } } return PathFuzzy::isDir(ConfigPaths::workspacesDir() + QStringLiteral("/") + candidate); }; while (collides(name)) { name = QStringLiteral("%1-%2").arg(base).arg(n); ++n; } ConfigPaths::ensureWorkspacesDir(); m_pendingAddMode = mode; const QString dest = ConfigPaths::workspacesDir() + QStringLiteral("/") + name; if (mode == QStringLiteral("jj")) { m_jjBridge->addWorkspace(repoRoot, name, dest); } else { m_gitBridge->addWorktree(repoRoot, dest, name); }}
void WorkspaceController::renameWorkspace(int index, const QString& newName) { AppModel* model = AppModel::instance(); if (model == nullptr || index < 0) { return; } const QList<Workspace*>& workspaces = model->workspaces(); if (index >= static_cast<int>(workspaces.size())) { return; } Workspace* ws = workspaces.at(index); if (ws == nullptr) { return; } const QString oldName = ws->name(); model->renameWorkspace(index, newName); if (ws->kind() == WorkspaceKind::JjWorkspace && !ws->path().isEmpty()) { m_renameTarget = index; m_renameOldName = oldName; m_jjBridge->renameWorkspace(ws->path(), newName); }}
void WorkspaceController::forgetJjWorkspace(int index) { AppModel* model = AppModel::instance(); if (model == nullptr || index < 0) { return; } const QList<Workspace*>& workspaces = model->workspaces(); if (index >= static_cast<int>(workspaces.size())) { return; } Workspace* ws = workspaces.at(index); if (ws == nullptr) { return; } const WorkspaceKind kind = ws->kind(); if (kind != WorkspaceKind::Repository && kind != WorkspaceKind::JjWorkspace) { return; } m_forgetTarget = index; m_jjBridge->forgetWorkspace(ws->repoRoot(), ws->jjWorkspaceName());}
void WorkspaceController::setWorkspaceDirectory(int index) { emit setDirectoryRequested(index);}
QString WorkspaceController::defaultNameFromPath(const QString& path) { if (path.isEmpty()) { return tr("workspace"); } const QString last = path.section(QLatin1Char('/'), -1); return last.isEmpty() ? tr("workspace") : last;}
QString WorkspaceController::localPath(const QUrl& url) { // Shell.qml localPath port: strip a file:// prefix, then percent-decode. QString s = url.toString(); if (s.startsWith(QStringLiteral("file://"))) { s = s.mid(static_cast<int>(QStringLiteral("file://").size())); } return QUrl::fromPercentEncoding(s.toUtf8());}
QString WorkspaceController::currentDir() { AppModel* model = AppModel::instance(); if (model != nullptr) { const QString activeId = model->activeWorkspaceId(); for (Workspace* ws : model->workspaces()) { if (ws != nullptr && ws->id() == activeId) { const QString path = ws->path(); if (!path.isEmpty()) { return path; } break; } } } return PathFuzzy::homeDir();}
// --- JjBridge async results -------------------------------------------------
void WorkspaceController::onJjWorkspacesListed(const QVariantList& items) { if (m_pickMode != QStringLiteral("jj")) { return; } // JjBridge emits {name, root}; normalize to the {name, path} shape the // pick dialog consumes. QVariantList normalized; normalized.reserve(items.size()); for (const QVariant& itemVariant : items) { const QVariantMap item = itemVariant.toMap(); QVariantMap entry; entry.insert(QStringLiteral("name"), item.value(QStringLiteral("name"))); entry.insert(QStringLiteral("path"), item.value(QStringLiteral("root"))); normalized.append(entry); } emit jjWorkspacesReady(normalized); m_pickMode.clear();}
void WorkspaceController::onWorkspaceAdded(bool ok, const QString& error, const QString& root, const QString& name) { if (m_pendingAddMode != QStringLiteral("jj")) { return; } if (ok) { QVariantMap item; item.insert(QStringLiteral("path"), root); item.insert(QStringLiteral("name"), name); addPickedWorkspace(QStringLiteral("jj"), item); emit pickDialogClose(); } else { emit pickDialogError(error); } m_pendingAddMode.clear();}
void WorkspaceController::onWorkspaceRenamed(bool ok, const QString& /*error*/) { if (!ok && m_renameTarget >= 0) { // Revert the optimistic rename on jj failure. AppModel* model = AppModel::instance(); if (model != nullptr && m_renameTarget < static_cast<int>(model->workspaces().size())) { model->renameWorkspace(m_renameTarget, m_renameOldName); } } m_renameTarget = -1; m_renameOldName.clear();}
void WorkspaceController::onWorkspaceForgotten(bool ok, const QString& /*error*/) { if (ok && m_forgetTarget >= 0) { AppModel* model = AppModel::instance(); if (model != nullptr && m_forgetTarget < static_cast<int>(model->workspaces().size())) { // JjWorkspace -> RepositoryWorkspace reseat (forget in jj terms). model->forgetJjWorkspace(m_forgetTarget); } } m_forgetTarget = -1;}
// --- GitBridge async results -------------------------------------------------
void WorkspaceController::onGitWorktreesListed(const QVariantList& items) { if (m_pickMode != QStringLiteral("git")) { return; } // GitBridge already emits {name, path}; pass through unchanged. emit gitWorktreesReady(items); m_pickMode.clear();}
void WorkspaceController::onWorktreeAdded(bool ok, const QString& error, const QString& root, const QString& name) { if (m_pendingAddMode != QStringLiteral("git")) { return; } if (ok) { QVariantMap item; item.insert(QStringLiteral("path"), root); item.insert(QStringLiteral("name"), name); addPickedWorkspace(QStringLiteral("git"), item); emit pickDialogClose(); } else { emit pickDialogError(error); } m_pendingAddMode.clear();}