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:
parent
460247e8a0
commit
029b1885a6
528
gitplusplus.cpp
528
gitplusplus.cpp
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue