From 029b1885a604742bc53bd85af4f517333efe37f4 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 22 Jul 2026 16:09:07 +0200 Subject: [PATCH] Add workspace support: multi-repo detection, create, remove, prune Supports directories containing multiple git repos grouped by task/release. Detects workspaces via .kateworkspace marker, .gitmodules (submodule monorepo), or heuristic (2+ sibling git repos in parent dir). New Workspaces tab lists peer workspaces with branch info. Repo combo box switches active repo within a workspace. .kateworkspace format (INI): [workspace] source=. # primary source=../r7.20 # derived [hooks] OnRemove= %d Create workspace: pick source dir, enter name (=branch), fans out git worktree add across all repos, copies .kateworkspace. Remove workspace: runs OnRemove hook, git worktree remove per repo, rmdir. Prune: git worktree prune across all source repos. --- gitplusplus.cpp | 528 ++++++++++++++++++++++++++++++++++++++++++++++++ gitplusplus.h | 22 ++ 2 files changed, 550 insertions(+) diff --git a/gitplusplus.cpp b/gitplusplus.cpp index cf65f6a..8f42d67 100644 --- a/gitplusplus.cpp +++ b/gitplusplus.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -111,6 +112,12 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin m_repoLabel->setStyleSheet(QStringLiteral("font-weight: bold; padding: 2px 4px;")); mainLayout->addWidget(m_repoLabel); + // --- Repo selector (visible only in workspace mode) --- + m_repoCombo = new QComboBox(container); + m_repoCombo->setToolTip(QStringLiteral("Switch active repository within workspace")); + m_repoCombo->hide(); + mainLayout->addWidget(m_repoCombo); + // --- Status bar widget --- m_statusBarWidget = nullptr; if (auto *win = qobject_cast(m_mainWindow->window())) { @@ -314,6 +321,35 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin OptionalTab wtOpt; wtOpt.widget = worktreeTab; wtOpt.icon = QIcon::fromTheme(QStringLiteral("folder-sync")); wtOpt.label = QStringLiteral("Worktrees"); wtOpt.visible = true; m_optionalTabs.append(wtOpt); + // ===== WORKSPACES TAB ===== + auto *workspaceTab = new QWidget; + auto *workspaceLayout = new QVBoxLayout(workspaceTab); + workspaceLayout->setContentsMargins(0, 0, 0, 0); + workspaceLayout->setSpacing(4); + + auto *wsToolbar = new QHBoxLayout; + wsToolbar->setSpacing(2); + wsToolbar->setContentsMargins(0, 0, 0, 0); + + auto *wsRefreshBtn = makeToolButton(QStringLiteral("view-refresh"), QStringLiteral("Refresh workspaces")); + auto *wsCreateBtn = makeToolButton(QStringLiteral("folder-new"), QStringLiteral("Create workspace")); + auto *wsRemoveBtn = makeToolButton(QStringLiteral("edit-delete"), QStringLiteral("Remove selected workspace")); + auto *wsPruneBtn = makeToolButton(QStringLiteral("edit-clear-all"), QStringLiteral("Prune stale workspaces")); + + wsToolbar->addWidget(wsRefreshBtn); + wsToolbar->addWidget(wsCreateBtn); + wsToolbar->addWidget(wsRemoveBtn); + wsToolbar->addWidget(wsPruneBtn); + wsToolbar->addStretch(); + workspaceLayout->addLayout(wsToolbar); + + m_workspaceList = new QListWidget(workspaceTab); + m_workspaceList->setContextMenuPolicy(Qt::CustomContextMenu); + workspaceLayout->addWidget(m_workspaceList, 1); + + OptionalTab wsOpt; wsOpt.widget = workspaceTab; wsOpt.icon = QIcon::fromTheme(QStringLiteral("folder-documents")); wsOpt.label = QStringLiteral("Workspaces"); wsOpt.visible = true; + m_optionalTabs.append(wsOpt); + // ===== LOG TAB ===== auto *logTab = new QWidget; auto *logLayout = new QVBoxLayout(logTab); @@ -404,6 +440,39 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin connect(wtPruneBtn, &QToolButton::clicked, this, &GitPlusPlusView::pruneWorktrees); connect(m_worktreeList, &QListWidget::customContextMenuRequested, this, &GitPlusPlusView::worktreeContextMenu); + // --- Connections: Workspaces tab --- + connect(wsRefreshBtn, &QToolButton::clicked, this, &GitPlusPlusView::refreshWorkspaces); + connect(wsCreateBtn, &QToolButton::clicked, this, &GitPlusPlusView::createWorkspace); + connect(wsRemoveBtn, &QToolButton::clicked, this, &GitPlusPlusView::removeWorkspace); + connect(wsPruneBtn, &QToolButton::clicked, this, &GitPlusPlusView::pruneWorkspaces); + connect(m_workspaceList, &QListWidget::itemDoubleClicked, this, &GitPlusPlusView::switchWorkspace); + connect(m_workspaceList, &QListWidget::customContextMenuRequested, this, &GitPlusPlusView::workspaceContextMenu); + + // --- Connections: Repo combo (workspace mode) --- + connect(m_repoCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { + if (index < 0 || !m_isWorkspace) return; + QString repoPath = m_repoCombo->itemData(index).toString(); + if (!repoPath.isEmpty() && repoPath != m_gitDir) { + m_gitDir = repoPath; + + // Switch Kate's project view to this repo first + QString kateProjectFile = repoPath + QStringLiteral("/.kateproject"); + if (QFileInfo::exists(kateProjectFile)) { + m_mainWindow->openUrl(QUrl::fromLocalFile(kateProjectFile)); + } else { + // Open any file to trigger project detection + QDir dir(repoPath); + QStringList files = dir.entryList(QDir::Files, QDir::Name); + if (!files.isEmpty()) + m_mainWindow->openUrl(QUrl::fromLocalFile(dir.absoluteFilePath(files.first()))); + } + + // Refresh git state for the new repo + setupFileWatcher(); + refreshAll(); + } + }); + // --- Connections: Log tab --- connect(logRefreshBtn, &QToolButton::clicked, this, &GitPlusPlusView::refreshLog); connect(logDiffHeadBtn, &QToolButton::clicked, this, &GitPlusPlusView::logDiffAgainstHead); @@ -478,6 +547,7 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin QString newDir = projectBaseDir(); if (!newDir.isEmpty() && newDir != m_gitDir) { m_gitDir = newDir; + detectWorkspace(); setupFileWatcher(); refreshAll(); } @@ -506,10 +576,12 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin } // Initial refresh + QMetaObject::invokeMethod(this, [this]() { detectWorkspace(); }, Qt::QueuedConnection); QMetaObject::invokeMethod(this, &GitPlusPlusView::updateStatus, Qt::QueuedConnection); QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshBranches, Qt::QueuedConnection); QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshStashes, Qt::QueuedConnection); QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshWorktrees, Qt::QueuedConnection); + QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshWorkspaces, Qt::QueuedConnection); QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshLog, Qt::QueuedConnection); QMetaObject::invokeMethod(this, [this]() { setupFileWatcher(); }, Qt::QueuedConnection); } @@ -579,6 +651,7 @@ void GitPlusPlusView::refreshAll() refreshBranches(); refreshStashes(); refreshWorktrees(); + refreshWorkspaces(); refreshLog(); } @@ -2130,4 +2203,459 @@ void GitPlusPlusView::worktreeContextMenu(const QPoint &pos) menu.exec(m_worktreeList->mapToGlobal(pos)); } +// ============================================================================ +// Workspaces +// ============================================================================ + +void GitPlusPlusView::detectWorkspace() +{ + m_isWorkspace = false; + m_workspaceDir.clear(); + m_workspaceSourceDir.clear(); + m_workspaceRepos.clear(); + + if (m_gitDir.isEmpty()) + m_gitDir = projectBaseDir(); + if (m_gitDir.isEmpty()) { + m_repoCombo->hide(); + return; + } + + // Walk up from m_gitDir to find .kateworkspace + // m_gitDir is typically a repo subdir inside a workspace, or the workspace itself + QDir dir(m_gitDir); + + // Check if the parent directory has .kateworkspace (m_gitDir is a repo inside a workspace) + QDir parent(m_gitDir); + parent.cdUp(); + QString wsFile = parent.absoluteFilePath(QStringLiteral(".kateworkspace")); + if (QFileInfo::exists(wsFile)) { + m_workspaceDir = parent.absolutePath(); + } else { + // Check if m_gitDir itself is a workspace dir (e.g. projectBaseDir returned the ws root) + wsFile = dir.absoluteFilePath(QStringLiteral(".kateworkspace")); + if (QFileInfo::exists(wsFile)) { + m_workspaceDir = dir.absolutePath(); + } else { + // Heuristic: parent dir has no .git but has 2+ child dirs with .git + if (!QFileInfo::exists(parent.absoluteFilePath(QStringLiteral(".git")))) { + int gitCount = 0; + const auto entries = parent.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const auto &entry : entries) { + if (QFileInfo::exists(parent.absoluteFilePath(entry + QStringLiteral("/.git")))) + ++gitCount; + } + if (gitCount >= 2) + m_workspaceDir = parent.absolutePath(); + } + } + } + + if (m_workspaceDir.isEmpty()) { + // Check for submodule monorepo: .gitmodules in m_gitDir + if (QFileInfo::exists(m_gitDir + QStringLiteral("/.gitmodules"))) { + m_isWorkspace = true; + m_workspaceDir = m_gitDir; + m_workspaceSourceDir = m_gitDir; + // Parse .gitmodules for submodule paths + QFile modules(m_gitDir + QStringLiteral("/.gitmodules")); + if (modules.open(QIODevice::ReadOnly | QIODevice::Text)) { + while (!modules.atEnd()) { + QString line = QString::fromUtf8(modules.readLine()).trimmed(); + if (line.startsWith(QStringLiteral("path"))) { + QString path = line.section(QLatin1Char('='), 1).trimmed(); + if (!path.isEmpty()) + m_workspaceRepos.append(m_gitDir + QLatin1Char('/') + path); + } + } + modules.close(); + } + // Include root repo itself + m_workspaceRepos.prepend(m_gitDir); + } else { + m_repoCombo->hide(); + return; + } + } else { + m_isWorkspace = true; + m_workspaceSourceDir = resolveWorkspaceSource(); + m_workspaceRepos = workspaceRepoPaths(); + } + + // Populate repo combo + m_repoCombo->blockSignals(true); + m_repoCombo->clear(); + for (const auto &repoPath : qAsConst(m_workspaceRepos)) { + QString name = QFileInfo(repoPath).fileName(); + m_repoCombo->addItem(name, repoPath); + } + // Select current m_gitDir in combo + for (int i = 0; i < m_repoCombo->count(); ++i) { + if (m_repoCombo->itemData(i).toString() == m_gitDir) { + m_repoCombo->setCurrentIndex(i); + break; + } + } + m_repoCombo->blockSignals(false); + m_repoCombo->show(); +} + +QString GitPlusPlusView::resolveWorkspaceSource() const +{ + if (m_workspaceDir.isEmpty()) return {}; + + QString wsFile = m_workspaceDir + QStringLiteral("/.kateworkspace"); + if (!QFileInfo::exists(wsFile)) + return m_workspaceDir; // No config file: treat self as source + + QSettings ws(wsFile, QSettings::IniFormat); + ws.beginGroup(QStringLiteral("workspace")); + QString source = ws.value(QStringLiteral("source"), QStringLiteral(".")).toString(); + ws.endGroup(); + + if (source == QLatin1Char('.') || source.isEmpty()) + return m_workspaceDir; + + // Resolve relative to workspace dir + QDir wsDir(m_workspaceDir); + return QDir::cleanPath(wsDir.absoluteFilePath(source)); +} + +QStringList GitPlusPlusView::workspaceRepoPaths() const +{ + // Enumerate subdirs of m_workspaceDir that contain .git + QStringList repos; + QDir dir(m_workspaceDir); + const auto entries = dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + for (const auto &entry : entries) { + QString subPath = dir.absoluteFilePath(entry); + if (QFileInfo::exists(subPath + QStringLiteral("/.git"))) + repos.append(subPath); + } + return repos; +} + +void GitPlusPlusView::refreshWorkspaces() +{ + m_workspaceList->clear(); + if (!m_isWorkspace) return; + + // Find parent of all workspaces (parent of m_workspaceDir for multi-repo, + // or parent of root for submodule monorepo) + QDir wsDir(m_workspaceDir); + QDir parentDir(m_workspaceDir); + parentDir.cdUp(); + + // Scan for sibling workspace directories + const auto entries = parentDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + for (const auto &entry : entries) { + QString sibPath = parentDir.absoluteFilePath(entry); + // A workspace is a dir with .kateworkspace, or a dir with 2+ git repos inside + bool isWs = QFileInfo::exists(sibPath + QStringLiteral("/.kateworkspace")); + if (!isWs) { + // Heuristic: contains 2+ git repos and no .git at top level + if (!QFileInfo::exists(sibPath + QStringLiteral("/.git"))) { + int cnt = 0; + QDir sub(sibPath); + const auto subEntries = sub.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const auto &se : subEntries) { + if (QFileInfo::exists(sub.absoluteFilePath(se + QStringLiteral("/.git")))) + ++cnt; + } + isWs = (cnt >= 2); + } + } + + if (!isWs) continue; + + // Get branch info from the first git repo inside + QString branch; + QDir sub(sibPath); + const auto subEntries = sub.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const auto &se : subEntries) { + QString repoPath = sub.absoluteFilePath(se); + if (QFileInfo::exists(repoPath + QStringLiteral("/.git"))) { + QByteArray out = GitProcess::run(repoPath, {QStringLiteral("branch"), QStringLiteral("--show-current")}); + branch = QString::fromUtf8(out).trimmed(); + break; + } + } + + QString display = entry; + if (!branch.isEmpty()) + display += QStringLiteral(" [") + branch + QLatin1Char(']'); + + auto *item = new QListWidgetItem(display, m_workspaceList); + item->setData(Qt::UserRole, sibPath); + + // Mark current workspace + if (sibPath == m_workspaceDir) { + QFont f = item->font(); + f.setBold(true); + item->setFont(f); + item->setText(QStringLiteral("\u25cf ") + display); + } + } +} + +void GitPlusPlusView::createWorkspace() +{ + // 1. Select source workspace directory + QString sourceDir = QFileDialog::getExistingDirectory( + m_toolView, QStringLiteral("Select source workspace (containing repos)"), + m_workspaceSourceDir.isEmpty() ? m_workspaceDir : m_workspaceSourceDir); + if (sourceDir.isEmpty()) return; + + // Verify it contains git repos + QDir source(sourceDir); + QStringList sourceRepos; + const auto entries = source.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + for (const auto &entry : entries) { + if (QFileInfo::exists(source.absoluteFilePath(entry + QStringLiteral("/.git")))) + sourceRepos.append(entry); + } + if (sourceRepos.isEmpty()) { + setMessage(QStringLiteral("No git repos found in: ") + sourceDir, true); + return; + } + + // 2. Get workspace name from user + bool ok; + QString name = QInputDialog::getText(m_toolView, QStringLiteral("Create Workspace"), + QStringLiteral("Workspace name (also used as branch name):"), + QLineEdit::Normal, {}, &ok); + if (!ok || name.trimmed().isEmpty()) return; + name = name.trimmed(); + + // 3. Create workspace directory (sibling of source) + QDir parentDir(sourceDir); + parentDir.cdUp(); + QString newWsPath = parentDir.absoluteFilePath(name); + + if (QFileInfo::exists(newWsPath)) { + setMessage(QStringLiteral("Directory already exists: ") + newWsPath, true); + return; + } + if (!parentDir.mkpath(name)) { + setMessage(QStringLiteral("Failed to create directory: ") + newWsPath, true); + return; + } + + // 4. Copy .kateworkspace from source, updating source= to point to source dir + QString sourceWsFile = sourceDir + QStringLiteral("/.kateworkspace"); + QString newWsFile = newWsPath + QStringLiteral("/.kateworkspace"); + + // Determine relative path from new workspace to source + QDir newWsDir(newWsPath); + QString relSource = newWsDir.relativeFilePath(sourceDir); + + // If source has a .kateworkspace with source != ".", resolve to the ultimate source + if (QFileInfo::exists(sourceWsFile)) { + QSettings srcWs(sourceWsFile, QSettings::IniFormat); + srcWs.beginGroup(QStringLiteral("workspace")); + QString srcSource = srcWs.value(QStringLiteral("source"), QStringLiteral(".")).toString(); + srcWs.endGroup(); + if (srcSource != QLatin1Char('.') && !srcSource.isEmpty()) { + // Source is itself derived — resolve to ultimate source + QDir srcDir(sourceDir); + QString ultimateSource = QDir::cleanPath(srcDir.absoluteFilePath(srcSource)); + relSource = newWsDir.relativeFilePath(ultimateSource); + } + + // Copy the file first, then update source= + QFile::copy(sourceWsFile, newWsFile); + } + + // Write/update .kateworkspace + { + QSettings ws(newWsFile, QSettings::IniFormat); + ws.beginGroup(QStringLiteral("workspace")); + ws.setValue(QStringLiteral("source"), relSource); + ws.endGroup(); + ws.sync(); + } + + // 5. Fan out: create worktree for each repo + int succeeded = 0; + int failed = 0; + for (const auto &repoName : sourceRepos) { + QString sourceRepoPath = source.absoluteFilePath(repoName); + QString targetRepoPath = newWsPath + QLatin1Char('/') + repoName; + + // Check if branch exists in this repo + QByteArray branchCheck = GitProcess::run(sourceRepoPath, + {QStringLiteral("rev-parse"), QStringLiteral("--verify"), QStringLiteral("refs/heads/") + name}); + bool branchExists = !branchCheck.isEmpty(); + + QStringList args; + args << QStringLiteral("worktree") << QStringLiteral("add"); + if (branchExists) { + args << targetRepoPath << name; + } else { + args << QStringLiteral("-b") << name << targetRepoPath; + } + + QByteArray out = GitProcess::run(sourceRepoPath, args); + // GitProcess::run returns empty on failure, but worktree add writes to stderr + // Check if the directory was created + if (QFileInfo::exists(targetRepoPath + QStringLiteral("/.git"))) { + ++succeeded; + } else { + ++failed; + } + } + + // 6. Write .kateproject in new workspace + QFile kateProject(newWsPath + QStringLiteral("/.kateproject")); + if (kateProject.open(QIODevice::WriteOnly | QIODevice::Text)) { + kateProject.write("{\n \"name\": \"" + name.toUtf8() + "\",\n \"files\": [ { \"git\": true } ]\n}\n"); + kateProject.close(); + } + + if (failed == 0) { + setMessage(QStringLiteral("Workspace created: %1 (%2 repos)").arg(name).arg(succeeded)); + } else { + setMessage(QStringLiteral("Workspace created with errors: %1 ok, %2 failed").arg(succeeded).arg(failed), true); + } + + refreshWorkspaces(); +} + +void GitPlusPlusView::removeWorkspace() +{ + auto *item = m_workspaceList->currentItem(); + if (!item) { + setMessage(QStringLiteral("No workspace selected"), true); + return; + } + + QString wsPath = item->data(Qt::UserRole).toString(); + if (wsPath.isEmpty()) return; + + // Don't allow removing current workspace + if (wsPath == m_workspaceDir) { + setMessage(QStringLiteral("Cannot remove the workspace you are currently in"), true); + return; + } + + if (QMessageBox::question(m_toolView, QStringLiteral("Remove Workspace"), + QStringLiteral("Remove workspace at:\n%1\n\nThis will remove all worktrees and delete the directory.").arg(wsPath), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) + return; + + // Read OnRemove hook from target workspace's .kateworkspace + QString wsFile = wsPath + QStringLiteral("/.kateworkspace"); + QString onRemove; + if (QFileInfo::exists(wsFile)) { + QSettings ws(wsFile, QSettings::IniFormat); + ws.beginGroup(QStringLiteral("hooks")); + onRemove = ws.value(QStringLiteral("OnRemove")).toString(); + ws.endGroup(); + } + + // Execute OnRemove hook if set + if (!onRemove.isEmpty()) { + QString cmd = onRemove; + cmd.replace(QStringLiteral("%d"), wsPath); + int ret = QProcess::execute(QStringLiteral("/bin/sh"), {QStringLiteral("-c"), cmd}); + if (ret != 0) { + setMessage(QStringLiteral("OnRemove hook failed (exit %1): %2").arg(ret).arg(cmd), true); + // Continue anyway — try to clean up what we can + } + } + + // Remove git worktrees for each repo subdir (if directory still exists) + if (QFileInfo::exists(wsPath)) { + QDir wsDir(wsPath); + const auto entries = wsDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const auto &entry : entries) { + QString repoPath = wsDir.absoluteFilePath(entry); + if (QFileInfo::exists(repoPath + QStringLiteral("/.git"))) { + // Find the source repo to run worktree remove from + // We can run it from within the worktree itself + GitProcess::run(repoPath, {QStringLiteral("worktree"), QStringLiteral("remove"), + QStringLiteral("--force"), repoPath}); + } + } + + // Remove the workspace directory + QDir(wsPath).removeRecursively(); + } + + setMessage(QStringLiteral("Removed workspace: ") + QFileInfo(wsPath).fileName()); + refreshWorkspaces(); +} + +void GitPlusPlusView::pruneWorkspaces() +{ + if (!m_isWorkspace) return; + + // Prune in each repo of the source workspace + QString sourceDir = m_workspaceSourceDir.isEmpty() ? m_workspaceDir : m_workspaceSourceDir; + QDir source(sourceDir); + const auto entries = source.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + int pruned = 0; + for (const auto &entry : entries) { + QString repoPath = source.absoluteFilePath(entry); + if (QFileInfo::exists(repoPath + QStringLiteral("/.git"))) { + GitProcess::run(repoPath, {QStringLiteral("worktree"), QStringLiteral("prune")}); + ++pruned; + } + } + + setMessage(QStringLiteral("Pruned worktrees in %1 repos").arg(pruned)); + refreshWorkspaces(); +} + +void GitPlusPlusView::switchWorkspace(QListWidgetItem *item) +{ + if (!item) return; + QString wsPath = item->data(Qt::UserRole).toString(); + if (wsPath.isEmpty() || wsPath == m_workspaceDir) return; + + // Try to open .kateproject from the workspace + QString kateProjectFile = wsPath + QStringLiteral("/.kateproject"); + if (QFileInfo::exists(kateProjectFile)) { + m_mainWindow->openUrl(QUrl::fromLocalFile(kateProjectFile)); + } else { + // Fall back: open any file from the first repo inside + QDir wsDir(wsPath); + const auto entries = wsDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const auto &entry : entries) { + QString repoPath = wsDir.absoluteFilePath(entry); + if (QFileInfo::exists(repoPath + QStringLiteral("/.git"))) { + QDir repo(repoPath); + QStringList files = repo.entryList(QDir::Files, QDir::Name); + if (!files.isEmpty()) { + m_mainWindow->openUrl(QUrl::fromLocalFile(repo.absoluteFilePath(files.first()))); + break; + } + } + } + } +} + +void GitPlusPlusView::workspaceContextMenu(const QPoint &pos) +{ + auto *item = m_workspaceList->itemAt(pos); + if (!item) return; + m_workspaceList->setCurrentItem(item); + + QString wsPath = item->data(Qt::UserRole).toString(); + bool isCurrent = (wsPath == m_workspaceDir); + + QMenu menu; + menu.addAction(QStringLiteral("Open"), this, [this, item]() { switchWorkspace(item); }); + menu.addAction(QStringLiteral("Copy path"), this, [wsPath]() { + QApplication::clipboard()->setText(wsPath); + }); + if (!isCurrent) { + menu.addSeparator(); + menu.addAction(QStringLiteral("Remove"), this, &GitPlusPlusView::removeWorkspace); + } + menu.addSeparator(); + menu.addAction(QStringLiteral("Prune all"), this, &GitPlusPlusView::pruneWorkspaces); + menu.exec(m_workspaceList->mapToGlobal(pos)); +} + #include "gitplusplus.moc" diff --git a/gitplusplus.h b/gitplusplus.h index 6b561b7..283924d 100644 --- a/gitplusplus.h +++ b/gitplusplus.h @@ -29,6 +29,7 @@ class QTabWidget; class QSortFilterProxyModel; class QStringListModel; class QFileSystemWatcher; +class QListWidgetItem; /** * Git++ Plugin - standalone KTextEditor plugin providing git operations. @@ -93,6 +94,14 @@ private Q_SLOTS: void pruneWorktrees(); void worktreeContextMenu(const QPoint &pos); + // Workspaces + void refreshWorkspaces(); + void createWorkspace(); + void removeWorkspace(); + void pruneWorkspaces(); + void switchWorkspace(QListWidgetItem *item); + void workspaceContextMenu(const QPoint &pos); + // Stash void stashSave(); void stashApply(); @@ -126,6 +135,9 @@ private: QStringList selectedFiles(bool staged) const; QString selectedBranch() const; QString selectedStash() const; + void detectWorkspace(); + QString resolveWorkspaceSource() const; + QStringList workspaceRepoPaths() const; GitPlusPlusPlugin *m_plugin; KTextEditor::MainWindow *m_mainWindow; @@ -170,6 +182,10 @@ private: // --- Worktrees tab --- QListWidget *m_worktreeList; + // --- Workspaces tab --- + QListWidget *m_workspaceList; + QComboBox *m_repoCombo; + // --- Log tab --- QListWidget *m_logList; @@ -200,6 +216,12 @@ private: int m_spinnerFrame = 0; QTimer m_fsDebounce; QFileSystemWatcher *m_watcher = nullptr; + + // Workspace state + bool m_isWorkspace = false; + QString m_workspaceDir; // this workspace's directory + QString m_workspaceSourceDir; // resolved source workspace path + QStringList m_workspaceRepos; // repo subdirs in source workspace }; #endif // GITPLUSPLUS_H