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=<cleanup command> %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.
This commit is contained in:
Levi Neely 2026-07-22 16:09:07 +02:00
parent 460247e8a0
commit 029b1885a6
2 changed files with 550 additions and 0 deletions

View File

@ -23,6 +23,7 @@
#include <QComboBox> #include <QComboBox>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileDialog>
#include <QFileInfo> #include <QFileInfo>
#include <QFileSystemWatcher> #include <QFileSystemWatcher>
#include <QFont> #include <QFont>
@ -111,6 +112,12 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin
m_repoLabel->setStyleSheet(QStringLiteral("font-weight: bold; padding: 2px 4px;")); m_repoLabel->setStyleSheet(QStringLiteral("font-weight: bold; padding: 2px 4px;"));
mainLayout->addWidget(m_repoLabel); 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 --- // --- Status bar widget ---
m_statusBarWidget = nullptr; m_statusBarWidget = nullptr;
if (auto *win = qobject_cast<QMainWindow *>(m_mainWindow->window())) { if (auto *win = qobject_cast<QMainWindow *>(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; OptionalTab wtOpt; wtOpt.widget = worktreeTab; wtOpt.icon = QIcon::fromTheme(QStringLiteral("folder-sync")); wtOpt.label = QStringLiteral("Worktrees"); wtOpt.visible = true;
m_optionalTabs.append(wtOpt); 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 ===== // ===== LOG TAB =====
auto *logTab = new QWidget; auto *logTab = new QWidget;
auto *logLayout = new QVBoxLayout(logTab); auto *logLayout = new QVBoxLayout(logTab);
@ -404,6 +440,39 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin
connect(wtPruneBtn, &QToolButton::clicked, this, &GitPlusPlusView::pruneWorktrees); connect(wtPruneBtn, &QToolButton::clicked, this, &GitPlusPlusView::pruneWorktrees);
connect(m_worktreeList, &QListWidget::customContextMenuRequested, this, &GitPlusPlusView::worktreeContextMenu); 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<int>::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 --- // --- Connections: Log tab ---
connect(logRefreshBtn, &QToolButton::clicked, this, &GitPlusPlusView::refreshLog); connect(logRefreshBtn, &QToolButton::clicked, this, &GitPlusPlusView::refreshLog);
connect(logDiffHeadBtn, &QToolButton::clicked, this, &GitPlusPlusView::logDiffAgainstHead); connect(logDiffHeadBtn, &QToolButton::clicked, this, &GitPlusPlusView::logDiffAgainstHead);
@ -478,6 +547,7 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin
QString newDir = projectBaseDir(); QString newDir = projectBaseDir();
if (!newDir.isEmpty() && newDir != m_gitDir) { if (!newDir.isEmpty() && newDir != m_gitDir) {
m_gitDir = newDir; m_gitDir = newDir;
detectWorkspace();
setupFileWatcher(); setupFileWatcher();
refreshAll(); refreshAll();
} }
@ -506,10 +576,12 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin
} }
// Initial refresh // Initial refresh
QMetaObject::invokeMethod(this, [this]() { detectWorkspace(); }, Qt::QueuedConnection);
QMetaObject::invokeMethod(this, &GitPlusPlusView::updateStatus, Qt::QueuedConnection); QMetaObject::invokeMethod(this, &GitPlusPlusView::updateStatus, Qt::QueuedConnection);
QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshBranches, Qt::QueuedConnection); QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshBranches, Qt::QueuedConnection);
QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshStashes, Qt::QueuedConnection); QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshStashes, Qt::QueuedConnection);
QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshWorktrees, 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, &GitPlusPlusView::refreshLog, Qt::QueuedConnection);
QMetaObject::invokeMethod(this, [this]() { setupFileWatcher(); }, Qt::QueuedConnection); QMetaObject::invokeMethod(this, [this]() { setupFileWatcher(); }, Qt::QueuedConnection);
} }
@ -579,6 +651,7 @@ void GitPlusPlusView::refreshAll()
refreshBranches(); refreshBranches();
refreshStashes(); refreshStashes();
refreshWorktrees(); refreshWorktrees();
refreshWorkspaces();
refreshLog(); refreshLog();
} }
@ -2130,4 +2203,459 @@ void GitPlusPlusView::worktreeContextMenu(const QPoint &pos)
menu.exec(m_worktreeList->mapToGlobal(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" #include "gitplusplus.moc"

View File

@ -29,6 +29,7 @@ class QTabWidget;
class QSortFilterProxyModel; class QSortFilterProxyModel;
class QStringListModel; class QStringListModel;
class QFileSystemWatcher; class QFileSystemWatcher;
class QListWidgetItem;
/** /**
* Git++ Plugin - standalone KTextEditor plugin providing git operations. * Git++ Plugin - standalone KTextEditor plugin providing git operations.
@ -93,6 +94,14 @@ private Q_SLOTS:
void pruneWorktrees(); void pruneWorktrees();
void worktreeContextMenu(const QPoint &pos); void worktreeContextMenu(const QPoint &pos);
// Workspaces
void refreshWorkspaces();
void createWorkspace();
void removeWorkspace();
void pruneWorkspaces();
void switchWorkspace(QListWidgetItem *item);
void workspaceContextMenu(const QPoint &pos);
// Stash // Stash
void stashSave(); void stashSave();
void stashApply(); void stashApply();
@ -126,6 +135,9 @@ private:
QStringList selectedFiles(bool staged) const; QStringList selectedFiles(bool staged) const;
QString selectedBranch() const; QString selectedBranch() const;
QString selectedStash() const; QString selectedStash() const;
void detectWorkspace();
QString resolveWorkspaceSource() const;
QStringList workspaceRepoPaths() const;
GitPlusPlusPlugin *m_plugin; GitPlusPlusPlugin *m_plugin;
KTextEditor::MainWindow *m_mainWindow; KTextEditor::MainWindow *m_mainWindow;
@ -170,6 +182,10 @@ private:
// --- Worktrees tab --- // --- Worktrees tab ---
QListWidget *m_worktreeList; QListWidget *m_worktreeList;
// --- Workspaces tab ---
QListWidget *m_workspaceList;
QComboBox *m_repoCombo;
// --- Log tab --- // --- Log tab ---
QListWidget *m_logList; QListWidget *m_logList;
@ -200,6 +216,12 @@ private:
int m_spinnerFrame = 0; int m_spinnerFrame = 0;
QTimer m_fsDebounce; QTimer m_fsDebounce;
QFileSystemWatcher *m_watcher = nullptr; 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 #endif // GITPLUSPLUS_H